Compare commits
13 Commits
4fd02288cd
...
feature/up
Author | SHA1 | Date | |
---|---|---|---|
99b78b9197 | |||
c5308eb430 | |||
61154028a3 | |||
5f34e04c1d | |||
5927739f7d | |||
cce376a703 | |||
449d91c1c9 | |||
1bb8271328 | |||
a6b9878c5f | |||
fef4cac1cf | |||
e6dedf5cd8 | |||
4667029869 | |||
72123aee2f |
@ -1 +1,3 @@
|
|||||||
# traefik-confman
|
# traefik-confman
|
||||||
|
|
||||||
|
A simple management UI for your Traefik config. Inspired by [Nginx Proxy Manager](https://nginxproxymanager.com/).
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
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
|
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)
|
||||||
|
21
api/seed.py
21
api/seed.py
@ -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()
|
||||||
|
@ -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
2
frontend/.gitignore
vendored
@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
src/generated
|
16
frontend/codegen.yml
Normal file
16
frontend/codegen.yml
Normal 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"
|
@ -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
3200
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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 |
@ -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>
|
||||||
|
16
frontend/src/components/atoms/Header1.tsx
Normal file
16
frontend/src/components/atoms/Header1.tsx
Normal 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;
|
11
frontend/src/components/atoms/InlineLink.tsx
Normal file
11
frontend/src/components/atoms/InlineLink.tsx
Normal 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;
|
@ -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
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
16
frontend/src/components/layouts/Container.tsx
Normal file
16
frontend/src/components/layouts/Container.tsx
Normal 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;
|
@ -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">
|
||||||
|
16
frontend/src/components/layouts/RequireAuth.tsx
Normal file
16
frontend/src/components/layouts/RequireAuth.tsx
Normal 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;
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
86
frontend/src/components/pages/login/index.tsx
Normal file
86
frontend/src/components/pages/login/index.tsx
Normal 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;
|
@ -1,11 +0,0 @@
|
|||||||
import DashboardLayout from "../../layouts/Dashboard";
|
|
||||||
|
|
||||||
const MiddlewarePage = () => {
|
|
||||||
return (
|
|
||||||
<DashboardLayout>
|
|
||||||
<div>Middleware</div>
|
|
||||||
</DashboardLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MiddlewarePage;
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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;
|
|
74
frontend/src/components/pages/user/index.tsx
Normal file
74
frontend/src/components/pages/user/index.tsx
Normal 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;
|
0
frontend/src/hooks/.gitkeep
Normal file
0
frontend/src/hooks/.gitkeep
Normal 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 };
|
||||||
|
14
frontend/src/queries/auth.graphql
Normal file
14
frontend/src/queries/auth.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation Login($credentials: LoginInput!) {
|
||||||
|
login(body: $credentials) {
|
||||||
|
__typename
|
||||||
|
... on AuthSuccess {
|
||||||
|
user {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
token
|
||||||
|
}
|
||||||
|
... on CommonError {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
frontend/src/queries/services.graphql
Normal file
26
frontend/src/queries/services.graphql
Normal 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
7
frontend/src/types.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type InputSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
name: string;
|
||||||
|
references: string[];
|
||||||
|
destination: string[];
|
||||||
|
}
|
14
main.py
14
main.py
@ -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")
|
||||||
|
Reference in New Issue
Block a user