backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Traefik GUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3951
frontend/package-lock.json
generated
Normal file
3951
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "traefik-gui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"build:check": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"zustand": "^4.5.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"lucide-react": "^0.309.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
30
frontend/src/App.tsx
Normal file
30
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth'
|
||||
import Layout from './components/layout/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Routers from './pages/Routers'
|
||||
import Services from './pages/Services'
|
||||
import Middlewares from './pages/Middlewares'
|
||||
import Certificates from './pages/Certificates'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
if (isLoading) {
|
||||
return <div style={{display:'flex',alignItems:'center',justifyContent:'center',minHeight:'100vh'}}>Loading...</div>
|
||||
}
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/*" element={<PrivateRoute><Layout /></PrivateRoute>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
276
frontend/src/api/client.ts
Normal file
276
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
let csrfToken: string | null = null
|
||||
|
||||
export const setCSRFToken = (token: string) => {
|
||||
csrfToken = token
|
||||
}
|
||||
|
||||
export const getCSRFToken = () => csrfToken
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
if (csrfToken && ['post', 'put', 'patch', 'delete'].includes(config.method?.toLowerCase() || '')) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// Capture rotated CSRF token from response header if present
|
||||
const newToken = (response.headers as Record<string,string>)['x-csrf-token'] || (response.headers as Record<string,string>)['X-CSRF-Token']
|
||||
if (newToken) csrfToken = newToken
|
||||
return response
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ user: User; csrf_token: string }>('/auth/login', { username, password }),
|
||||
|
||||
logout: () =>
|
||||
api.post('/auth/logout'),
|
||||
|
||||
me: () =>
|
||||
api.get<User>('/auth/me'),
|
||||
}
|
||||
|
||||
// Config API
|
||||
export const configApi = {
|
||||
listRouters: () =>
|
||||
api.get<Router[]>('/config/routers'),
|
||||
|
||||
getRouter: (id: string) =>
|
||||
api.get<Router>(`/config/routers/${id}`),
|
||||
|
||||
createRouter: (router: Partial<Router>) =>
|
||||
api.post<Router>('/config/routers', router),
|
||||
|
||||
updateRouter: (id: string, router: Partial<Router>) =>
|
||||
api.put<Router>(`/config/routers/${id}`, router),
|
||||
|
||||
deleteRouter: (id: string) =>
|
||||
api.delete(`/config/routers/${id}`),
|
||||
|
||||
listServices: () =>
|
||||
api.get<Service[]>('/config/services'),
|
||||
|
||||
listMiddlewares: () =>
|
||||
api.get<Middleware[]>('/config/middlewares'),
|
||||
|
||||
listCertificates: () =>
|
||||
api.get<Certificate[]>('/config/certificates'),
|
||||
|
||||
listEntryPoints: () =>
|
||||
api.get<EntryPoint[]>('/config/entrypoints'),
|
||||
}
|
||||
|
||||
// File-provider API (Phase 2/3)
|
||||
export const fileApi = {
|
||||
listFiles: () => api.get<FileMeta[]>('/config/files'),
|
||||
getFile: (name: string) => api.get<{ filename: string; content: string }>(`/config/files/${encodeURIComponent(name)}`),
|
||||
preview: (filename: string, content: string) => api.post<PreviewResult>('/config/preview', { filename, content }),
|
||||
validate: (filename: string, content: string) => api.post<PreviewResult>('/config/validate', { filename, content }),
|
||||
apply: (filename: string, content: string, confirm: boolean) => api.post<{ message: string; diff: string }>('/config/apply', { filename, content, confirm }),
|
||||
rollback: (filename: string, backupId?: string) => api.post<{ message: string; diff: string }>('/config/rollback', { filename, backupId }),
|
||||
history: (filename?: string) => api.get<BackupInfo[]>(`/config/history${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`),
|
||||
}
|
||||
|
||||
// Traefik API (read-only)
|
||||
export const traefikApi = {
|
||||
getOverview: () =>
|
||||
api.get<Overview>('/traefik/overview'),
|
||||
|
||||
listRouters: () =>
|
||||
api.get<Router[]>('/traefik/routers'),
|
||||
|
||||
listServices: () =>
|
||||
api.get<Service[]>('/traefik/services'),
|
||||
|
||||
listMiddlewares: () =>
|
||||
api.get<Middleware[]>('/traefik/middlewares'),
|
||||
|
||||
listCertificates: () =>
|
||||
api.get<Certificate[]>('/traefik/certificates'),
|
||||
|
||||
listEntryPoints: () =>
|
||||
api.get<EntryPoint[]>('/traefik/entrypoints'),
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'operator' | 'viewer'
|
||||
}
|
||||
|
||||
export interface Router {
|
||||
name: string
|
||||
provider: string
|
||||
rule: string
|
||||
entry_points: string[]
|
||||
service: string
|
||||
middlewares: string[]
|
||||
priority: number
|
||||
tls?: RouterTLSConfig
|
||||
status: string
|
||||
using?: string[]
|
||||
}
|
||||
|
||||
export interface RouterTLSConfig {
|
||||
options?: string
|
||||
cert_resolver?: string
|
||||
domains?: Domain[]
|
||||
}
|
||||
|
||||
export interface Domain {
|
||||
main: string
|
||||
sans?: string[]
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
name: string
|
||||
provider: string
|
||||
type: string
|
||||
load_balancer?: LoadBalancer
|
||||
server_status?: Record<string, string>
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface LoadBalancer {
|
||||
servers?: Server[]
|
||||
strategy?: string
|
||||
pass_host_header?: boolean
|
||||
health_check?: HealthCheck
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
url: string
|
||||
weight?: number
|
||||
preserve_path?: boolean
|
||||
}
|
||||
|
||||
export interface HealthCheck {
|
||||
scheme?: string
|
||||
path?: string
|
||||
method?: string
|
||||
status?: number
|
||||
port?: number
|
||||
interval?: string
|
||||
timeout?: string
|
||||
hostname?: string
|
||||
follow_redirects?: boolean
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Middleware {
|
||||
name: string
|
||||
provider: string
|
||||
type: string
|
||||
spec?: Record<string, unknown>
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
name: string
|
||||
common_name: string
|
||||
sans: string[]
|
||||
issuer_org: string
|
||||
issuer_cn: string
|
||||
not_before: string
|
||||
not_after: string
|
||||
status: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface EntryPoint {
|
||||
name: string
|
||||
address: string
|
||||
http?: EntryPointHTTP
|
||||
forwarded_headers?: ForwardedHeaders
|
||||
}
|
||||
|
||||
export interface EntryPointHTTP {
|
||||
tls?: EntryPointTLS
|
||||
middlewares?: string[]
|
||||
redirect_to_https?: boolean
|
||||
}
|
||||
|
||||
export interface EntryPointTLS {
|
||||
cert_resolver?: string
|
||||
domains?: Domain[]
|
||||
options?: string
|
||||
}
|
||||
|
||||
export interface ForwardedHeaders {
|
||||
insecure_skip_verify?: boolean
|
||||
trusted_ips?: string[]
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
http: {
|
||||
routers: Section
|
||||
services: Section
|
||||
middlewares: Section
|
||||
}
|
||||
tcp: {
|
||||
routers: Section
|
||||
services: Section
|
||||
middlewares: Section
|
||||
}
|
||||
udp: {
|
||||
routers: Section
|
||||
services: Section
|
||||
}
|
||||
certificates?: Section
|
||||
providers?: string[]
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
total: number
|
||||
warnings: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface FileMeta {
|
||||
filename: string
|
||||
size: number
|
||||
modTime: string
|
||||
valid: boolean
|
||||
validationErr?: string
|
||||
lastAction?: string
|
||||
lastActionTime?: string
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
valid: boolean
|
||||
errors?: Array<{ message: string; line?: number; column?: number }>
|
||||
diff: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
id: string
|
||||
filename: string
|
||||
created_at: string
|
||||
created_by: string
|
||||
reason: string
|
||||
}
|
||||
11
frontend/src/api/queryClient.ts
Normal file
11
frontend/src/api/queryClient.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
74
frontend/src/components/layout/Layout.tsx
Normal file
74
frontend/src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { Home, Network, Server, Shield, Settings, LogOut, Menu, ChevronDown, FileText } from 'lucide-react'
|
||||
import Dashboard from '../../pages/Dashboard'
|
||||
import Routers from '../../pages/Routers'
|
||||
import Services from '../../pages/Services'
|
||||
import Middlewares from '../../pages/Middlewares'
|
||||
import Certificates from '../../pages/Certificates'
|
||||
import SettingsPage from '../../pages/Settings'
|
||||
import ConfigEditor from '../../pages/ConfigEditor'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||
{ name: 'Config Editor', href: '/config', icon: FileText },
|
||||
{ name: 'Routers', href: '/routers', icon: Network },
|
||||
{ name: 'Services', href: '/services', icon: Server },
|
||||
{ name: 'Middlewares', href: '/middlewares', icon: Shield },
|
||||
{ name: 'Certificates', href: '/certificates', icon: Settings },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<aside style={{ width: 'var(--sidebar-width)', background: 'var(--color-surface)', borderRight: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', position: 'fixed', top:0, left:0, height:'100vh', zIndex:100 }}>
|
||||
<div style={{ padding: '20px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: 36, height: 36, background: 'var(--color-primary)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 700 }}>TG</div>
|
||||
<span style={{ fontWeight: 600, fontSize: 18 }}>Traefik GUI</span>
|
||||
</div>
|
||||
<nav style={{ flex: 1, padding: '16px 12px', overflowY: 'auto' }}>
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
{navigation.map(item => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<NavLink to={item.href} style={({ isActive }) => ({ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 12px', borderRadius: '8px', color: isActive ? 'var(--color-primary)' : 'var(--color-text)', background: isActive ? 'var(--color-primary-light)' : 'transparent', textDecoration: 'none' })}>
|
||||
<Icon size={20} />{item.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<div style={{ padding: '16px', borderTop: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: 36, height: 36, background: 'var(--color-primary)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white' }}>{user?.username?.charAt(0).toUpperCase()}</div>
|
||||
<div style={{ flex: 1 }}><p style={{ fontWeight: 500, fontSize: 14 }}>{user?.username}</p><p style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{user?.role}</p></div>
|
||||
<button onClick={() => setUserMenuOpen(!userMenuOpen)}><ChevronDown size={16} /></button>
|
||||
</div>
|
||||
{userMenuOpen && <div style={{ marginTop: 8 }}><button onClick={() => logout()} style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--color-danger)' }}><LogOut size={16} /> Sign out</button></div>}
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ flex: 1, marginLeft: 'var(--sidebar-width)', minWidth: 0 }}>
|
||||
<main style={{ padding: '24px' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/config" element={<ConfigEditor />} />
|
||||
<Route path="/routers" element={<Routers />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
<Route path="/middlewares" element={<Middlewares />} />
|
||||
<Route path="/certificates" element={<Certificates />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/ui/Toast.tsx
Normal file
2
frontend/src/components/ui/Toast.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export function Toaster(){ return null }
|
||||
export function useToast(){ return { showToast: (_t:string,_m:string)=>{} } }
|
||||
14
frontend/src/hooks/useAuth.tsx
Normal file
14
frontend/src/hooks/useAuth.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { authApi, setCSRFToken } from '../api/client'
|
||||
import type { User } from '../api/client'
|
||||
interface AuthContextType { user: User | null; isAuthenticated: boolean; isLoading: boolean; login: (u:string,p:string)=>Promise<void>; logout: ()=>Promise<void> }
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(()=>{ authApi.me().then(r=>setUser(r.data)).catch(()=>setUser(null)).finally(()=>setIsLoading(false)) },[])
|
||||
const login = async (username:string,password:string)=>{ const {data}=await authApi.login(username,password); if (data.csrf_token) setCSRFToken(data.csrf_token); setUser(data.user) }
|
||||
const logout = async ()=>{ await authApi.logout(); setCSRFToken(''); setUser(null) }
|
||||
return <AuthContext.Provider value={{user,isAuthenticated:!!user,isLoading,login,logout}}>{children}</AuthContext.Provider>
|
||||
}
|
||||
export function useAuth(){ const c=useContext(AuthContext); if(!c) throw new Error('useAuth must be used within AuthProvider'); return c }
|
||||
463
frontend/src/index.css
Normal file
463
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: #2496d7;
|
||||
--color-primary-hover: #1a7bc4;
|
||||
--color-primary-light: #e8f4fd;
|
||||
--color-success: #28a745;
|
||||
--color-warning: #ffc107;
|
||||
--color-danger: #dc3545;
|
||||
--color-info: #17a2b8;
|
||||
--color-bg: #f8f9fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #dee2e6;
|
||||
--color-text: #212529;
|
||||
--color-text-muted: #6c757d;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 64px;
|
||||
--transition: 0.15s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-primary: #4da8e0;
|
||||
--color-primary-hover: #6db8e8;
|
||||
--color-primary-light: #1a3a5c;
|
||||
--color-bg: #1a1d21;
|
||||
--color-surface: #22272e;
|
||||
--color-border: #30363d;
|
||||
--color-text: #e6edf3;
|
||||
--color-text-muted: #8b949e;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: var(--color-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
td:first-child, th:first-child {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
td:last-child, th:last-child {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-enabled { background: #dcfce7; color: #166534; }
|
||||
.status-enabled::before { background: #22c55e; }
|
||||
|
||||
.status-disabled { background: #fef2f2; color: #991b1b; }
|
||||
.status-disabled::before { background: #ef4444; }
|
||||
|
||||
.status-warning { background: #fef9c3; color: #854d0e; }
|
||||
.status-warning::before { background: #eab308; }
|
||||
|
||||
.status-expired { background: #fef2f2; color: #991b1b; }
|
||||
.status-expired::before { background: #ef4444; }
|
||||
|
||||
.status-valid { background: #dcfce7; color: #166534; }
|
||||
.status-valid::before { background: #22c55e; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-group .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.badge-provider {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-success { border-left: 4px solid var(--color-success); }
|
||||
.toast-error { border-left: 4px solid var(--color-danger); }
|
||||
.toast-warning { border-left: 4px solid var(--color-warning); }
|
||||
.toast-info { border-left: 4px solid var(--color-info); }
|
||||
|
||||
.toast-message { flex: 1; font-size: 14px; }
|
||||
.toast-close { color: var(--color-text-muted); padding: 4px; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
max-width: 300px;
|
||||
}
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30000, retry: 1, refetchOnWindowFocus: false } } })
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter><App /></BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
186
frontend/src/pages/Certificates.tsx
Normal file
186
frontend/src/pages/Certificates.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { traefikApi, type Certificate } from '../api/client'
|
||||
import { Search, Plus, Filter, ChevronDown, Shield, AlertTriangle, XCircle, CheckCircle, AlertCircle, Calendar, Lock } from 'lucide-react'
|
||||
import { format, differenceInDays } from 'date-fns'
|
||||
|
||||
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||
valid: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||
expired: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
export default function Certificates() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'not_after'>('not_after')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const { data: certificates, isLoading, error } = useQuery({
|
||||
queryKey: ['certificates'],
|
||||
queryFn: traefikApi.listCertificates,
|
||||
})
|
||||
|
||||
const filteredCertificates = certificates?.filter(c => {
|
||||
if (search && !c.name.toLowerCase().includes(search.toLowerCase()) && !c.common_name.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (statusFilter !== 'all' && c.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||
if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir
|
||||
return new Date(a.not_after).getTime() - new Date(b.not_after).getTime() * dir
|
||||
}) || []
|
||||
|
||||
const handleSort = (field: 'name' | 'status' | 'provider' | 'not_after') => {
|
||||
if (sortBy === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(field)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'not_after' }) => (
|
||||
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{children}
|
||||
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
|
||||
const getDaysUntilExpiry = (notAfter: string) => {
|
||||
return differenceInDays(new Date(notAfter), new Date())
|
||||
}
|
||||
|
||||
const getExpiryStatus = (days: number) => {
|
||||
if (days < 0) return { label: 'Expired', color: 'var(--color-danger)', bg: '#fef2f2', icon: XCircle }
|
||||
if (days <= 30) return { label: `${days} days`, color: 'var(--color-warning)', bg: '#fef9c3', icon: AlertTriangle }
|
||||
return { label: `${days} days`, color: 'var(--color-success)', bg: '#dcfce7', icon: CheckCircle }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Monitor TLS certificates and expiration dates</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled>
|
||||
<Plus size={18} /> Add Certificate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search certificates..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '40px' }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ width: 'auto', minWidth: 160 }}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="valid">Valid</option>
|
||||
<option value="warning">Expiring Soon</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||
<SortableHeader field="name">Common Name</SortableHeader>
|
||||
<th>SANs</th>
|
||||
<th>Issuer</th>
|
||||
<SortableHeader field="not_after">Valid Until</SortableHeader>
|
||||
<SortableHeader field="not_after">Expires In</SortableHeader>
|
||||
<SortableHeader field="provider">Provider</SortableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCertificates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||
No certificates found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredCertificates.map(cert => {
|
||||
const statusConfig = statusColors[cert.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertCircle }
|
||||
const StatusIcon = statusConfig.icon
|
||||
const daysLeft = getDaysUntilExpiry(cert.not_after)
|
||||
const expiryInfo = getExpiryStatus(daysLeft)
|
||||
const ExpiryIcon = expiryInfo.icon
|
||||
return (
|
||||
<tr key={cert.name}>
|
||||
<td>
|
||||
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||
<StatusIcon size={10} /> {cert.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13, fontWeight: 500 }}>{cert.common_name}</td>
|
||||
<td style={{ maxWidth: 250 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{cert.sans.length > 0 ? cert.sans.join(', ') : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontSize: 13 }}>
|
||||
{cert.issuer_org || cert.issuer_cn}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--color-text-muted)' }} />
|
||||
{format(new Date(cert.not_after), 'MMM d, yyyy')}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontSize: 12, fontWeight: 500, background: expiryInfo.bg, color: expiryInfo.color }}>
|
||||
<ExpiryIcon size={10} /> {expiryInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="badge badge-provider">{cert.provider}</span></td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredCertificates.length > 0 && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||
<span>Showing {filteredCertificates.length} of {certificates?.length} certificates</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
522
frontend/src/pages/ConfigEditor.tsx
Normal file
522
frontend/src/pages/ConfigEditor.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { useEffect, useState, useRef } from 'react'
|
||||
import { fileApi } from '../api/client'
|
||||
import type { FileMeta, PreviewResult, BackupInfo } from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { Save, Eye, RotateCcw, FilePlus, AlertTriangle, CheckCircle, XCircle, Clock, FileText, Shield } from 'lucide-react'
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`
|
||||
return `${(n/1024/1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(s: string) {
|
||||
try { return new Date(s).toLocaleString() } catch { return s }
|
||||
}
|
||||
|
||||
export default function ConfigEditor() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const canApply = user?.role === 'admin' || user?.role === 'operator'
|
||||
const canRollback = isAdmin
|
||||
|
||||
// File list state
|
||||
const [files, setFiles] = useState<FileMeta[]>([])
|
||||
const [filesLoading, setFilesLoading] = useState(true)
|
||||
const [filesError, setFilesError] = useState<string|null>(null)
|
||||
|
||||
// Editor state
|
||||
const [selected, setSelected] = useState<string>("")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [newFilename, setNewFilename] = useState("")
|
||||
const [creatingNew, setCreatingNew] = useState(false)
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Preview/apply state
|
||||
const [preview, setPreview] = useState<PreviewResult|null>(null)
|
||||
const [previewError, setPreviewError] = useState<string|null>(null)
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [isApplying, setIsApplying] = useState(false)
|
||||
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false)
|
||||
const [successMsg, setSuccessMsg] = useState<string|null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string|null>(null)
|
||||
|
||||
// History
|
||||
const [history, setHistory] = useState<BackupInfo[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [rollbackConfirm, setRollbackConfirm] = useState<{id:string, filename:string}|null>(null)
|
||||
|
||||
const isDirty = content !== originalContent
|
||||
const filename = creatingNew ? newFilename.trim() : selected
|
||||
|
||||
// Load file list
|
||||
const loadFiles = async () => {
|
||||
setFilesLoading(true)
|
||||
setFilesError(null)
|
||||
try {
|
||||
const res = await fileApi.listFiles()
|
||||
setFiles(res.data)
|
||||
} catch (e:any) {
|
||||
setFilesError(e.response?.data?.error || e.message || "Failed to load files")
|
||||
} finally {
|
||||
setFilesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadHistory = async (fname?: string) => {
|
||||
setHistoryLoading(true)
|
||||
try {
|
||||
const res = await fileApi.history(fname)
|
||||
setHistory(res.data)
|
||||
} catch (e:any) {
|
||||
// silent
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
loadHistory()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ""
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [isDirty])
|
||||
|
||||
const handleSelectFile = async (fname: string) => {
|
||||
if (isDirty && !confirm("You have unsaved changes. Discard them?")) return
|
||||
setSelected(fname)
|
||||
setCreatingNew(false)
|
||||
setNewFilename("")
|
||||
setPreview(null)
|
||||
setPreviewError(null)
|
||||
setErrorMsg(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
const res = await fileApi.getFile(fname)
|
||||
setContent(res.data.content)
|
||||
setOriginalContent(res.data.content)
|
||||
loadHistory(fname)
|
||||
setTimeout(() => editorRef.current?.focus(), 100)
|
||||
} catch (e:any) {
|
||||
setErrorMsg(e.response?.data?.error || "Failed to load file")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNew = () => {
|
||||
if (isDirty && !confirm("You have unsaved changes. Discard them?")) return
|
||||
setCreatingNew(true)
|
||||
setSelected("")
|
||||
setNewFilename("")
|
||||
setContent(sampleYAML)
|
||||
setOriginalContent("")
|
||||
setPreview(null)
|
||||
setPreviewError(null)
|
||||
setHistory([])
|
||||
}
|
||||
|
||||
const validateFilenameClient = (name: string): string|null => {
|
||||
if (!name) return "Filename is required"
|
||||
if (name.includes("..")) return "Filename must not contain '..'"
|
||||
if (name.includes("/") || name.includes("\\")) return "Use a single file name without path separators"
|
||||
const lower = name.toLowerCase()
|
||||
if (!(lower.endsWith(".yml") || lower.endsWith(".yaml") || lower.endsWith(".toml"))) return "Filename must end with .yml, .yaml, or .toml"
|
||||
if (name.length > 255) return "Filename too long"
|
||||
return null
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreviewError(null)
|
||||
setPreview(null)
|
||||
setErrorMsg(null)
|
||||
const fnameErr = validateFilenameClient(filename)
|
||||
if (fnameErr) {
|
||||
setPreviewError(fnameErr)
|
||||
return
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setPreviewError("Content must not be empty")
|
||||
return
|
||||
}
|
||||
setIsPreviewing(true)
|
||||
try {
|
||||
const res = await fileApi.preview(filename, content)
|
||||
setPreview(res.data)
|
||||
} catch (e:any) {
|
||||
const data = e.response?.data
|
||||
if (data?.errors) {
|
||||
setPreview({ valid: false, errors: data.errors, diff: data.diff || "" })
|
||||
} else if (e.response?.status === 400) {
|
||||
setPreview({ valid: false, errors: [{message: data?.error || "Validation failed"}], diff: "" })
|
||||
} else if (e.response?.status === 401) {
|
||||
setErrorMsg("Session expired. Please log in again.")
|
||||
} else if (e.response?.status === 403) {
|
||||
setErrorMsg("You don't have permission to preview. Requires operator or admin.")
|
||||
} else {
|
||||
setErrorMsg(data?.error || e.message || "Preview failed")
|
||||
}
|
||||
} finally {
|
||||
setIsPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!preview || !preview.valid) return
|
||||
setIsApplying(true)
|
||||
setErrorMsg(null)
|
||||
try {
|
||||
const res = await fileApi.apply(filename, content, true)
|
||||
setSuccessMsg(`Applied ${filename} successfully`)
|
||||
setOriginalContent(content)
|
||||
setPreview(null)
|
||||
setApplyConfirmOpen(false)
|
||||
// Refresh
|
||||
await loadFiles()
|
||||
await loadHistory(filename)
|
||||
// If was new file, select it
|
||||
if (creatingNew) {
|
||||
setCreatingNew(false)
|
||||
setSelected(filename)
|
||||
}
|
||||
} catch (e:any) {
|
||||
const status = e.response?.status
|
||||
const data = e.response?.data
|
||||
if (status === 400) {
|
||||
if (data?.errors) setPreview({ valid: false, errors: data.errors, diff: data.diff || "" })
|
||||
else setErrorMsg(data?.error || "Validation failed")
|
||||
} else if (status === 403) setErrorMsg("Forbidden: operator or admin required")
|
||||
else if (status === 409) setErrorMsg("Conflict: file is being modified concurrently, try again")
|
||||
else if (status === 401) setErrorMsg("Session expired. Please log in again.")
|
||||
else setErrorMsg(data?.error || e.message || "Apply failed")
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async (backupId?: string) => {
|
||||
const targetFilename = selected || filename
|
||||
if (!targetFilename) return
|
||||
setErrorMsg(null)
|
||||
try {
|
||||
const res = await fileApi.rollback(targetFilename, backupId)
|
||||
setSuccessMsg(`Rolled back ${targetFilename}`)
|
||||
setRollbackConfirm(null)
|
||||
// Reload file content
|
||||
try {
|
||||
const fileRes = await fileApi.getFile(targetFilename)
|
||||
setContent(fileRes.data.content)
|
||||
setOriginalContent(fileRes.data.content)
|
||||
} catch {
|
||||
// File was deleted (rollback to empty)
|
||||
setContent("")
|
||||
setOriginalContent("")
|
||||
}
|
||||
await loadFiles()
|
||||
await loadHistory(targetFilename)
|
||||
setPreview(null)
|
||||
} catch (e:any) {
|
||||
const status = e.response?.status
|
||||
if (status === 403) setErrorMsg("Rollback requires admin role")
|
||||
else setErrorMsg(e.response?.data?.error || e.message || "Rollback failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Derived stats for empty state
|
||||
const hasFiles = files.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Configuration Editor</h1>
|
||||
<p style={{color:'var(--color-text-muted)'}}>Manage dynamic file-provider YAML — validated, atomic, with backup & rollback</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleCreateNew}>
|
||||
<FilePlus size={18}/> New file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{successMsg && (
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,padding:'12px',background:'#dcfce7',border:'1px solid #bbf7d0',borderRadius:8,color:'#166534',marginBottom:16}}>
|
||||
<CheckCircle size={18}/>{successMsg}
|
||||
<button onClick={()=>setSuccessMsg(null)} style={{marginLeft:'auto',color:'#166534'}}>×</button>
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,padding:'12px',background:'#fef2f2',border:'1px solid #fecaca',borderRadius:8,color:'#991b1b',marginBottom:16}}>
|
||||
<XCircle size={18}/>{errorMsg}
|
||||
<button onClick={()=>setErrorMsg(null)} style={{marginLeft:'auto',color:'#991b1b'}}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{display:'grid',gridTemplateColumns:'320px 1fr',gap:16, alignItems:'start'}}>
|
||||
{/* File list */}
|
||||
<div className="card" style={{position:'sticky',top:16}}>
|
||||
<div className="card-header">
|
||||
<h3 style={{fontSize:14,fontWeight:600,display:'flex',alignItems:'center',gap:8}}><FileText size={16}/> Files in configs/dynamic</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={loadFiles}>Refresh</button>
|
||||
</div>
|
||||
<div className="card-body" style={{padding:0}}>
|
||||
{filesLoading ? (
|
||||
<div style={{padding:20,textAlign:'center',color:'var(--color-text-muted)'}}>Loading…</div>
|
||||
) : filesError ? (
|
||||
<div style={{padding:16,color:'var(--color-danger)',fontSize:14}}>{filesError}</div>
|
||||
) : !hasFiles ? (
|
||||
<div className="empty-state" style={{padding:24}}>
|
||||
<div className="empty-state-title">No files yet</div>
|
||||
<div className="empty-state-text">Create a new .yml file to start. Files are never exposed with full paths.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{maxHeight:'60vh',overflowY:'auto'}}>
|
||||
{files.map(f => (
|
||||
<button
|
||||
key={f.filename}
|
||||
onClick={()=>handleSelectFile(f.filename)}
|
||||
style={{
|
||||
display:'flex',flexDirection:'column',alignItems:'flex-start',width:'100%',padding:'12px 16px',textAlign:'left',
|
||||
background: selected===f.filename ? 'var(--color-primary-light)' : 'transparent',
|
||||
borderBottom:'1px solid var(--color-border)',
|
||||
borderLeft: selected===f.filename ? '3px solid var(--color-primary)' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{fontFamily:'monospace',fontSize:13,fontWeight: selected===f.filename?600:500,display:'flex',alignItems:'center',gap:6}}>
|
||||
{f.valid ? <CheckCircle size={12} style={{color:'var(--color-success)'}}/> : <XCircle size={12} style={{color:'var(--color-danger)'}}/>}
|
||||
{f.filename}
|
||||
</span>
|
||||
<span style={{fontSize:12,color:'var(--color-text-muted)',display:'flex',gap:8,marginTop:4,flexWrap:'wrap'}}>
|
||||
<span>{formatBytes(f.size)}</span>
|
||||
<span>· {formatDate(f.modTime)}</span>
|
||||
{!f.valid && <span style={{color:'var(--color-danger)'}}>· invalid</span>}
|
||||
</span>
|
||||
{f.lastAction && <span style={{fontSize:11,color:'var(--color-text-muted)',marginTop:2}}>last: {f.lastAction} {f.lastActionTime ? formatDate(f.lastActionTime) : ''}</span>}
|
||||
{f.validationErr && <span style={{fontSize:11,color:'var(--color-danger)',marginTop:2,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis',maxWidth:'100%'}}>{f.validationErr}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{padding:'12px 16px',borderTop:'1px solid var(--color-border)',fontSize:12,color:'var(--color-text-muted)'}}>
|
||||
<Shield size={12} style={{display:'inline',marginRight:4}}/> Only filenames shown — no paths exposed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor + preview */}
|
||||
<div style={{display:'flex',flexDirection:'column',gap:16, minWidth:0}}>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 style={{fontSize:14,fontWeight:600}}>
|
||||
{creatingNew ? "New file" : selected ? `Editing: ${selected}` : "Select a file or create new"}
|
||||
{isDirty && <span style={{marginLeft:8,color:'var(--color-warning)',fontSize:12}}>• unsaved changes</span>}
|
||||
</h3>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<span style={{fontSize:12,color:'var(--color-text-muted)',display:'flex',alignItems:'center',gap:4}}>
|
||||
{canApply ? <Shield size={12}/> : <AlertTriangle size={12}/>} {user?.role} {canApply ? "can apply" : "read-only"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body" style={{display:'flex',flexDirection:'column',gap:12}}>
|
||||
{creatingNew && (
|
||||
<div className="form-group" style={{marginBottom:0}}>
|
||||
<label className="form-label">Filename (must end .yml/.yaml, no path)</label>
|
||||
<input className="form-input" placeholder="my-service.yml" value={newFilename} onChange={e=>setNewFilename(e.target.value)} style={{fontFamily:'monospace'}} />
|
||||
<div className="form-help">Example: api.yml — traversal and unsupported extensions are rejected client and server side</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{position:'relative'}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
|
||||
<label className="form-label" style={{marginBottom:0}}>YAML Content</label>
|
||||
<span style={{fontSize:12,color:'var(--color-text-muted)'}}>{content.length} chars</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={e=>setContent(e.target.value)}
|
||||
placeholder="http:\n routers:\n my-router:\n rule: \"Host(`example.com`)\"\n service: my-service\n..."
|
||||
style={{
|
||||
width:'100%',minHeight:340,padding:'12px',fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace',fontSize:13,
|
||||
lineHeight:1.5,border:'1px solid var(--color-border)',borderRadius:8,background:'var(--color-bg)',
|
||||
tabSize:2,whiteSpace:'pre',overflowX:'auto'
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div style={{fontSize:11,color:'var(--color-text-muted)',marginTop:4}}>Syntax highlighting via monospace + server-side parser validation. Secrets are never logged or stored in localStorage.</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
|
||||
<button className="btn btn-secondary" onClick={handlePreview} disabled={isPreviewing || isApplying}>
|
||||
<Eye size={16}/>{isPreviewing ? "Validating..." : "Validate & Preview"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={()=>setApplyConfirmOpen(true)}
|
||||
disabled={!preview || !preview.valid || isApplying || isPreviewing || !canApply}
|
||||
title={!canApply ? "Requires operator or admin" : preview && !preview.valid ? "Fix validation errors first" : ""}
|
||||
>
|
||||
<Save size={16}/>{isApplying ? "Applying..." : "Apply (confirm)"}
|
||||
</button>
|
||||
{isDirty && <span style={{fontSize:12,color:'var(--color-warning)',display:'flex',alignItems:'center',gap:4}}><AlertTriangle size={12}/> Unsaved changes</span>}
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
<div style={{padding:12,background:'#fef2f2',border:'1px solid #fecaca',borderRadius:8,color:'#991b1b',fontSize:13}}>
|
||||
<strong>Validation error:</strong> {previewError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && !preview.valid && preview.errors && (
|
||||
<div style={{padding:12,background:'#fef2f2',border:'1px solid #fecaca',borderRadius:8}}>
|
||||
<div style={{fontWeight:600,color:'#991b1b',display:'flex',alignItems:'center',gap:6}}><XCircle size={16}/> Validation failed</div>
|
||||
<ul style={{marginTop:8,paddingLeft:16}}>
|
||||
{preview.errors.map((e, i) => (
|
||||
<li key={i} style={{fontSize:13,fontFamily:'monospace',color:'#991b1b'}}>
|
||||
{e.line ? `line ${e.line}${e.column ? ` col ${e.column}` : ''}: ` : ''}{e.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && preview.valid && (
|
||||
<div style={{border:'1px solid var(--color-border)',borderRadius:8,overflow:'hidden'}}>
|
||||
<div style={{padding:'8px 12px',background:'var(--color-bg)',borderBottom:'1px solid var(--color-border)',display:'flex',alignItems:'center',gap:8,fontSize:13,fontWeight:600}}>
|
||||
<CheckCircle size={14} style={{color:'var(--color-success)'}}/> Preview diff — no file modified yet
|
||||
</div>
|
||||
<pre style={{
|
||||
margin:0,padding:12,background:'#fafafa',overflowX:'auto',fontSize:12,lineHeight:1.5,
|
||||
fontFamily:'ui-monospace, monospace',whiteSpace:'pre-wrap',wordBreak:'break-word', maxHeight:260, overflowY:'auto'
|
||||
}}>
|
||||
{preview.diff ? preview.diff.split('\n').map((line, idx) => {
|
||||
let style: any = {}
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) style = {background:'#dcfce7',color:'#166534'}
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) style = {background:'#fef2f2',color:'#991b1b'}
|
||||
else if (line.startsWith('---') || line.startsWith('+++')) style = {color:'var(--color-text-muted)',fontWeight:600}
|
||||
return <div key={idx} style={style}>{line || ' '}</div>
|
||||
}) : <span style={{color:'var(--color-text-muted)'}}>No changes (identical to current file, or new file with same content)</span>}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History / rollback */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 style={{fontSize:14,fontWeight:600,display:'flex',alignItems:'center',gap:8}}><Clock size={16}/> History & Rollback {selected ? `— ${selected}` : ""}</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={()=>loadHistory(selected || undefined)} disabled={historyLoading}>Refresh</button>
|
||||
</div>
|
||||
<div className="card-body" style={{padding:0}}>
|
||||
{historyLoading ? (
|
||||
<div style={{padding:16,textAlign:'center',color:'var(--color-text-muted)'}}>Loading history…</div>
|
||||
) : history.length===0 ? (
|
||||
<div style={{padding:16,textAlign:'center',color:'var(--color-text-muted)',fontSize:13}}>No backups yet. Apply a change to create history.</div>
|
||||
) : (
|
||||
<div style={{overflowX:'auto'}}>
|
||||
<table style={{width:'100%',fontSize:13}}>
|
||||
<thead><tr><th>Time</th><th>Reason</th><th>User</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
{history.map(h => (
|
||||
<tr key={h.id}>
|
||||
<td style={{fontFamily:'monospace',fontSize:12}}>{formatDate(h.created_at)}</td>
|
||||
<td><span className="badge" style={{background: h.reason==='rollback'?'#fef9c3':'#e0f7fa',color: h.reason==='rollback'?'#854d0e':'#006064',textTransform:'capitalize'}}>{h.reason}</span></td>
|
||||
<td style={{fontFamily:'monospace',fontSize:11}}>{h.created_by.slice(0,8)}…</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!canRollback}
|
||||
title={!canRollback ? "Admin only" : ""}
|
||||
onClick={()=>setRollbackConfirm({id:h.id, filename:h.filename})}
|
||||
style={{color: canRollback ? 'var(--color-danger)' : 'var(--color-text-muted)'}}
|
||||
>
|
||||
<RotateCcw size={12}/> Rollback
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!canRollback && <div style={{padding:'8px 16px',fontSize:12,color:'var(--color-text-muted)',borderTop:'1px solid var(--color-border)'}}>Rollback requires admin role (backend enforced, not just UI hidden)</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply confirmation modal */}
|
||||
{applyConfirmOpen && preview && (
|
||||
<div className="modal-overlay" onClick={()=>setApplyConfirmOpen(false)}>
|
||||
<div className="modal" onClick={e=>e.stopPropagation()} style={{maxWidth:720}}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Confirm apply</h3>
|
||||
<button className="modal-close" onClick={()=>setApplyConfirmOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{fontSize:14}}>You are about to overwrite <code style={{background:'var(--color-bg)',padding:'2px 6px',borderRadius:4}}>{filename}</code> with the following diff:</p>
|
||||
<pre style={{marginTop:12,padding:12,background:'#fafafa',border:'1px solid var(--color-border)',borderRadius:8,fontSize:12,overflowX:'auto',maxHeight:300,whiteSpace:'pre-wrap'}}>
|
||||
{preview.diff || "(no diff — file will be overwritten with identical content)"}
|
||||
</pre>
|
||||
<div style={{marginTop:12,padding:10,background:'#fef9c3',border:'1px solid #fde68a',borderRadius:8,fontSize:12,color:'#854d0e'}}>
|
||||
This will create a backup, then atomically replace the file. Traefik will hot-reload. This action is audited.
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-ghost" onClick={()=>setApplyConfirmOpen(false)} disabled={isApplying}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={handleApply} disabled={isApplying} style={{background:'var(--color-danger)',borderColor:'var(--color-danger)'}}>
|
||||
{isApplying ? "Applying..." : "Confirm & Apply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rollback confirmation */}
|
||||
{rollbackConfirm && (
|
||||
<div className="modal-overlay" onClick={()=>setRollbackConfirm(null)}>
|
||||
<div className="modal" onClick={e=>e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title" style={{color:'var(--color-danger)'}}>Confirm rollback</h3>
|
||||
<button className="modal-close" onClick={()=>setRollbackConfirm(null)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{fontSize:14}}>Restore <code>{rollbackConfirm.filename}</code> to backup <code>{rollbackConfirm.id.slice(0,8)}…</code>?</p>
|
||||
<p style={{fontSize:12,color:'var(--color-text-muted)',marginTop:8}}>A backup of the current version will be created before rollback. This is a destructive action.</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-ghost" onClick={()=>setRollbackConfirm(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={()=>handleRollback(rollbackConfirm.id)} style={{background:'var(--color-danger)'}}>Rollback</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`@media (max-width: 900px) { div[style*="grid-template-columns:320px"] { grid-template-columns: 1fr !important; } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sampleYAML = `http:
|
||||
routers:
|
||||
my-router:
|
||||
rule: "Host(\`example.com\`)"
|
||||
service: my-service
|
||||
entryPoints: ["web"]
|
||||
services:
|
||||
my-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
middlewares:
|
||||
my-strip:
|
||||
stripPrefix:
|
||||
prefixes: ["/api"]
|
||||
`
|
||||
156
frontend/src/pages/Dashboard.tsx
Normal file
156
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { traefikApi } from '../api/client'
|
||||
import { Server, Network, Shield, AlertTriangle, CheckCircle, XCircle, AlertCircle, FileText, Database, Users } from 'lucide-react'
|
||||
|
||||
const statCards = [
|
||||
{ title: 'HTTP Routers', key: 'http.routers.total', icon: Network, color: 'var(--color-primary)', bg: 'var(--color-primary-light)' },
|
||||
{ title: 'HTTP Services', key: 'http.services.total', icon: Server, color: 'var(--color-info)', bg: '#e0f7fa' },
|
||||
{ title: 'Middlewares', key: 'http.middlewares.total', icon: Shield, color: 'var(--color-success)', bg: '#e8f5e9' },
|
||||
{ title: 'Certificates', key: 'certificates.total', icon: FileText, color: 'var(--color-warning)', bg: '#fff8e1' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: overview, isLoading, error } = useQuery({
|
||||
queryKey: ['overview'],
|
||||
queryFn: traefikApi.getOverview,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<AlertCircle size={48} style={{ color: 'var(--color-danger)', marginBottom: '16px' }} />
|
||||
<h2>Failed to load dashboard</h2>
|
||||
<p style={{ color: 'var(--color-text-muted)', marginTop: '8px' }}>{(error as Error).message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getValue = (obj: any, path: string) => {
|
||||
return path.split('.').reduce((o, k) => o?.[k], obj) ?? 0
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
{statCards.map(card => {
|
||||
const Icon = card.icon
|
||||
const value = getValue(overview, card.key)
|
||||
const warnings = getValue(overview, card.key.replace('.total', '.warnings'))
|
||||
const errors = getValue(overview, card.key.replace('.total', '.errors'))
|
||||
|
||||
return (
|
||||
<div key={card.title} className="card" style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>{card.title}</p>
|
||||
<p style={{ fontSize: 32, fontWeight: 700, color: 'var(--color-text)' }}>{value}</p>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', fontSize: 12 }}>
|
||||
{warnings > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--color-warning)' }}>
|
||||
<AlertTriangle size={12} /> {warnings} warnings
|
||||
</span>
|
||||
)}
|
||||
{errors > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--color-danger)' }}>
|
||||
<XCircle size={12} /> {errors} errors
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
background: card.bg,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: card.color,
|
||||
}}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '16px' }}>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Providers</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{overview?.providers?.length ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{overview.providers.map((provider: string) => (
|
||||
<span key={provider} className="badge badge-provider">{provider}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>No providers configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Certificate Status</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{overview?.certificates && (
|
||||
<div style={{ display: 'flex', gap: '24px' }}>
|
||||
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||
<p style={{ fontSize: 36, fontWeight: 700, color: 'var(--color-success)' }}>{overview.certificates.total - overview.certificates.warnings - overview.certificates.errors}</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>Valid</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||
<p style={{ fontSize: 36, fontWeight: 700, color: 'var(--color-warning)' }}>{overview.certificates.warnings}</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>Expiring Soon</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||
<p style={{ fontSize: 36, fontWeight: 700, color: 'var(--color-danger)' }}>{overview.certificates.errors}</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>Expired</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<div className="card-header">
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Quick Actions</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||
<a href="/routers" className="btn btn-primary">
|
||||
<Network size={18} /> Manage Routers
|
||||
</a>
|
||||
<a href="/services" className="btn btn-secondary">
|
||||
<Server size={18} /> Manage Services
|
||||
</a>
|
||||
<a href="/middlewares" className="btn btn-secondary">
|
||||
<Shield size={18} /> Manage Middlewares
|
||||
</a>
|
||||
<a href="/certificates" className="btn btn-secondary">
|
||||
<FileText size={18} /> View Certificates
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
frontend/src/pages/Login.tsx
Normal file
129
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useToast } from '../components/ui/Toast' // keep
|
||||
import { Lock, AlertCircle } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Login failed')
|
||||
// showToast error suppressed
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 400, padding: '32px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
width: 64, height: 64,
|
||||
background: 'var(--color-primary)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
margin: '0 auto 16px',
|
||||
}}>TG</div>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Traefik GUI</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', marginTop: '8px' }}>Sign in to manage your Traefik configuration</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: '#991b1b',
|
||||
marginBottom: '20px',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<AlertCircle size={18} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username" className="form-label">Username</label>
|
||||
<div className="input-group">
|
||||
<Lock size={18} style={{ color: 'var(--color-text-muted)', marginTop: '10px', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
className="form-input"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">Password</label>
|
||||
<div className="input-group">
|
||||
<Lock size={18} style={{ color: 'var(--color-text-muted)', marginTop: '10px', flexShrink: 0 }} />
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', padding: '12px' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '24px', paddingTop: '24px', borderTop: '1px solid var(--color-border)', textAlign: 'center', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
<p>Default credentials: <code>admin</code> / <code>changeme</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
frontend/src/pages/Middlewares.tsx
Normal file
179
frontend/src/pages/Middlewares.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { traefikApi, type Middleware } from '../api/client'
|
||||
import { Search, Plus, Filter, ChevronDown, Shield, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||
enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||
disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
const middlewareTypeIcons: Record<string, any> = {
|
||||
headers: Shield,
|
||||
redirectscheme: Shield,
|
||||
ratelimit: Shield,
|
||||
stripprefix: Shield,
|
||||
addprefix: Shield,
|
||||
replacepath: Shield,
|
||||
replacepathregex: Shield,
|
||||
chain: Shield,
|
||||
circuitbreaker: Shield,
|
||||
compress: Shield,
|
||||
retry: Shield,
|
||||
timeout: Shield,
|
||||
buffering: Shield,
|
||||
passTLSClientCert: Shield,
|
||||
forwardauth: Shield,
|
||||
ipwhitelist: Shield,
|
||||
inFlightReq: Shield,
|
||||
}
|
||||
|
||||
export default function Middlewares() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'type'>('name')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const { data: middlewares, isLoading, error } = useQuery({
|
||||
queryKey: ['middlewares'],
|
||||
queryFn: traefikApi.listMiddlewares,
|
||||
})
|
||||
|
||||
const filteredMiddlewares = middlewares?.filter(m => {
|
||||
if (search && !m.name.toLowerCase().includes(search.toLowerCase()) && !m.type.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (statusFilter !== 'all' && m.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||
if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir
|
||||
return a.type.localeCompare(b.type) * dir
|
||||
}) || []
|
||||
|
||||
const handleSort = (field: 'name' | 'status' | 'provider' | 'type') => {
|
||||
if (sortBy === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(field)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'type' }) => (
|
||||
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{children}
|
||||
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">HTTP Middlewares</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Manage middleware for request/response processing</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled>
|
||||
<Plus size={18} /> Add Middleware
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search middlewares..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '40px' }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ width: 'auto', minWidth: 160 }}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="warning">Warning</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||
<SortableHeader field="name">Name</SortableHeader>
|
||||
<SortableHeader field="type">Type</SortableHeader>
|
||||
<SortableHeader field="provider">Provider</SortableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMiddlewares.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||
No middlewares found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredMiddlewares.map(middleware => {
|
||||
const statusConfig = statusColors[middleware.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||
const StatusIcon = statusConfig.icon
|
||||
const TypeIcon = middlewareTypeIcons[middleware.type] || Shield
|
||||
return (
|
||||
<tr key={middleware.name}>
|
||||
<td>
|
||||
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||
<StatusIcon size={10} /> {middleware.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{middleware.name}</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<TypeIcon size={14} style={{ color: 'var(--color-text-muted)' }} />
|
||||
<span className="badge badge-provider">{middleware.type}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="badge badge-provider">{middleware.provider}</span></td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredMiddlewares.length > 0 && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||
<span>Showing {filteredMiddlewares.length} of {middlewares?.length} middlewares</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
frontend/src/pages/Routers.tsx
Normal file
172
frontend/src/pages/Routers.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { traefikApi, configApi, type Router } from '../api/client'
|
||||
import { Search, Plus, Filter, ChevronDown, ExternalLink, Shield, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||
enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||
disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
export default function Routers() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider'>('name')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const { data: routers, isLoading, error } = useQuery({
|
||||
queryKey: ['routers'],
|
||||
queryFn: traefikApi.listRouters,
|
||||
})
|
||||
|
||||
const filteredRouters = routers?.filter(r => {
|
||||
if (search && !r.name.toLowerCase().includes(search.toLowerCase()) && !r.rule.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||
return a.provider.localeCompare(b.provider) * dir
|
||||
}) || []
|
||||
|
||||
const handleSort = (field: 'name' | 'status' | 'provider') => {
|
||||
if (sortBy === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(field)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' }) => (
|
||||
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{children}
|
||||
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">HTTP Routers</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Manage routing rules for your HTTP services</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled>
|
||||
<Plus size={18} /> Add Router
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search routers..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '40px' }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ width: 'auto', minWidth: 160 }}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="warning">Warning</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||
<SortableHeader field="name">TLS</SortableHeader>
|
||||
<SortableHeader field="name">Rule</SortableHeader>
|
||||
<th>Entry Points</th>
|
||||
<SortableHeader field="name">Name</SortableHeader>
|
||||
<th>Service</th>
|
||||
<SortableHeader field="provider">Provider</SortableHeader>
|
||||
<th>Priority</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRouters.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||
No routers found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRouters.map(router => {
|
||||
const statusConfig = statusColors[router.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||
const StatusIcon = statusConfig.icon
|
||||
return (
|
||||
<tr key={router.name}>
|
||||
<td>
|
||||
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||
<StatusIcon size={10} /> {router.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ width: 40, textAlign: 'center' }}>
|
||||
{router.tls && (
|
||||
<Shield size={16} style={{ color: 'var(--color-primary)' }} title="TLS enabled" />
|
||||
)}
|
||||
</td>
|
||||
<td style={{ maxWidth: 300 }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{router.rule}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{router.entry_points.map(ep => (
|
||||
<span key={ep} className="badge badge-provider" style={{ marginRight: 4 }}>{ep}</span>
|
||||
))}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{router.name}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)' }}>{router.service}</td>
|
||||
<td><span className="badge badge-provider">{router.provider}</span></td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{router.priority}</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredRouters.length > 0 && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||
<span>Showing {filteredRouters.length} of {routers?.length} routers</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
frontend/src/pages/Services.tsx
Normal file
175
frontend/src/pages/Services.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { traefikApi, type Service } from '../api/client'
|
||||
import { Search, Plus, Filter, ChevronDown, Server, Database, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||
enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||
disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
export default function Services() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'type'>('name')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const { data: services, isLoading, error } = useQuery({
|
||||
queryKey: ['services'],
|
||||
queryFn: traefikApi.listServices,
|
||||
})
|
||||
|
||||
const filteredServices = services?.filter(s => {
|
||||
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (statusFilter !== 'all' && s.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||
if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir
|
||||
return a.type.localeCompare(b.type) * dir
|
||||
}) || []
|
||||
|
||||
const handleSort = (field: 'name' | 'status' | 'provider' | 'type') => {
|
||||
if (sortBy === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(field)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'type' }) => (
|
||||
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{children}
|
||||
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
|
||||
const getServerCount = (service: Service) => {
|
||||
return service.load_balancer?.servers?.length || 0
|
||||
}
|
||||
|
||||
const getUpServers = (service: Service) => {
|
||||
const statuses = service.server_status || {}
|
||||
return Object.values(statuses).filter(s => s === 'UP').length
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">HTTP Services</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Manage load balancers and backend services</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled>
|
||||
<Plus size={18} /> Add Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '40px' }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ width: 'auto', minWidth: 160 }}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="warning">Warning</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||
<SortableHeader field="name">Name</SortableHeader>
|
||||
<SortableHeader field="type">Type</SortableHeader>
|
||||
<th>Servers</th>
|
||||
<SortableHeader field="provider">Provider</SortableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredServices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||
No services found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredServices.map(service => {
|
||||
const statusConfig = statusColors[service.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||
const StatusIcon = statusConfig.icon
|
||||
const serverCount = getServerCount(service)
|
||||
const upServers = getUpServers(service)
|
||||
return (
|
||||
<tr key={service.name}>
|
||||
<td>
|
||||
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||
<StatusIcon size={10} /> {service.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{service.name}</td>
|
||||
<td><span className="badge badge-provider">{service.type}</span></td>
|
||||
<td>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{upServers}/{serverCount}
|
||||
{serverCount > 0 && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: upServers === serverCount ? 'var(--color-success)' : upServers > 0 ? 'var(--color-warning)' : 'var(--color-danger)' }}>
|
||||
{upServers === serverCount ? 'All UP' : upServers > 0 ? 'Partial' : 'All DOWN'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="badge badge-provider">{service.provider}</span></td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredServices.length > 0 && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||
<span>Showing {filteredServices.length} of {services?.length} services</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
frontend/src/pages/Settings.tsx
Normal file
213
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { useState } from 'react'
|
||||
import { Shield, Database, Server, Bell, User, Key, Save, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '../components/ui/Toast'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General', icon: Server },
|
||||
{ id: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ id: 'traefik', label: 'Traefik', icon: Database },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const [activeTab, setActiveTab] = useState('general')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
setSaving(false)
|
||||
showToast('success', 'Settings saved successfully')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 800 }}>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', overflowX: 'auto' }}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '14px 20px',
|
||||
background: activeTab === tab.id ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
borderBottom: activeTab === tab.id ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
fontSize: 14,
|
||||
borderRadius: 0,
|
||||
transition: 'all var(--transition)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Icon size={18} /> {tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card-body" style={{ padding: '24px' }}>
|
||||
{activeTab === 'general' && (
|
||||
<div>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>General Settings</h3>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Application Name</label>
|
||||
<input type="text" className="form-input" defaultValue="Traefik GUI" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Theme</label>
|
||||
<select className="form-input" style={{ width: 'auto', maxWidth: 300 }}>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Default Page Size</label>
|
||||
<input type="number" className="form-input" defaultValue="25" style={{ width: 'auto', maxWidth: 100 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>Enable auto-refresh on dashboard</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" />
|
||||
<span>Show advanced options by default</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'auth' && (
|
||||
<div>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>Authentication</h3>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Session Timeout</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<input type="number" className="form-input" defaultValue="24" style={{ width: 'auto', maxWidth: 100 }} />
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>hours</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>Require CSRF protection</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>Secure cookies (HTTPS only)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" />
|
||||
<span>Enable OIDC/OAuth2 integration</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">OIDC Issuer URL</label>
|
||||
<input type="url" className="form-input" placeholder="https://accounts.example.com" />
|
||||
<p className="form-help">Leave empty to disable OIDC</p>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">OIDC Client ID</label>
|
||||
<input type="text" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'traefik' && (
|
||||
<div>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>Traefik Connection</h3>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Traefik API URL</label>
|
||||
<input type="url" className="form-input" defaultValue="http://localhost:8080/api" />
|
||||
<p className="form-help">Base URL for Traefik's REST API</p>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">File Provider Config Directory</label>
|
||||
<input type="text" className="form-input" defaultValue="/etc/traefik/dynamic" />
|
||||
<p className="form-help">Directory watched by Traefik's file provider for dynamic configuration</p>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>Auto-reload on config changes</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" />
|
||||
<span>Validate config before applying</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Config Backup Retention</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<input type="number" className="form-input" defaultValue="30" style={{ width: 'auto', maxWidth: 100 }} />
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<div>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>Notifications</h3>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>Enable email notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">SMTP Host</label>
|
||||
<input type="text" className="form-input" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">SMTP Port</label>
|
||||
<input type="number" className="form-input" defaultValue="587" style={{ width: 'auto', maxWidth: 100 }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">From Address</label>
|
||||
<input type="email" className="form-input" placeholder="traefik@example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Notification Events</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||
{['Certificate Expiring', 'Certificate Expired', 'Config Applied', 'Config Validation Failed', 'Service Down'].map(event => (
|
||||
<label key={event} style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>{event}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px', paddingTop: '24px', borderTop: '1px solid var(--color-border)' }}>
|
||||
<button className="btn btn-secondary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <> <Save size={18} /> Save Changes</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
30
frontend/vite.config.ts
Normal file
30
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue