From fef4cac1cf74b0451df9914b42c33d85ca020388 Mon Sep 17 00:00:00 2001 From: strNophix Date: Sun, 19 Jun 2022 21:10:05 +0200 Subject: [PATCH] Connected service management front to back; migrated to formik --- api/models.py | 44 ++++- api/schema/__init__.py | 28 +-- api/schema/definitions/auth.py | 3 +- api/schema/definitions/service.py | 105 ++++++++++++ api/schema/definitions/user.py | 4 +- api/seed.py | 21 ++- api/token.py | 5 +- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 52 ++++-- frontend/src/App.tsx | 2 - frontend/src/components/atoms/Button.tsx | 6 +- frontend/src/components/atoms/Input.tsx | 6 +- frontend/src/components/layouts/Container.tsx | 11 +- frontend/src/components/layouts/Dashboard.tsx | 2 +- frontend/src/components/molecules/Sidebar.tsx | 23 ++- .../src/components/molecules/TextField.tsx | 2 + frontend/src/components/pages/login/index.tsx | 60 ++++--- .../src/components/pages/middleware/index.tsx | 11 -- .../src/components/pages/services/index.tsx | 159 +++++++++++++----- frontend/src/hooks/.gitkeep | 0 frontend/src/hooks/useServices.tsx | 19 --- frontend/src/queries/auth.graphql | 13 ++ frontend/src/queries/services.graphql | 37 ++-- main.py | 1 + 24 files changed, 454 insertions(+), 162 deletions(-) create mode 100644 api/schema/definitions/service.py delete mode 100644 frontend/src/components/pages/middleware/index.tsx create mode 100644 frontend/src/hooks/.gitkeep delete mode 100644 frontend/src/hooks/useServices.tsx create mode 100644 frontend/src/queries/auth.graphql diff --git a/api/models.py b/api/models.py index 07805a0..6115ff2 100644 --- a/api/models.py +++ b/api/models.py @@ -1,17 +1,55 @@ -from sqlalchemy import Column +from sqlalchemy import Column, ForeignKey from sqlalchemy import Integer from sqlalchemy import VARCHAR +from sqlalchemy.orm import relationship from api.database import Base from api.database import engine -class UserModel(Base): +class User(Base): __tablename__ = "user" - id = Column(Integer, primary_key=True, index=True, nullable=False) + id = Column(Integer, primary_key=True, index=True) name = Column(VARCHAR(length=32), nullable=False, unique=True) password_hash = Column(VARCHAR, nullable=False) +class Service(Base): + __tablename__ = "service" + + id = Column(Integer, primary_key=True, index=True) + name = Column(VARCHAR, nullable=False, unique=True) + + # children + loadbalancer = relationship( + "Loadbalancer", cascade="all,delete", uselist=False, backref="service" + ) + + +class Loadbalancer(Base): + __tablename__ = "loadbalancer" + id = Column(Integer, primary_key=True, index=True) + + # parent + service_id = Column(Integer, ForeignKey("service.id")) + + # children + servers = relationship( + "LoadbalancerServers", + cascade="all,delete", + backref="loadbalancer", + lazy="dynamic", + ) + + +class LoadbalancerServers(Base): + __tablename__ = "loadbalancerservers" + id = Column(Integer, primary_key=True, index=True) + address = Column(VARCHAR, nullable=False) + + # parent + loadbalancer_id = Column(Integer, ForeignKey("loadbalancer.id")) + + Base.metadata.create_all(engine) diff --git a/api/schema/__init__.py b/api/schema/__init__.py index aabb02b..801c778 100644 --- a/api/schema/__init__.py +++ b/api/schema/__init__.py @@ -1,24 +1,32 @@ import strawberry -from api.schema.definitions.auth import AuthResult -from api.schema.definitions.auth import login -from api.schema.definitions.auth import update_me -from api.schema.definitions.common import CommonMessage +import api.schema.definitions.auth as auth +import api.schema.permissions as permissions +import api.schema.definitions.service as service from api.schema.extensions import extensions -from api.schema.permissions import IsAuthenticated + +user_perms = { + "permission_classes": [ + # permissions.IsAuthenticated + ] +} @strawberry.type class Query: - hello: str + # service + services = strawberry.field(resolver=service.get_services, **user_perms) @strawberry.type class Mutation: - login: AuthResult = strawberry.field(resolver=login) - update_me: CommonMessage = strawberry.field( - resolver=update_me, permission_classes=[IsAuthenticated] - ) + # auth + login = strawberry.field(resolver=auth.login) + update_me = strawberry.field(resolver=auth.update_me, **user_perms) + + # service + add_service = strawberry.field(resolver=service.add_service, **user_perms) + remove_service = strawberry.field(resolver=service.remove_service, **user_perms) schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=extensions) diff --git a/api/schema/definitions/auth.py b/api/schema/definitions/auth.py index 42a0bee..590ab06 100644 --- a/api/schema/definitions/auth.py +++ b/api/schema/definitions/auth.py @@ -2,11 +2,10 @@ from typing import TYPE_CHECKING import strawberry from fastapi import Request -from sqlalchemy import true from sqlalchemy.orm import Session from api.hasher import argon2_hasher -from api.models import UserModel +from api.models import User as UserModel from api.schema.definitions.common import CommonError from api.schema.definitions.common import CommonMessage from api.schema.definitions.user import User diff --git a/api/schema/definitions/service.py b/api/schema/definitions/service.py new file mode 100644 index 0000000..cd566fa --- /dev/null +++ b/api/schema/definitions/service.py @@ -0,0 +1,105 @@ +import strawberry +import typing +import api.models as models +from sqlalchemy.orm import Session + +if typing.TYPE_CHECKING: + from strawberry.types import Info + from api.schema import Query + + +@strawberry.input +class AddLoadbalancerInput: + servers: typing.List[str] + + +@strawberry.input +class AddServiceInput: + name: str + loadbalancer: AddLoadbalancerInput + + +@strawberry.input +class ServiceFilterInput: + id: typing.Optional[int] = None + search: typing.Optional[str] = None + + +@strawberry.type +class LoadbalancerServer: + id: int + address: str + + @classmethod + def from_instance(cls, instance: models.LoadbalancerServers): + return cls(id=instance.id, address=instance.address) + + +@strawberry.type +class Loadbalancer: + servers: typing.List[LoadbalancerServer] + + @classmethod + def from_instance(cls, instance: models.Loadbalancer): + if not instance: + return cls(servers=[]) + + return cls( + servers=[ + LoadbalancerServer.from_instance(server) for server in instance.servers + ] + ) + + +@strawberry.type +class Service: + id: int + name: str + loadbalancer: Loadbalancer + + @classmethod + def from_instance(cls, instance: models.Service): + return cls( + id=instance.id, + name=instance.name, + loadbalancer=Loadbalancer.from_instance(instance.loadbalancer), + ) + + +async def get_services( + root: "Query", info: "Info", body: typing.Optional[ServiceFilterInput] = None +) -> typing.List[Service]: + db: Session = info.context["db"] + stmt = db.query(models.Service) + + if body: + if body.id: + stmt = stmt.filter(models.Service.id == body.id) + elif body.search: + stmt = stmt.filter(models.Service.name.like("%" + body.search + "%")) + + services = [service[0] for service in db.execute(stmt).all()] + return [Service.from_instance(service) for service in services] + + +async def remove_service(root: "Query", info: "Info", id: int) -> None: + db: Session = info.context["db"] + db.query(models.Service).filter(models.Service.id == id).delete() + db.commit() + return None + + +async def add_service(root: "Query", info: "Info", body: AddServiceInput) -> Service: + db: Session = info.context["db"] + service = models.Service(name=body.name) + + if body.loadbalancer: + lb = models.Loadbalancer() + for server in body.loadbalancer.servers: + lb.servers.append(models.LoadbalancerServers(address=server)) + + service.loadbalancer = lb + + db.add(service) + db.commit() + return Service.from_instance(service) diff --git a/api/schema/definitions/user.py b/api/schema/definitions/user.py index d809cdc..0391b19 100644 --- a/api/schema/definitions/user.py +++ b/api/schema/definitions/user.py @@ -1,6 +1,6 @@ import strawberry -from api.models import UserModel +import api.models as models @strawberry.type @@ -9,5 +9,5 @@ class User: name: str @classmethod - def from_instance(cls, instance: UserModel): + def from_instance(cls, instance: models.User): return cls(id=instance.id, name=instance.name) diff --git a/api/seed.py b/api/seed.py index ce778af..e948320 100644 --- a/api/seed.py +++ b/api/seed.py @@ -2,13 +2,28 @@ from sqlalchemy.orm import Session from api.database import SessionLocal from api.hasher import argon2_hasher -from api.models import UserModel +import api.models as models def seed(): db: Session = SessionLocal() - if db.query(UserModel).count() == 0: - admin = UserModel(name="admin", password_hash=argon2_hasher.hash("admin")) + if db.query(models.User).count() == 0: + admin = models.User(name="admin", password_hash=argon2_hasher.hash("admin")) db.add(admin) db.commit() + + if db.query(models.Service).count() == 0: + lb = models.Loadbalancer() + lb.servers.append( + models.LoadbalancerServers(address="http://192.168.2.134:8000/") + ) + lb.servers.append( + models.LoadbalancerServers(address="http://192.168.2.132:80/") + ) + + test_service = models.Service(name="service") + test_service.loadbalancer = lb + + db.add(test_service) + db.commit() diff --git a/api/token.py b/api/token.py index bc01c7f..6b6d62a 100644 --- a/api/token.py +++ b/api/token.py @@ -5,10 +5,9 @@ from datetime import timedelta from datetime import timezone import jwt -from fastapi import Request from starlette.datastructures import Headers -from api.models import UserModel +import api.models as models if typing.TYPE_CHECKING: UserTokenData = dict[str, typing.Any] @@ -17,7 +16,7 @@ JWT_SECRET = os.getenv("JWT_SECRET", "") def encode_user_token( - user: UserModel, expire_in: timedelta = timedelta(hours=6) + user: models.User, expire_in: timedelta = timedelta(hours=6) ) -> str: payload = {} payload["id"] = user.id diff --git a/frontend/package.json b/frontend/package.json index f0c8091..1db8861 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,11 +16,11 @@ "@headlessui/react": "^1.6.3", "@heroicons/react": "^1.0.6", "clsx": "^1.1.1", + "formik": "^2.2.9", "graphql": "^16.5.0", "graphql-tag": "^2.12.6", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-hook-form": "^7.31.3", "react-query": "^3.39.0", "react-router-dom": "6" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ef522f8..0da705d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -15,12 +15,12 @@ specifiers: autoprefixer: ^10.4.7 clsx: ^1.1.1 eslint-plugin-react-hooks: ^4.3.0 + formik: ^2.2.9 graphql: ^16.5.0 graphql-tag: ^2.12.6 postcss: ^8.4.14 react: ^18.0.0 react-dom: ^18.0.0 - react-hook-form: ^7.31.3 react-query: ^3.39.0 react-router-dom: '6' tailwindcss: ^3.0.24 @@ -35,11 +35,11 @@ dependencies: '@headlessui/react': 1.6.3_ef5jwxihqo6n7gxfmzogljlgcm '@heroicons/react': 1.0.6_react@18.1.0 clsx: 1.1.1 + formik: 2.2.9_react@18.1.0 graphql: 16.5.0 graphql-tag: 2.12.6_graphql@16.5.0 react: 18.1.0 react-dom: 18.1.0_react@18.1.0 - react-hook-form: 7.31.3_react@18.1.0 react-query: 3.39.0_ef5jwxihqo6n7gxfmzogljlgcm react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm @@ -1960,6 +1960,11 @@ packages: engines: {node: '>=4.0.0'} dev: true + /deepmerge/2.2.1: + resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==} + engines: {node: '>=0.10.0'} + dev: false + /defaults/1.0.3: resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==} dependencies: @@ -2429,6 +2434,21 @@ packages: web-streams-polyfill: 4.0.0-beta.1 dev: true + /formik/2.2.9_react@18.1.0: + resolution: {integrity: sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==} + peerDependencies: + react: '>=16.8.0' + dependencies: + deepmerge: 2.2.1 + hoist-non-react-statics: 3.3.2 + lodash: 4.17.21 + lodash-es: 4.17.21 + react: 18.1.0 + react-fast-compare: 2.0.4 + tiny-warning: 1.0.3 + tslib: 1.14.1 + dev: false + /fraction.js/4.2.0: resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} dev: true @@ -2650,6 +2670,12 @@ packages: '@babel/runtime': 7.18.0 dev: false + /hoist-non-react-statics/3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + /http-cache-semantics/4.1.0: resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==} dev: true @@ -3042,6 +3068,10 @@ packages: p-locate: 4.1.0 dev: false + /lodash-es/4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + /lodash.get/4.4.2: resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=} dev: true @@ -3599,13 +3629,12 @@ packages: scheduler: 0.22.0 dev: false - /react-hook-form/7.31.3_react@18.1.0: - resolution: {integrity: sha512-NVZdCWViIWXXXlQ3jxVQH0NuNfwPf8A/0KvuCxrM9qxtP1qYosfR2ZudarziFrVOC7eTUbWbm1T4OyYCwv9oSQ==} - engines: {node: '>=12.22.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 - dependencies: - react: 18.1.0 + /react-fast-compare/2.0.4: + resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} + dev: false + + /react-is/16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false /react-query/3.39.0_ef5jwxihqo6n7gxfmzogljlgcm: @@ -4079,6 +4108,10 @@ packages: resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=} dev: true + /tiny-warning/1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + /title-case/3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} dependencies: @@ -4132,7 +4165,6 @@ packages: /tslib/1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - dev: true /tslib/2.3.1: resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a75601..33a4e59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,4 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import MiddlewarePage from "./components/pages/middleware"; import RoutersPage from "./components/pages/routers"; import SecretsPage from "./components/pages/secrets"; import ServicesPage from "./components/pages/services"; @@ -13,7 +12,6 @@ export default function App() { }> } /> } /> - } /> } /> } /> diff --git a/frontend/src/components/atoms/Button.tsx b/frontend/src/components/atoms/Button.tsx index 5fe5afd..0aefd89 100644 --- a/frontend/src/components/atoms/Button.tsx +++ b/frontend/src/components/atoms/Button.tsx @@ -12,10 +12,10 @@ interface InputProps { const getButtonSize = (size?: InputSize) => { switch (size) { - case "md": - return "px-[1rem] py-[.5rem] text-sm"; - default: + case "lg": return "px-10 py-4 text-base"; + default: + return "px-[1rem] py-[.5rem] text-sm"; } }; diff --git a/frontend/src/components/atoms/Input.tsx b/frontend/src/components/atoms/Input.tsx index 6f5ca87..afcbcf1 100644 --- a/frontend/src/components/atoms/Input.tsx +++ b/frontend/src/components/atoms/Input.tsx @@ -17,10 +17,10 @@ interface InputProps { const getInputSize = (size?: InputSize) => { switch (size) { - case "md": - return "px-[1rem] py-[.5rem] text-sm"; - default: + case "lg": return "px-5 py-3 text-base"; + default: + return "px-[1rem] py-[.5rem] text-sm"; } }; diff --git a/frontend/src/components/layouts/Container.tsx b/frontend/src/components/layouts/Container.tsx index 1e25d59..a2dc12d 100644 --- a/frontend/src/components/layouts/Container.tsx +++ b/frontend/src/components/layouts/Container.tsx @@ -1,7 +1,16 @@ import { FC, HTMLProps } from "react"; +import clsx from "clsx"; + +const styleClasses = { + base: "w-full lg:w-1/2 mx-auto", +}; const Container: FC> = (props) => { - return
{props.children}
; + return ( +
+ {props.children} +
+ ); }; export default Container; diff --git a/frontend/src/components/layouts/Dashboard.tsx b/frontend/src/components/layouts/Dashboard.tsx index 615c6ca..9bd4c7b 100644 --- a/frontend/src/components/layouts/Dashboard.tsx +++ b/frontend/src/components/layouts/Dashboard.tsx @@ -3,7 +3,7 @@ import Sidebar from "../molecules/Sidebar"; const DashboardLayout: FC> = (props) => { return ( -
+
diff --git a/frontend/src/components/molecules/Sidebar.tsx b/frontend/src/components/molecules/Sidebar.tsx index 87cc405..25d1239 100644 --- a/frontend/src/components/molecules/Sidebar.tsx +++ b/frontend/src/components/molecules/Sidebar.tsx @@ -7,19 +7,25 @@ import { } from "@heroicons/react/outline"; import { FC, ReactNode } from "react"; import { useNavigate } from "react-router-dom"; -import useServices from "../../hooks/useServices"; +import { useServicesQuery } from "../../generated/graphql"; import Badge from "../atoms/Badge"; import InputLabel from "../atoms/InputLabel"; +import clsx from "clsx"; + +const styleClasses = { + base: "cursor-pointer inline-flex items-center justify-between w-full px-4 py-2 text-base text-black transition duration-500 ease-in-out transform rounded-lg focus:shadow-outline hover:bg-gray-100", +}; const SidebarTab: FC<{ onClick?: any; children?: ReactNode; badge?: ReactNode; + className?: string; }> = (props) => { return (
-

nikuu

+

admin

; onBlur?: React.FocusEventHandler; + value?: string; } const TextField: FC = (props) => { @@ -33,6 +34,7 @@ const TextField: FC = (props) => { size={props.size} onChange={props.onChange} onBlur={props.onBlur} + value={props.value} />
diff --git a/frontend/src/components/pages/login/index.tsx b/frontend/src/components/pages/login/index.tsx index e7208b6..730fbd8 100644 --- a/frontend/src/components/pages/login/index.tsx +++ b/frontend/src/components/pages/login/index.tsx @@ -1,3 +1,4 @@ +import { useFormik } from "formik"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; @@ -7,18 +8,19 @@ import Button from "../../atoms/Button"; import TextField from "../../molecules/TextField"; const LoginPage = () => { - const { - handleSubmit, - control, - formState: { errors }, - } = useForm(); const navigator = useNavigate(); const loginMutation = useLoginMutation(); const [submitError, setSubmitError] = useState(""); - const onSubmit = ({ name, password }: any) => { - loginMutation.mutate({ credentials: { name, password } }); - }; + const loginForm = useFormik({ + initialValues: { + name: "", + password: "", + }, + onSubmit: ({ name, password }) => { + loginMutation.mutate({ credentials: { name, password } }); + }, + }); useEffect(() => { if (!loginMutation.data) return; @@ -48,35 +50,31 @@ const LoginPage = () => {
- ( - - )} + - ( - - )} +
- + {submitError && {submitError}}
diff --git a/frontend/src/components/pages/middleware/index.tsx b/frontend/src/components/pages/middleware/index.tsx deleted file mode 100644 index 135326e..0000000 --- a/frontend/src/components/pages/middleware/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import DashboardLayout from "../../layouts/Dashboard"; - -const MiddlewarePage = () => { - return ( - -
Middleware
-
- ); -}; - -export default MiddlewarePage; diff --git a/frontend/src/components/pages/services/index.tsx b/frontend/src/components/pages/services/index.tsx index 18d886f..9284c33 100644 --- a/frontend/src/components/pages/services/index.tsx +++ b/frontend/src/components/pages/services/index.tsx @@ -1,39 +1,109 @@ import { PlusIcon } from "@heroicons/react/outline"; import clsx from "clsx"; -import { useState } from "react"; -import useServices from "../../../hooks/useServices"; +import { useEffect, useMemo, useState } from "react"; import Button from "../../atoms/Button"; import InlineLink from "../../atoms/InlineLink"; import Container from "../../layouts/Container"; import DashboardLayout from "../../layouts/Dashboard"; import TextField from "../../molecules/TextField"; +import { + useServicesQuery, + useRemoveServiceMutation, + useAddServiceMutation, + Service, +} from "../../../generated/graphql"; +import { queryClient } from "../../../main"; +import { useFormik } from "formik"; const ServicesPage = () => { const [toggleCreate, setToggleCreate] = useState(false); - const services = useServices(); + const [searchQuery, setSearchQuery] = useState(""); + const services = useServicesQuery(); - const handleToggleCreate = () => { - setToggleCreate(!toggleCreate); + const serviceForm = useFormik({ + initialValues: { + name: "", + destination: "", + }, + onSubmit: ({ name, destination }, helpers) => { + addService.mutate({ name, loadbalancer: { servers: [destination] } }); + helpers.resetForm(); + }, + }); + + // TODO: Figure out what I am doing wrong with optimistic updating (removeService and addService) + const removeService = useRemoveServiceMutation({ + onMutate: async ({ id }) => { + const queryKey = ["Services"]; + await queryClient.cancelQueries(queryKey); + const prevServices = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (old) => + old.services.filter((s: Service) => s.id !== id) + ); + return { prevServices }; + }, + onError: (err, newTodo, ctx) => { + const queryKey = ["Services"]; + queryClient.setQueryData(queryKey, ctx.prevServices); + }, + onSettled: () => { + const queryKey = ["Services"]; + queryClient.invalidateQueries(queryKey); + }, + }); + + const addService = useAddServiceMutation({ + onMutate: async (newService) => { + const queryKey = ["Services"]; + await queryClient.cancelQueries(queryKey); + const prevServices = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (old) => [ + ...old.services, + newService, + ]); + return { prevServices }; + }, + onError: (err, newTodo, ctx) => { + const queryKey = ["Services"]; + queryClient.setQueryData(queryKey, ctx.prevServices); + }, + onSettled: () => { + const queryKey = ["Services"]; + queryClient.invalidateQueries(queryKey); + }, + }); + + const deleteService = (serviceId: number) => { + return (ev: React.MouseEvent) => { + ev.preventDefault(); + removeService.mutate({ id: serviceId }); + }; }; + const filteredServices = useMemo(() => { + if (!services.data?.services) return []; + return services.data.services.filter((service) => { + return service.name.toLowerCase().includes(searchQuery.toLowerCase()); + }); + }, [services, searchQuery]); + return ( - +

Services

setSearchQuery(ev.target.value)} />
{toggleCreate && ( -
+

Create a new service

+
- +
-
+ )} -
- +
+
- - - - + + + + - {services.data && - services.data.map((service) => ( - - - + + - - - - ))} + ))} + + + + + ))}
NameDestinationReferenced by{/* Delete */}NameDestinationReferenced by{/* Options */}
{service.name} - - {service.destination[0]} + {filteredServices.map((service) => ( +
{service.name} + {service.loadbalancer.servers.map((server) => ( + + {server.address} - - - {service.references} - - Delete
+ {/* TODO: Display list of references */} +

None

+
+ +
diff --git a/frontend/src/hooks/.gitkeep b/frontend/src/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/hooks/useServices.tsx b/frontend/src/hooks/useServices.tsx deleted file mode 100644 index abc54c6..0000000 --- a/frontend/src/hooks/useServices.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { useQuery } from "react-query"; -import { Service } from "../types"; - -let services = Array(11) - .fill(0) - .map(() => ({ - name: faker.name.lastName(), - references: [faker.name.firstName()], - destination: [faker.internet.ipv4()], - })); - -const fetchServices = async (): Promise => { - return services; -}; - -export default function useServices() { - return useQuery(["services"], fetchServices); -} diff --git a/frontend/src/queries/auth.graphql b/frontend/src/queries/auth.graphql new file mode 100644 index 0000000..3233b15 --- /dev/null +++ b/frontend/src/queries/auth.graphql @@ -0,0 +1,13 @@ +mutation Login($credentials: LoginInput!) { + login(body: $credentials) { + ... on AuthSuccess { + user { + name + } + token + } + ... on CommonError { + message + } + } +} diff --git a/frontend/src/queries/services.graphql b/frontend/src/queries/services.graphql index bd05a8b..a1f0ed5 100644 --- a/frontend/src/queries/services.graphql +++ b/frontend/src/queries/services.graphql @@ -1,13 +1,26 @@ - mutation Login($credentials: LoginInput!) { - login(body: $credentials) { - ... on AuthSuccess { - user { - name - } - token - } - ... on CommonError { - message - } +fragment UITableFragment on Service { + id + name + loadbalancer { + servers { + id + address } - } \ No newline at end of file + } +} + +query Services { + services { + ...UITableFragment + } +} + +mutation AddService($name: String!, $loadbalancer: AddLoadbalancerInput!) { + addService(body:{name: $name, loadbalancer: $loadbalancer}) { + ...UITableFragment + } +} + +mutation RemoveService($id: Int!) { + removeService(id: $id) +} diff --git a/main.py b/main.py index 70a2ac5..fe23b75 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware + from strawberry.fastapi import GraphQLRouter from api.schema import schema