Connected service management front to back; migrated to formik

This commit is contained in:
strNophix 2022-06-19 21:10:05 +02:00
parent e6dedf5cd8
commit fef4cac1cf
24 changed files with 454 additions and 162 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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"
},

View File

@ -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==}

View File

@ -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() {
<Route element={<RequireAuth />}>
<Route path="/services" element={<ServicesPage />} />
<Route path="/routers" element={<RoutersPage />} />
<Route path="/middleware" element={<MiddlewarePage />} />
<Route path="/secrets" element={<SecretsPage />} />
<Route path="/" element={<Navigate to="/services" />} />
</Route>

View File

@ -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";
}
};

View File

@ -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";
}
};

View File

@ -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<HTMLProps<HTMLDivElement>> = (props) => {
return <div className="w-full lg:w-1/2 mx-auto">{props.children}</div>;
return (
<div className={clsx(styleClasses.base, props.className)}>
{props.children}
</div>
);
};
export default Container;

View File

@ -3,7 +3,7 @@ import Sidebar from "../molecules/Sidebar";
const DashboardLayout: FC<HTMLProps<HTMLDivElement>> = (props) => {
return (
<div className="flex h-screen overflow-hidden bg-white rounded-lg">
<div className="antialiased flex h-screen overflow-hidden bg-white rounded-lg">
<Sidebar />
<div className="flex flex-col flex-1 w-0 overflow-hidden">
<main className="relative flex-1 overflow-y-auto focus:outline-none">

View File

@ -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 (
<button
onClick={props.onClick}
className="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"
className={clsx(styleClasses.base, props.className)}
>
<div className="flex items-center">{props.children}</div>
{props.badge}
@ -28,6 +34,7 @@ const SidebarTab: FC<{
};
const Sidebar = () => {
const services = useServicesQuery();
const navigate = useNavigate();
const navFactory = (to: string) => () => {
@ -57,7 +64,9 @@ const Sidebar = () => {
<ul className="space-y-1">
<li>
<SidebarTab
badge={<Badge>12</Badge>}
badge={
<Badge>{(services.data?.services || []).length}</Badge>
}
onClick={navFactory("/services")}
>
<PuzzleIcon className="w-6 h-6 text-gray-700" />
@ -75,10 +84,10 @@ const Sidebar = () => {
</li>
<li>
<SidebarTab
badge={<Badge>0</Badge>}
onClick={navFactory("/middleware")}
badge={<Badge>Soon</Badge>}
className="text-gray-400 cursor-not-allowed"
>
<CubeIcon className="w-6 h-6 text-gray-700" />
<CubeIcon className="w-6 h-6" />
<span className="ml-3">Middleware</span>
</SidebarTab>
</li>
@ -101,7 +110,7 @@ const Sidebar = () => {
</div>
<div className="flex flex-shrink-0 py-4 px-5 justify-between items-center border-t border-gray-200">
<div className="flex flex-row justify-center items-center space-x-4">
<p>nikuu</p>
<p>admin</p>
</div>
<div className="w-6 h-6">
<LogoutIcon

View File

@ -15,6 +15,7 @@ interface TextFieldProps {
inputClassName?: string;
onChange?: React.FormEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
value?: string;
}
const TextField: FC<TextFieldProps> = (props) => {
@ -33,6 +34,7 @@ const TextField: FC<TextFieldProps> = (props) => {
size={props.size}
onChange={props.onChange}
onBlur={props.onBlur}
value={props.value}
/>
</div>
</div>

View File

@ -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 = () => {
<div className="py-10">
<form
className="space-y-6"
action="#"
method="POST"
data-bitwarden-watching="1"
onSubmit={handleSubmit(onSubmit)}
onSubmit={loginForm.handleSubmit}
>
<Controller
name="name"
control={control}
defaultValue=""
render={({ field }) => (
<TextField label="Username" placeholder="name" {...field} />
)}
<TextField
label="Username"
placeholder="name"
id="name"
size="lg"
onChange={loginForm.handleChange}
value={loginForm.values.name}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextField
type="password"
label="Password"
placeholder="*****"
{...field}
/>
)}
<TextField
type="password"
id="password"
label="Password"
size="lg"
placeholder="*****"
onChange={loginForm.handleChange}
value={loginForm.values.password}
/>
<div>
<Button type="submit">Login</Button>
<Button type="submit" size="lg">
Login
</Button>
{submitError && <span>{submitError}</span>}
</div>
</form>

View File

@ -1,11 +0,0 @@
import DashboardLayout from "../../layouts/Dashboard";
const MiddlewarePage = () => {
return (
<DashboardLayout>
<div>Middleware</div>
</DashboardLayout>
);
};
export default MiddlewarePage;

View File

@ -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<HTMLButtonElement, 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 (
<DashboardLayout>
<Container>
<Container className="px-2">
<h1 className="text-3xl font-bold mt-12 mb-8">Services</h1>
<div className="mb-4 flex items-end justify-between">
<TextField
label="Search"
size="md"
placeholder="Search services"
inputClassName="bg-white border border-gray-300 w-60 shadow-none"
onChange={(ev) => setSearchQuery(ev.target.value)}
/>
<Button
size="md"
className={clsx(
"w-20 border border-sky-600",
toggleCreate && "bg-white text-sky-600 hover:bg-white"
)}
onClick={handleToggleCreate}
onClick={() => setToggleCreate(!toggleCreate)}
>
<div className="flex flex-row space-x-1">
<PlusIcon width={20} />
@ -42,59 +112,72 @@ const ServicesPage = () => {
</Button>
</div>
{toggleCreate && (
<div className="w-full bg-gray-50 border border-gray-200 rounded-lg p-5 mb-4">
<form
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-5 mb-4"
onSubmit={serviceForm.handleSubmit}
>
<h1 className="mb-4">Create a new service</h1>
<div className="space-y-3">
<TextField
label="Name"
required={true}
id="name"
placeholder="service name"
size="md"
inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.name}
/>
<TextField
label="Destination"
required={true}
placeholder="url"
size="md"
id="destination"
inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.destination}
/>
<div className="flex justify-end items-center">
<Button size="md" className="w-20 border border-sky-600">
Create
</Button>
<Button className="w-20 border border-sky-600">Create</Button>
</div>
</div>
</div>
</form>
)}
<div className="rounded-lg border">
<table className="w-full border-collapse">
<div className="rounded-lg border overflow-x-auto">
<table className="w-full max-w-6xl border-collapse table-auto">
<thead className="h-12">
<tr className="text-left font-semibold text-sm border-b bg-gray-50">
<th className="px-3 py-2 lg:w-2/12">Name</th>
<th className="px-3 py-2 lg:w-4/12">Destination</th>
<th className="px-3 py-2 lg:w-5/12">Referenced by</th>
<th className="px-3 py-2 lg:w-1/12">{/* Delete */}</th>
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Destination</th>
<th className="px-3 py-2">Referenced by</th>
<th className="px-3 py-2">{/* Options */}</th>
</tr>
</thead>
<tbody>
{services.data &&
services.data.map((service) => (
<tr key={service.name} className="h-12 border-b ">
<td className="px-3 py-2 truncate">{service.name}</td>
<td className="px-3 py-2 truncate">
<InlineLink href="https://example.com/">
{service.destination[0]}
{filteredServices.map((service) => (
<tr key={service.name} className="h-12 border-b ">
<td className="px-3 py-2 truncate">{service.name}</td>
<td className="px-3 py-2 truncate space-x-1">
{service.loadbalancer.servers.map((server) => (
<InlineLink key={server.id} href={server.address}>
{server.address}
</InlineLink>
</td>
<td className="px-3 py-2 truncate">
<InlineLink href="https://example.com/">
{service.references}
</InlineLink>
</td>
<td className="py-2 cursor-pointer text-sky-900">Delete</td>
</tr>
))}
))}
</td>
<td className="px-3 py-2 truncate">
{/* TODO: Display list of references */}
<p>None</p>
</td>
<td className="py-2">
<button
className="cursor-pointer text-sky-900"
onClick={deleteService(service.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>

View File

View File

@ -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<Service[]> => {
return services;
};
export default function useServices() {
return useQuery(["services"], fetchServices);
}

View File

@ -0,0 +1,13 @@
mutation Login($credentials: LoginInput!) {
login(body: $credentials) {
... on AuthSuccess {
user {
name
}
token
}
... on CommonError {
message
}
}
}

View File

@ -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
}
}
}
}
query Services {
services {
...UITableFragment
}
}
mutation AddService($name: String!, $loadbalancer: AddLoadbalancerInput!) {
addService(body:{name: $name, loadbalancer: $loadbalancer}) {
...UITableFragment
}
}
mutation RemoveService($id: Int!) {
removeService(id: $id)
}

View File

@ -1,5 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from strawberry.fastapi import GraphQLRouter
from api.schema import schema