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 Integer
from sqlalchemy import VARCHAR from sqlalchemy import VARCHAR
from sqlalchemy.orm import relationship
from api.database import Base from api.database import Base
from api.database import engine from api.database import engine
class UserModel(Base): class User(Base):
__tablename__ = "user" __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) name = Column(VARCHAR(length=32), nullable=False, unique=True)
password_hash = Column(VARCHAR, nullable=False) 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) Base.metadata.create_all(engine)

View File

@ -1,24 +1,32 @@
import strawberry import strawberry
from api.schema.definitions.auth import AuthResult import api.schema.definitions.auth as auth
from api.schema.definitions.auth import login import api.schema.permissions as permissions
from api.schema.definitions.auth import update_me import api.schema.definitions.service as service
from api.schema.definitions.common import CommonMessage
from api.schema.extensions import extensions from api.schema.extensions import extensions
from api.schema.permissions import IsAuthenticated
user_perms = {
"permission_classes": [
# permissions.IsAuthenticated
]
}
@strawberry.type @strawberry.type
class Query: class Query:
hello: str # service
services = strawberry.field(resolver=service.get_services, **user_perms)
@strawberry.type @strawberry.type
class Mutation: class Mutation:
login: AuthResult = strawberry.field(resolver=login) # auth
update_me: CommonMessage = strawberry.field( login = strawberry.field(resolver=auth.login)
resolver=update_me, permission_classes=[IsAuthenticated] 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) schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=extensions)

View File

@ -2,11 +2,10 @@ from typing import TYPE_CHECKING
import strawberry import strawberry
from fastapi import Request from fastapi import Request
from sqlalchemy import true
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from api.hasher import argon2_hasher 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 CommonError
from api.schema.definitions.common import CommonMessage from api.schema.definitions.common import CommonMessage
from api.schema.definitions.user import User 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 import strawberry
from api.models import UserModel import api.models as models
@strawberry.type @strawberry.type
@ -9,5 +9,5 @@ class User:
name: str name: str
@classmethod @classmethod
def from_instance(cls, instance: UserModel): def from_instance(cls, instance: models.User):
return cls(id=instance.id, name=instance.name) 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.database import SessionLocal
from api.hasher import argon2_hasher from api.hasher import argon2_hasher
from api.models import UserModel import api.models as models
def seed(): def seed():
db: Session = SessionLocal() db: Session = SessionLocal()
if db.query(UserModel).count() == 0: if db.query(models.User).count() == 0:
admin = UserModel(name="admin", password_hash=argon2_hasher.hash("admin")) admin = models.User(name="admin", password_hash=argon2_hasher.hash("admin"))
db.add(admin) db.add(admin)
db.commit() 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 from datetime import timezone
import jwt import jwt
from fastapi import Request
from starlette.datastructures import Headers from starlette.datastructures import Headers
from api.models import UserModel import api.models as models
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
UserTokenData = dict[str, typing.Any] UserTokenData = dict[str, typing.Any]
@ -17,7 +16,7 @@ JWT_SECRET = os.getenv("JWT_SECRET", "")
def encode_user_token( def encode_user_token(
user: UserModel, expire_in: timedelta = timedelta(hours=6) user: models.User, expire_in: timedelta = timedelta(hours=6)
) -> str: ) -> str:
payload = {} payload = {}
payload["id"] = user.id payload["id"] = user.id

View File

@ -16,11 +16,11 @@
"@headlessui/react": "^1.6.3", "@headlessui/react": "^1.6.3",
"@heroicons/react": "^1.0.6", "@heroicons/react": "^1.0.6",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"formik": "^2.2.9",
"graphql": "^16.5.0", "graphql": "^16.5.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-hook-form": "^7.31.3",
"react-query": "^3.39.0", "react-query": "^3.39.0",
"react-router-dom": "6" "react-router-dom": "6"
}, },

View File

