Compare commits

...

13 Commits

35 changed files with 4021 additions and 152 deletions

View File

@ -1 +1,3 @@
# traefik-confman # 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 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

2
frontend/.gitignore vendored
View File

@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"codegen": "graphql-codegen --config codegen.yml"
}, },
"dependencies": { "dependencies": {
"@babel/core": ">=7.0.0 <8.0.0", "@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", "@heroicons/react": "^1.0.6",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"formik": "^2.2.9",
"graphql": "^16.5.0",
"graphql-tag": "^2.12.6",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-query": "^3.39.0",
"react-router-dom": "6" "react-router-dom": "6"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^7.1.0",
"@graphql-codegen/cli": "^2.6.2",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0", "@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^1.3.0", "@vitejs/plugin-react": "^1.3.0",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
"eslint-plugin-react-hooks": "^4.3.0",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"tailwindcss": "^3.0.24", "tailwindcss": "^3.0.24",
"typescript": "^4.6.3", "typescript": "^4.6.3",
"vite": "^2.9.9" "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 { 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";
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() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<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="/setup" element={<SetupPage />} />
<Route path="/secrets" element={<SecretsPage />} /> <Route path="/secrets" element={<SecretsPage />} />
<Route path="*" element={<Navigate to="/services" />} /> <Route path="/me" element={<UserPage />} />
<Route path="/" element={<Navigate to="/services" />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<LoginPage />} />
</Routes> </Routes>
</BrowserRouter> </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 ( return (
<button <button
onClick={props.onClick} 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} {props.children}
</button> </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 ( return (
<input <input
id={props.id} id={props.id}
name={props.name} name={props.name}
type={props.type} type={props.type}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
placeholder={props.placeholder} placeholder={props.placeholder}
required={props.required} 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"; import { FC, HTMLProps } from "react";
const InputLabel: FC<HTMLProps<HTMLLabelElement>> = (props) => { interface InputLabelProps extends HTMLProps<HTMLLabelElement> {
required?: boolean;
}
const InputLabel: FC<InputLabelProps> = (props) => {
return ( return (
<label <label
htmlFor={props.id} htmlFor={props.id}
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-gray-700"
> >
{props.children} {props.children}
{props.required && <span className="text-red-600">*</span>}
</label> </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) => { 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

@ -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"; } 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 { 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}
@ -27,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) => () => {
@ -47,20 +55,6 @@ const Sidebar = () => {
</h2> </h2>
</div> </div>
</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>
<div className="flex flex-col flex-grow px-4 mt-8 space-y-2"> <div className="flex flex-col flex-grow px-4 mt-8 space-y-2">
<div className="ml-3"> <div className="ml-3">
@ -70,7 +64,9 @@ const Sidebar = () => {
<ul className="space-y-1"> <ul className="space-y-1">
<li> <li>
<SidebarTab <SidebarTab
badge={<Badge>0</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" />
@ -88,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>
@ -112,12 +108,18 @@ const Sidebar = () => {
</ul> </ul>
</nav> </nav>
</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 space-x-4">
<div className="flex flex-row justify-center items-center space-x-4"> <SidebarTab onClick={navFactory("/me")}>
<p>nikuu</p> <span className="ml-3">admin</span>
</div> </SidebarTab>
<div className="w-6 h-6"> <div className="w-6 h-6">
<LogoutIcon /> <LogoutIcon
className="cursor-pointer"
onClick={() => {
localStorage.removeItem("token");
navigate("/login");
}}
/>
</div> </div>
</div> </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 Input from "../atoms/Input";
import InputLabel from "../atoms/InputLabel"; 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 ( return (
<div> <div className="w-full">
<InputLabel id={props.id}>{props.label}</InputLabel> <InputLabel id={props.id} required={props.required}>
{props.label}
</InputLabel>
<div className="mt-1"> <div className="mt-1">
<Input <Input
id={props.id} id={props.id}
type={props.type} type={props.type}
className={props.inputClassName}
placeholder={props.placeholder} placeholder={props.placeholder}
required={props.required} required={props.required}
size={props.size}
onChange={props.onChange}
onBlur={props.onBlur}
value={props.value}
/> />
</div> </div>
</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 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 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 ( return (
<DashboardLayout> <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> </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 React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}>
<App /> <App />
<ReactQueryDevtools initialIsOpen />
</QueryClientProvider>
</React.StrictMode> </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 import FastAPI
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
@ -13,5 +15,17 @@ graphql_app = GraphQLRouter(
app = FastAPI() 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.include_router(graphql_app, prefix="/graphql")
app.mount("/", SPAStaticFiles(directory="frontend/dist", html=True), name="frontend") app.mount("/", SPAStaticFiles(directory="frontend/dist", html=True), name="frontend")