Compare commits

...

13 Commits

35 changed files with 4021 additions and 152 deletions

View File

@ -1 +1,3 @@
# traefik-confman
A simple management UI for your Traefik config. Inspired by [Nginx Proxy Manager](https://nginxproxymanager.com/).

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

2
frontend/.gitignore vendored
View File

@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
src/generated

16
frontend/codegen.yml Normal file
View File

@ -0,0 +1,16 @@
overwrite: true
schema: "http://127.0.0.1:8000/graphql"
documents:
- 'src/queries/*.graphql'
generates:
./src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-query
config:
fetcher:
fetchParams:
headers:
Content-Type: application/json
endpoint: "http://localhost:8000/graphql"

View File

@ -5,24 +5,37 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"codegen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@babel/core": ">=7.0.0 <8.0.0",
"@graphql-codegen/typed-document-node": "^2.2.11",
"@graphql-codegen/typescript-operations": "^2.4.0",
"@graphql-codegen/typescript-react-query": "^3.5.12",
"@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-query": "^3.39.0",
"react-router-dom": "6"
},
"devDependencies": {
"@faker-js/faker": "^7.1.0",
"@graphql-codegen/cli": "^2.6.2",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^1.3.0",
"autoprefixer": "^10.4.7",
"eslint-plugin-react-hooks": "^4.3.0",
"postcss": "^8.4.14",
"tailwindcss": "^3.0.24",
"typescript": "^4.6.3",
"vite": "^2.9.9"
}
}
},
"proxy": "http://localhost:4000"
}