@ -15,12 +15,12 @@ specifiers:
autoprefixer: ^10.4.7 autoprefixer: ^10.4.7
clsx: ^1.1.1 clsx: ^1.1.1
eslint-plugin-react-hooks: ^4.3.0 eslint-plugin-react-hooks: ^4.3.0
formik: ^2.2.9
graphql: ^16.5.0 graphql: ^16.5.0
graphql-tag: ^2.12.6 graphql-tag: ^2.12.6
postcss: ^8.4.14 postcss: ^8.4.14
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
react-hook-form: ^7.31.3
react-query: ^3.39.0 react-query: ^3.39.0
react-router-dom: '6' react-router-dom: '6'
tailwindcss: ^3.0.24 tailwindcss: ^3.0.24
@ -35,11 +35,11 @@ dependencies:
'@headlessui/react': 1.6.3_ef5jwxihqo6n7gxfmzogljlgcm '@headlessui/react': 1.6.3_ef5jwxihqo6n7gxfmzogljlgcm
'@heroicons/react': 1.0.6_react@18.1.0 '@heroicons/react': 1.0.6_react@18.1.0
clsx: 1.1.1 clsx: 1.1.1
formik: 2.2.9_react@18.1.0
graphql: 16.5.0 graphql: 16.5.0
graphql-tag: 2.12.6_graphql@16.5.0 graphql-tag: 2.12.6_graphql@16.5.0
react: 18.1.0 react: 18.1.0
react-dom: 18.1.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-query: 3.39.0_ef5jwxihqo6n7gxfmzogljlgcm
react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm
@ -1960,6 +1960,11 @@ packages:
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
dev: true dev: true
/deepmerge/2.2.1:
resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
engines: {node: '>=0.10.0'}
dev: false
/defaults/1.0.3: /defaults/1.0.3:
resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==} resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==}
dependencies: dependencies:
@ -2429,6 +2434,21 @@ packages:
web-streams-polyfill: 4.0.0-beta.1 web-streams-polyfill: 4.0.0-beta.1
dev: true 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: /fraction.js/4.2.0:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true dev: true
@ -2650,6 +2670,12 @@ packages:
'@babel/runtime': 7.18.0 '@babel/runtime': 7.18.0
dev: false 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: /http-cache-semantics/4.1.0:
resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==} resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
dev: true dev: true
@ -3042,6 +3068,10 @@ packages:
p-locate: 4.1.0 p-locate: 4.1.0
dev: false dev: false
/lodash-es/4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
dev: false
/lodash.get/4.4.2: /lodash.get/4.4.2:
resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=} resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=}
dev: true dev: true
@ -3599,13 +3629,12 @@ packages:
scheduler: 0.22.0 scheduler: 0.22.0
dev: false dev: false
/react-hook-form/7.31.3_react@18.1.0: /react-fast-compare/2.0.4:
resolution: {integrity: sha512-NVZdCWViIWXXXlQ3jxVQH0NuNfwPf8A/0KvuCxrM9qxtP1qYosfR2ZudarziFrVOC7eTUbWbm1T4OyYCwv9oSQ==} resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
engines: {node: '>=12.22.0'} dev: false
peerDependencies:
react: ^16.8.0 || ^17 || ^18 /react-is/16.13.1:
dependencies: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react: 18.1.0
dev: false dev: false
/react-query/3.39.0_ef5jwxihqo6n7gxfmzogljlgcm: /react-query/3.39.0_ef5jwxihqo6n7gxfmzogljlgcm:
@ -4079,6 +4108,10 @@ packages:
resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=} resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=}
dev: true dev: true
/tiny-warning/1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/title-case/3.0.3: /title-case/3.0.3:
resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==}
dependencies: dependencies:
@ -4132,7 +4165,6 @@ packages:
/tslib/1.14.1: /tslib/1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
dev: true
/tslib/2.3.1: /tslib/2.3.1:
resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==}

View File

@ -1,5 +1,4 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import MiddlewarePage from "./components/pages/middleware";
import RoutersPage from "./components/pages/routers"; import RoutersPage from "./components/pages/routers";
import SecretsPage from "./components/pages/secrets"; import SecretsPage from "./components/pages/secrets";
import ServicesPage from "./components/pages/services"; import ServicesPage from "./components/pages/services";
@ -13,7 +12,6 @@ export default function App() {
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<ServicesPage />} />
<Route path="/routers" element={<RoutersPage />} /> <Route path="/routers" element={<RoutersPage />} />
<Route path="/middleware" element={<MiddlewarePage />} />
<Route path="/secrets" element={<SecretsPage />} /> <Route path="/secrets" element={<SecretsPage />} />
<Route path="/" element={<Navigate to="/services" />} /> <Route path="/" element={<Navigate to="/services" />} />
</Route> </Route>

View File

