Merge pull request 'Connected service management front to back; migrated to formik' (#2) from feature/service-management into main
Reviewed-on: #2
This commit is contained in:
commit
a6b9878c5f
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
105
api/schema/definitions/service.py
Normal file
105
api/schema/definitions/service.py
Normal 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)
|
@ -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)
|
||||
|
21
api/seed.py
21
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()
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
},
|
||||
|
52
frontend/pnpm-lock.yaml
generated
52
frontend/pnpm-lock.yaml
generated
@ -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==}
|
||||
|
@ -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>
|
||||
|
@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -1,11 +0,0 @@
|
||||
import DashboardLayout from "../../layouts/Dashboard";
|
||||
|
||||
const MiddlewarePage = () => {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div>Middleware</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiddlewarePage;
|
@ -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>
|
||||
|
0
frontend/src/hooks/.gitkeep
Normal file
0
frontend/src/hooks/.gitkeep
Normal 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);
|
||||
}
|
13
frontend/src/queries/auth.graphql
Normal file
13
frontend/src/queries/auth.graphql
Normal file
@ -0,0 +1,13 @@
|
||||
mutation Login($credentials: LoginInput!) {
|
||||
login(body: $credentials) {
|
||||
... on AuthSuccess {
|
||||
user {
|
||||
name
|
||||
}
|
||||
token
|
||||
}
|
||||
... on CommonError {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user