3200
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,24 @@
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";
import SetupPage from "./components/pages/setup";
import LoginPage from "./components/pages/login";
import RequireAuth from "./components/layouts/RequireAuth";
import UserPage from "./components/pages/user";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/services" element={<ServicesPage />} />
<Route path="/routers" element={<RoutersPage />} />
<Route path="/middleware" element={<MiddlewarePage />} />
<Route path="/setup" element={<SetupPage />} />
<Route path="/secrets" element={<SecretsPage />} />
<Route path="*" element={<Navigate to="/services" />} />
<Route element={<RequireAuth />}>
<Route path="/services" element={<ServicesPage />} />
<Route path="/routers" element={<RoutersPage />} />
<Route path="/secrets" element={<SecretsPage />} />
<Route path="/me" element={<UserPage />} />
<Route path="/" element={<Navigate to="/services" />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<LoginPage />} />
</Routes>
</BrowserRouter>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,10 +1,35 @@
import { FC, HTMLProps } from "react";
import clsx from "clsx";
import { FC, MouseEventHandler, ReactNode } from "react";
import { InputSize } from "../../types";
const Button: FC<HTMLProps<HTMLButtonElement>> = (props) => {
interface InputProps {
onClick?: MouseEventHandler<HTMLButtonElement> | undefined;
className?: string;
children?: ReactNode;
size?: InputSize;
type?: "button" | "submit" | "reset";
}
const getButtonSize = (size?: InputSize) => {
switch (size) {
case "lg":
return "px-10 py-4 text-base";
default:
return "px-[1rem] py-[.5rem] text-sm";
}
};
const Button: FC<InputProps> = (props) => {
const btnClassName = getButtonSize(props.size);
return (
<button
onClick={props.onClick}
className="flex items-center justify-center w-full px-10 py-4 text-base font-medium text-center text-white transition duration-500 ease-in-out transform bg-blue-600 rounded-xl hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className={clsx(
"flex items-center justify-center w-full font-medium text-center text-white transition duration-500 ease-in-out transform bg-blue-600 rounded-xl hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500",
btnClassName,
props.className
)}
type={props.type}
>
{props.children}
</button>

View File

@ -0,0 +1,16 @@
import { FC, HTMLProps } from "react";
import clsx from "clsx";
const styleClasses = {
base: "text-3xl font-bold mt-12 mb-8",
};
const Header1: FC<HTMLProps<HTMLParagraphElement>> = (props) => {
return (
<h1 className={clsx(styleClasses.base, props.className)}>
{props.children}
</h1>
);
};
export default Header1;

View File

@ -0,0 +1,11 @@
import { FC, HTMLProps } from "react";
const InlineLink: FC<HTMLProps<HTMLAnchorElement>> = (props) => {
return (
<a className="text-sky-400 inline-block" href={props.href}>
{props.children}
</a>
);
};
export default InlineLink;

View File

@ -1,14 +1,46 @@
import { FC, HTMLProps } from "react";
import clsx from "clsx";
import { FC } from "react";
import { InputSize } from "../../types";
const Input: FC<HTMLProps<HTMLLabelElement>> = (props) => {
interface InputProps {
id?: string;
type?: string;
className?: string;
placeholder?: string;
required?: boolean;
name?: string;
size?: InputSize;
value?: string;
onChange?: React.FormEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
}
const getInputSize = (size?: InputSize) => {
switch (size) {
case "lg":
return "px-5 py-3 text-base";
default:
return "px-[1rem] py-[.5rem] text-sm";
}
};
const Input: FC<InputProps> = (props) => {
const sizeClassNames = getInputSize(props.size);
return (
<input
id={props.id}
name={props.name}
type={props.type}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
placeholder={props.placeholder}
required={props.required}
className="block w-full px-5 py-3 text-base text-neutral-600 placeholder-gray-300 transition duration-500 ease-in-out transform border border-transparent rounded-lg bg-gray-50 focus:outline-none focus:border-transparent focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-300 shadow-sm"
className={clsx(
"block w-full text-neutral-600 placeholder-gray-300 transition duration-500 ease-in-out transform border border-transparent rounded-lg bg-gray-50 focus:outline-none focus:border-transparent focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-300 shadow-sm",
sizeClassNames,
props.className
)}
/>
);
};

View File

@ -1,12 +1,17 @@
import { FC, HTMLProps } from "react";
const InputLabel: FC<HTMLProps<HTMLLabelElement>> = (props) => {
interface InputLabelProps extends HTMLProps<HTMLLabelElement> {
required?: boolean;
}
const InputLabel: FC<InputLabelProps> = (props) => {
return (
<label
htmlFor={props.id}
className="block text-sm font-medium text-gray-700"
>
{props.children}
{props.required && <span className="text-red-600">*</span>}
</label>
);
};

View File

@ -0,0 +1,16 @@
import { FC, HTMLProps } from "react";
import clsx from "clsx";
const styleClasses = {
base: "w-full lg:w-1/2 mx-auto px-2",
};
const Container: FC<HTMLProps<HTMLDivElement>> = (props) => {
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

@ -0,0 +1,16 @@
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router-dom";
const RequireAuth = () => {
const navigate = useNavigate();
useEffect(() => {
if (!localStorage.getItem("token")) {
navigate("/login");
}
}, []);
return <Outlet />;
};
export default RequireAuth;

View File

@ -7,18 +7,25 @@ import {
} from "@heroicons/react/outline";
import { FC, ReactNode } from "react";
import { useNavigate } from "react-router-dom";
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}
@ -27,6 +34,7 @@ const SidebarTab: FC<{
};
const Sidebar = () => {
const services = useServicesQuery();
const navigate = useNavigate();
const navFactory = (to: string) => () => {
@ -47,20 +55,6 @@ const Sidebar = () => {
</h2>
</div>
</div>
<button className="hidden rounded-lg focus:outline-none focus:shadow-outline">
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
<path
fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM9 15a1 1 0 011-1h6a1 1 0 110 2h-6a1 1 0 01-1-1z"
clip-rule="evenodd"
></path>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</button>
</div>
<div className="flex flex-col flex-grow px-4 mt-8 space-y-2">
<div className="ml-3">
@ -70,7 +64,9 @@ const Sidebar = () => {
<ul className="space-y-1">
<li>
<SidebarTab
badge={<Badge>0</Badge>}
badge={
<Badge>{(services.data?.services || []).length}</Badge>
}
onClick={navFactory("/services")}
>
<PuzzleIcon className="w-6 h-6 text-gray-700" />
@ -88,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>
@ -112,12 +108,18 @@ const Sidebar = () => {
</ul>
</nav>
</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>
</div>
<div className="flex flex-shrink-0 py-4 px-5 justify-between items-center border-t border-gray-200 space-x-4">
<SidebarTab onClick={navFactory("/me")}>
<span className="ml-3">admin</span>
</SidebarTab>
<div className="w-6 h-6">
<LogoutIcon />
<LogoutIcon
className="cursor-pointer"
onClick={() => {
localStorage.removeItem("token");
navigate("/login");
}}
/>
</div>
</div>
</div>

View File

@ -1,17 +1,40 @@
import { FC, HTMLProps } from "react";
import { FC } from "react";
import { InputSize } from "../../types";
import Input from "../atoms/Input";
import InputLabel from "../atoms/InputLabel";
const TextField: FC<HTMLProps<HTMLInputElement>> = (props) => {
interface TextFieldProps {
id?: string;
type?: string;
className?: string;
placeholder?: string;
required?: boolean;
name?: string;
size?: InputSize;
label?: string;
inputClassName?: string;
onChange?: React.FormEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
value?: string;
}
const TextField: FC<TextFieldProps> = (props) => {
return (
<div>
<InputLabel id={props.id}>{props.label}</InputLabel>
<div className="w-full">
<InputLabel id={props.id} required={props.required}>
{props.label}
</InputLabel>
<div className="mt-1">
<Input
id={props.id}
type={props.type}
className={props.inputClassName}
placeholder={props.placeholder}
required={props.required}
size={props.size}
onChange={props.onChange}
onBlur={props.onBlur}
value={props.value}
/>
</div>
</div>

View File

@ -0,0 +1,86 @@
import { useFormik } from "formik";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useLoginMutation } from "../../../generated/graphql";
import Button from "../../atoms/Button";
import TextField from "../../molecules/TextField";
const LoginPage = () => {
const navigator = useNavigate();
const loginMutation = useLoginMutation();
const [submitError, setSubmitError] = useState("");
const loginForm = useFormik({
initialValues: {
name: "",
password: "",
},
onSubmit: ({ name, password }) => {
loginMutation.mutate({ credentials: { name, password } });
},
});
useEffect(() => {
if (!loginMutation.data) {
return;
} else if (loginMutation.data.login.__typename == "CommonError") {
setSubmitError(loginMutation.data.login.message);
} else if (loginMutation.data.login.__typename == "AuthSuccess") {
localStorage.setItem("token", loginMutation.data.login.token);
navigator("/services");
}
}, [loginMutation.data]);
return (
<main>
<div className="flex flex-col justify-center min-h-screen py-12 sm:px-6 lg:px-8">
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex flex-row justify-start items-center w-full space-x-2">
<div className="w-16 h-16 bg-sky-300 rounded-md" />
<div>
<h1 className="text-xl">Traefik</h1>
<h2 className="block text-2xl font-extrabold tracking-tighter text-gray-900 transition duration-500 ease-in-out transform hover:text-gray-900">
Confman
</h2>
</div>
</div>
<div className="py-10">
<form
className="space-y-6"
data-bitwarden-watching="1"
onSubmit={loginForm.handleSubmit}
>
<TextField
label="Username"
placeholder="name"
id="name"
size="lg"
onChange={loginForm.handleChange}
value={loginForm.values.name}
/>
<TextField
type="password"
id="password"
label="Password"
size="lg"
placeholder="*****"
onChange={loginForm.handleChange}
value={loginForm.values.password}
/>
<div>
<Button type="submit" size="lg">
Login
</Button>
{submitError && <span>{submitError}</span>}
</div>
</form>
</div>
</div>
</div>
</main>
);
};
export default LoginPage;

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,9 +1,188 @@
import { PlusIcon } from "@heroicons/react/outline";
import clsx from "clsx";
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";
import Header1 from "../../atoms/Header1";
const ServicesPage = () => {
const [toggleCreate, setToggleCreate] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const services = useServicesQuery();
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>
<div>Services</div>
<Container>
<Header1>Services</Header1>
<div className="mb-4 flex items-end justify-between">
<TextField
label="Search"
placeholder="Search services"
inputClassName="bg-white border border-gray-300 w-60 shadow-none"
onChange={(ev) => setSearchQuery(ev.target.value)}
/>
<Button
className={clsx(
"w-20 border border-sky-600",
toggleCreate && "bg-white text-sky-600 hover:bg-white"
)}
onClick={() => setToggleCreate(!toggleCreate)}
>
<div className="flex flex-row space-x-1">
<PlusIcon width={20} />
<span>Add</span>
</div>
</Button>
</div>
{toggleCreate && (
<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"
inputClassName="shadow-none border border-gray-300 bg-white"
onChange={serviceForm.handleChange}
value={serviceForm.values.name}
/>
<TextField
label="Destination"
required={true}
placeholder="url"
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 className="w-20 border border-sky-600">Create</Button>
</div>
</div>
</form>
)}
<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">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>
{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">
{/* 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>
</Container>
</DashboardLayout>
);
};

View File

@ -1,62 +0,0 @@
import { useNavigate } from "react-router-dom";
import Button from "../../atoms/Button";
import TextField from "../../molecules/TextField";
import TraefikImgUrl from "../../../assets/traefik.png";
const SetupPage = () => {
const navigator = useNavigate();
const handleContinue = () => navigator("/");
return (
<main>
<div className="flex flex-col justify-center min-h-screen py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img className="w-40 h-40 mx-auto" src={TraefikImgUrl} />
<h2 className="mt-6 text-3xl font-extrabold text-center text-neutral-600">
Setup Traefik Confman
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="px-4 py-8 sm:px-10">
<form
className="space-y-6"
action="#"
method="POST"
data-bitwarden-watching="1"
>
<TextField
id="username"
label="Username"
placeholder="name"
required
/>
<TextField
id="password"
type="password"
label="Password"
placeholder="*****"
required
/>
<TextField
id="repeatPassword"
type="password"
label="Repeat Password"
placeholder="*****"
required
/>
<div>
<Button onClick={handleContinue}>Continue</Button>
</div>
</form>
</div>
</div>
</div>
</main>
);
};
export default SetupPage;

View File

@ -0,0 +1,74 @@
import { useFormik } from "formik";
import Button from "../../atoms/Button";
import Header1 from "../../atoms/Header1";
import Container from "../../layouts/Container";
import DashboardLayout from "../../layouts/Dashboard";
import TextField from "../../molecules/TextField";
const UserPage = () => {
const passwordUpdateForm = useFormik({
initialValues: { oldPassword: "", newPassword: "", confirmPassword: "" },
onSubmit: async (
{ oldPassword, newPassword, confirmPassword },
helpers
) => {
if (newPassword !== confirmPassword) {
helpers.setFieldError("confirmPassword", "Passwords do not match");
return;
}
helpers.resetForm();
},
});
return (
<DashboardLayout>
<Container>
<Header1>User settings</Header1>
<form
// onSubmit={serviceForm.handleSubmit}
>
<h1 className="mb-4">Update password</h1>
<div className="space-y-3">
<TextField
label="Old Password"
required={true}
id="oldPassword"
placeholder="*****"
inputClassName="shadow-none border border-gray-300 bg-white"
// onChange={serviceForm.handleChange}
// value={serviceForm.values.name}
/>
<TextField
label="Password"
required={true}
id="newPassword"
placeholder="*****"
inputClassName="shadow-none border border-gray-300 bg-white"
// onChange={serviceForm.handleChange}
// value={serviceForm.values.name}
/>
<TextField
label="Repeat Password"
required={true}
id="confirmPassword"
placeholder="*****"
inputClassName="shadow-none border border-gray-300 bg-white"
// onChange={serviceForm.handleChange}
// value={serviceForm.values.name}
/>
<div className="flex justify-end items-center">
<Button className="w-20 border border-sky-600">Update</Button>
{passwordUpdateForm.errors && (
<span>{JSON.stringify(passwordUpdateForm.errors)}</span>
)}
</div>
</div>
</form>
</Container>
</DashboardLayout>
);
};
export default UserPage;

View File

View File

@ -1,10 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen />
</QueryClientProvider>
</React.StrictMode>
);
export { queryClient };

View File

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

View File

@ -0,0 +1,26 @@
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)
}

7
frontend/src/types.ts Normal file
View File

@ -0,0 +1,7 @@
export type InputSize = "sm" | "md" | "lg";
export interface Service {
name: string;
references: string[];
destination: string[];
}

14
main.py
View File

@ -1,4 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from strawberry.fastapi import GraphQLRouter
from api.schema import schema
@ -13,5 +15,17 @@ graphql_app = GraphQLRouter(
app = FastAPI()
origins = [
"http://localhost:3000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(graphql_app, prefix="/graphql")
app.mount("/", SPAStaticFiles(directory="frontend/dist", html=True), name="frontend")