@ -12,10 +12,10 @@ interface InputProps {
const getButtonSize = (size?: InputSize) => { const getButtonSize = (size?: InputSize) => {
switch (size) { switch (size) {
case "md": case "lg":
return "px-[1rem] py-[.5rem] text-sm";
default:
return "px-10 py-4 text-base"; 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) => { const getInputSize = (size?: InputSize) => {
switch (size) { switch (size) {
case "md": case "lg":
return "px-[1rem] py-[.5rem] text-sm";
default:
return "px-5 py-3 text-base"; 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 { 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) => { 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; export default Container;

View File

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

View File

@ -7,19 +7,25 @@ import {
} from "@heroicons/react/outline"; } from "@heroicons/react/outline";
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import useServices from "../../hooks/useServices"; import { useServicesQuery } from "../../generated/graphql";
import Badge from "../atoms/Badge"; import Badge from "../atoms/Badge";
import InputLabel from "../atoms/InputLabel"; 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<{ const SidebarTab: FC<{
onClick?: any; onClick?: any;
children?: ReactNode; children?: ReactNode;
badge?: ReactNode; badge?: ReactNode;
className?: string;
}> = (props) => { }> = (props) => {
return ( return (
<button <button
onClick={props.onClick} 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> <div className="flex items-center">{props.children}</div>
{props.badge} {props.badge}
@ -28,6 +34,7 @@ const SidebarTab: FC<{
}; };
const Sidebar = () => { const Sidebar = () => {
const services = useServicesQuery();
const navigate = useNavigate(); const navigate = useNavigate();
const navFactory = (to: string) => () => { const navFactory = (to: string) => () => {
@ -57,7 +64,9 @@ const Sidebar = () => {
<ul className="space-y-1"> <ul className="space-y-1">
<li> <li>
<SidebarTab <SidebarTab
badge={<Badge>12</Badge>} badge={
<Badge>{(services.data?.services || []).length}</Badge>
}
onClick={navFactory("/services")} onClick={navFactory("/services")}
> >
<PuzzleIcon className="w-6 h-6 text-gray-700" /> <PuzzleIcon className="w-6 h-6 text-gray-700" />
@ -75,10 +84,10 @@ const Sidebar = () => {
</li> </li>
<li> <li>
<SidebarTab <SidebarTab
badge={<Badge>0</Badge>} badge={<Badge>Soon</Badge>}
onClick={navFactory("/middleware")} 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> <span className="ml-3">Middleware</span>
</SidebarTab> </SidebarTab>
</li> </li>
@ -101,7 +110,7 @@ const Sidebar = () => {
</div> </div>
<div className="flex flex-shrink-0 py-4 px-5 justify-between items-center border-t border-gray-200"> <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"> <div className="flex flex-row justify-center items-center space-x-4">
<p>nikuu</p> <p>admin</p>
</div> </div>
<div className="w-6 h-6"> <div className="w-6 h-6">
<LogoutIcon <LogoutIcon

View File

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

View File

@ -1,3 +1,4 @@
import { useFormik } from "formik";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -7,18 +8,19 @@ import Button from "../../atoms/Button";
import TextField from "../../molecules/TextField"; import TextField from "../../molecules/TextField";
const LoginPage = () => { const LoginPage = () => {
const {
handleSubmit,
control,
formState: { errors },
} = useForm();
const navigator = useNavigate(); const navigator = useNavigate();
const loginMutation = useLoginMutation(); const loginMutation = useLoginMutation();
const [submitError, setSubmitError] = useState(""); const [submitError, setSubmitError] = useState("");
const onSubmit = ({ name, password }: any) => { const loginForm = useFormik({
loginMutation.mutate({ credentials: { name, password } }); initialValues: {
}; name: "",
password: "",
},
onSubmit: ({ name, password }) => {
loginMutation.mutate({ credentials: { name, password } });
},
});
useEffect(() => { useEffect(() => {
if (!loginMutation.data) return; if (!loginMutation.data) return;
@ -48,35 +50,31 @@ const LoginPage = () => {
<div className="py-10"> <div className="py-10">
<form <form
className="space-y-6" className="space-y-6"
action="#"
method="POST"
data-bitwarden-watching="1" data-bitwarden-watching="1"
onSubmit={handleSubmit(onSubmit)} onSubmit={loginForm.handleSubmit}
> >
<Controller <TextField
name="name" label="Username"
control={control} placeholder="name"
defaultValue="" id="name"
render={({ field }) => ( size="lg"
<TextField label="Username" placeholder="name" {...field} /> onChange={loginForm.handleChange}
)} value={loginForm.values.name}
/> />
<Controller <TextField
name="password" type="password"
control={control} id="password"
defaultValue="" label="Password"
render={({ field }) => ( size="lg"
<TextField placeholder="*****"
type="password" onChange={loginForm.handleChange}
label="Password" value={loginForm.values.password}
placeholder="*****"
{...field}
/>
)}
/> />
<div> <div>
<Button type="submit">Login</Button> <Button type="submit" size="lg">
Login
</Button>
{submitError && <span>{submitError}</span>} {submitError && <span>{submitError}</span>}
</div> </div>
</form> </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 { PlusIcon } from "@heroicons/react/outline";
import clsx from "clsx"; import clsx from "clsx";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useServices from "../../../hooks/useServices";
import Button from "../../atoms/Button"; import Button from "../../atoms/Button";
import InlineLink from "../../atoms/InlineLink"; import InlineLink from "../../atoms/InlineLink";
import Container from "../../layouts/Container"; import Container from "../../layouts/Container";
import DashboardLayout from "../../layouts/Dashboard"; import DashboardLayout from "../../layouts/Dashboard";
import TextField from "../../molecules/TextField"; import TextField from "../../molecules/TextField";
import {
useServicesQuery,
useRemoveServiceMutation,
useAddServiceMutation,
Service,
} from "../../../generated/graphql";
import { queryClient } from "../../../main";
import { useFormik } from "formik";
const ServicesPage = () => { const ServicesPage = () => {
const [toggleCreate, setToggleCreate] = useState(false); const [toggleCreate, setToggleCreate] = useState(false);
const services = useServices(); const [searchQuery, setSearchQuery] = useState("");
const services = useServicesQuery();
const handleToggleCreate = () => { const serviceForm = useFormik({
setToggleCreate(!toggleCreate); 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 ( return (
<DashboardLayout> <DashboardLayout>
<Container> <Container className="px-2">
<h1 className="text-3xl font-bold mt-12 mb-8">Services</h1> <h1 className="text-3xl font-bold mt-12 mb-8">Services</h1>
<div className="mb-4 flex items-end justify-between"> <div className="mb-4 flex items-end justify-between">
<TextField <TextField
label="Search" label="Search"
size="md"
placeholder="Search services" placeholder="Search services"
inputClassName="bg-white border border-gray-300 w-60 shadow-none" inputClassName="bg-white border border-gray-300 w-60 shadow-none"
onChange={(ev) => setSearchQuery(ev.target.value)}
/> />
<Button <Button
size="md"
className={clsx( className={clsx(
"w-20 border border-sky-600", "w-20 border border-sky-600",
toggleCreate && "bg-white text-sky-600 hover:bg-white" toggleCreate && "bg-white text-sky-600 hover:bg-white"
)} )}
onClick={handleToggleCreate} onClick={() => setToggleCreate(!toggleCreate)}
> >
<div className="flex flex-row space-x-1"> <div className="flex flex-row space-x-1">
<PlusIcon width={20} /> <PlusIcon width={20} />
@ -42,59 +112,72 @@ const ServicesPage = () => {
</Button> </Button>
</div> </div>
{toggleCreate && ( {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> <h1 className="mb-4">Create a new service</h1>
<div className="space-y-3"> <div className="space-y-3">
<TextField <TextField
label="Name" label="Name"
required={true} required={true}
id="name"
placeholder="service name" placeholder="service name"
size="md"
inputClassName="shadow-none border border-gray-300 bg-white" inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.name}
/> />
<TextField <TextField
label="Destination" label="Destination"
required={true} required={true}
placeholder="url" placeholder="url"
size="md" id="destination"
inputClassName="shadow-none border border-gray-300 bg-white" inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.destination}
/> />
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
<Button size="md" className="w-20 border border-sky-600"> <Button className="w-20 border border-sky-600">Create</Button>
Create
</Button>
</div> </div>
</div> </div>
</div> </form>
)} )}
<div className="rounded-lg border"> <div className="rounded-lg border overflow-x-auto">
<table className="w-full border-collapse"> <table className="w-full max-w-6xl border-collapse table-auto">
<thead className="h-12"> <thead className="h-12">
<tr className="text-left font-semibold text-sm border-b bg-gray-50"> <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">Name</th>
<th className="px-3 py-2 lg:w-4/12">Destination</th> <th className="px-3 py-2">Destination</th>
<th className="px-3 py-2 lg:w-5/12">Referenced by</th> <th className="px-3 py-2">Referenced by</th>
<th className="px-3 py-2 lg:w-1/12">{/* Delete */}</th> <th className="px-3 py-2">{/* Options */}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{services.data && {filteredServices.map((service) => (
services.data.map((service) => ( <tr key={service.name} className="h-12 border-b ">
<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">{service.name}</td> <td className="px-3 py-2 truncate space-x-1">
<td className="px-3 py-2 truncate"> {service.loadbalancer.servers.map((server) => (
<InlineLink href="https://example.com/"> <InlineLink key={server.id} href={server.address}>
{service.destination[0]} {server.address}
</InlineLink> </InlineLink>
</td> ))}
<td className="px-3 py-2 truncate"> </td>
<InlineLink href="https://example.com/"> <td className="px-3 py-2 truncate">
{service.references} {/* TODO: Display list of references */}
</InlineLink> <p>None</p>
</td> </td>
<td className="py-2 cursor-pointer text-sky-900">Delete</td> <td className="py-2">
</tr> <button
))} className="cursor-pointer text-sky-900"
onClick={deleteService(service.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </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!) { fragment UITableFragment on Service {
login(body: $credentials) { id
... on AuthSuccess { name
user { loadbalancer {
name servers {
} id
token address
}
... on CommonError {
message
}
} }
} }
}
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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from strawberry.fastapi import GraphQLRouter from strawberry.fastapi import GraphQLRouter
from api.schema import schema from api.schema import schema