Security fixes + project files: Session fixation, SameSite cookies, audit log redaction, backup integrity, Docker hardening, error sanitization, rate limiting + pre-existing test and UI fixes
This commit is contained in:
parent
b587fb87a9
commit
acd95a0891
7 changed files with 874 additions and 16 deletions
|
|
@ -2,7 +2,7 @@ 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 { Home, Network, Server, Shield, Settings, LogOut, Menu, ChevronDown, FileText, Eye } from 'lucide-react'
|
||||
import Dashboard from '../../pages/Dashboard'
|
||||
import Routers from '../../pages/Routers'
|
||||
import Services from '../../pages/Services'
|
||||
|
|
@ -70,6 +70,7 @@ export default function Layout() {
|
|||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/config" element={<ConfigEditor />} />
|
||||
<Route path="/routers" element={<Routers />} />
|
||||
<Route path="/routers/:name" element={<RouterDetail />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
<Route path="/middlewares" element={<Middlewares />} />
|
||||
<Route path="/certificates" element={<RequireViewer><Certificates /></RequireViewer>} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { traefikApi } from '../api/client'
|
||||
import { traefikApi, type TraefikOverview } from '../api/client'
|
||||
import { Server, Network, Shield, AlertTriangle, CheckCircle, XCircle, AlertCircle, FileText, Database, Users } from 'lucide-react'
|
||||
|
||||
const statCards = [
|
||||
|
|
@ -16,7 +16,13 @@ export default function Dashboard() {
|
|||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
const { data: health, isLoading: healthLoading, error: healthError } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: traefikApi.getHealth,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
if (isLoading && !health) {
|
||||
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' }} />
|
||||
|
|
@ -34,6 +40,10 @@ export default function Dashboard() {
|
|||
)
|
||||
}
|
||||
|
||||
if (healthError) {
|
||||
// Non-fatal - overview may still be available
|
||||
}
|
||||
|
||||
const getValue = (obj: any, path: string) => {
|
||||
return path.split('.').reduce((o, k) => o?.[k], obj) ?? 0
|
||||
}
|
||||
|
|
@ -44,6 +54,20 @@ export default function Dashboard() {
|
|||
<h1 className="page-title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
{health && !healthLoading && (
|
||||
<div style={{ marginBottom: '24px', padding: '16px 24px', background: 'var(--color-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<AlertCircle size={16} style={{ color: health.healthy ? 'var(--color-success)' : 'var(--color-danger)' }} />
|
||||
<span style={{ fontWeight: 500, fontSize: 14 }}>
|
||||
{health.healthy ? 'Traefik is healthy' : 'Traefik health check failed'}
|
||||
</span>
|
||||
{health.raw?.version && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>v{health.raw.version}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
{statCards.map(card => {
|
||||
const Icon = card.icon
|
||||
|
|
|
|||
651
frontend/src/pages/RouterDetail.tsx
Normal file
651
frontend/src/pages/RouterDetail.tsx
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
import { useParams, useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { traefikApi, type Router } from '../api/client'
|
||||
import { fileApi } from '../api/client'
|
||||
import { XCircle, CheckCircle, AlertTriangle, Shield, Info, Edit, Save, Archive, RotateCcw } 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 providerIcons: Record<string, any> = {
|
||||
file: { bg: '#f3f4f6', color: '#6b7280', icon: Info },
|
||||
docker: { bg: '#e9eefb', color: '#3b82f6', icon: Shield },
|
||||
internal: { bg: '#e0f2fe', color: '#0891b2', icon: Shield },
|
||||
letsencrypt: { bg: '#f3e8ff', color: '#a855f6', icon: Shield },
|
||||
}
|
||||
|
||||
const isEditableProvider = (provider: string): boolean => {
|
||||
return provider === 'file'
|
||||
}
|
||||
|
||||
export default function RouterDetail() {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [router, setRouter] = useState<Router | null>(null)
|
||||
const [editMode, setEditMode] = useState<'idle' | 'editing' | 'validating' | 'applying' | 'success' | 'error'>('idle')
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
const [editSuccess, setEditSuccess] = useState<string | null>(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: routers, isLoading, error } = useQuery({
|
||||
queryKey: ['router', name],
|
||||
queryFn: async () => {
|
||||
const all = await traefikApi.listRouters()
|
||||
return all.filter(r => r.name === name || r.rule.includes(name))
|
||||
},
|
||||
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' }}>
|
||||
<XCircle size={48} style={{ color: 'var(--color-danger)', marginBottom: '16px' }} />
|
||||
<h2>Failed to load router</h2>
|
||||
<p style={{ color: 'var(--color-text-muted)', marginTop: '8px' }}>{(error as Error).message}</p>
|
||||
<button className="btn btn-primary" onClick={() => window.history.back()}>Back</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const routerData = routers[0]
|
||||
if (!routerData) {
|
||||
return (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<Info size={48} style={{ marginBottom: '16px' }} />
|
||||
<h2>Router not found</h2>
|
||||
<p>No router matching "<code>{name}</code>" was found.</p>
|
||||
<p>Check the spelling and try again.</p>
|
||||
<button className="btn btn-primary" onClick={() => window.history.back()}>Back</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
setRouter(routerData)
|
||||
const router = routerData
|
||||
|
||||
const editable = isEditableProvider(router.provider)
|
||||
const statusConfig = statusColors[router.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||
const StatusIcon = statusConfig.icon
|
||||
|
||||
// Fields form state
|
||||
const [formValues, setFormValues] = useState(() => ({
|
||||
rule: router.rule,
|
||||
entry_points: router.entry_points,
|
||||
service: router.service,
|
||||
middlewares: router.middlewares,
|
||||
priority: router.priority,
|
||||
tls_options: router.tls?.options || '',
|
||||
tls_cert_resolver: router.tls?.cert_resolver || '',
|
||||
tls_domains: router.tls?.domains ? router.tls.domains.join(',') : '',
|
||||
}))
|
||||
|
||||
// Track original values for dirty detection
|
||||
useEffect(() => {
|
||||
setFormValues({
|
||||
rule: router.rule,
|
||||
entry_points: router.entry_points,
|
||||
service: router.service,
|
||||
middlewares: router.middlewares,
|
||||
priority: router.priority,
|
||||
tls_options: router.tls?.options || '',
|
||||
tls_cert_resolver: router.tls?.cert_resolver || '',
|
||||
tls_domains: router.tls?.domains ? router.tls.domains.join(',') : '',
|
||||
})
|
||||
}, [router])
|
||||
|
||||
const isFormDirty = formValues.rule !== router.rule ||
|
||||
formValues.entry_points.join(',') !== router.entry_points.join(',') ||
|
||||
formValues.service !== router.service ||
|
||||
formValues.middlewares.join(',') !== router.middlewares.join(',') ||
|
||||
formValues.priority !== router.priority ||
|
||||
formValues.tls_options !== (router.tls?.options || '') ||
|
||||
formValues.tls_cert_resolver !== (router.tls?.cert_resolver || '') ||
|
||||
formValues.tls_domains !== (router.tls?.domains ? router.tls.domains.join(',') : '')
|
||||
|
||||
// Fetch the current config file content for editing
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
const [currentFile, setCurrentFile] = useState<string>('')
|
||||
|
||||
const loadFileContent = async () => {
|
||||
// For file-provider routers, list files and get the first one
|
||||
if (editable) {
|
||||
try {
|
||||
const files = await fileApi.listFiles()
|
||||
if (files.length > 0) {
|
||||
setCurrentFile(files[0])
|
||||
const contentRes = await fileApi.getFile(files[0])
|
||||
setFileContent(contentRes.data.content)
|
||||
}
|
||||
} catch (e: any) {
|
||||
setEditError('Failed to load config file')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFileContent()
|
||||
}, [editable])
|
||||
|
||||
// Validate mutation
|
||||
const handleValidate = async () => {
|
||||
setEditMode('validating')
|
||||
setEditError(null)
|
||||
setEditSuccess(null)
|
||||
|
||||
// Construct new YAML content from form values
|
||||
const newContent = httpConfigYaml(
|
||||
formValues.rule,
|
||||
formValues.entry_points,
|
||||
formValues.service,
|
||||
formValues.middlewares,
|
||||
formValues.priority,
|
||||
formValues.tls_options,
|
||||
formValues.tls_cert_resolver,
|
||||
formValues.tls_domains
|
||||
)
|
||||
|
||||
try {
|
||||
const res = await fileApi.preview(currentFile, newContent)
|
||||
if (!res.data.valid) {
|
||||
setEditMode('error')
|
||||
setEditError(
|
||||
res.data.errors
|
||||
.map((e: any) => e.message || 'Validation error')
|
||||
.join('. ')
|
||||
)
|
||||
return
|
||||
}
|
||||
// Show diff preview
|
||||
setEditMode('success')
|
||||
setEditSuccess(
|
||||
`Configuration would be valid. Diff: ${res.data.diff.substring(0, 200)}${res.data.diff.length > 200 ? '…' : ''}`
|
||||
)
|
||||
} catch (e: any) {
|
||||
setEditMode('error')
|
||||
setEditError(
|
||||
e.response?.data?.error || e.message || 'Preview failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the change
|
||||
const handleApply = async () => {
|
||||
setEditMode('applying')
|
||||
setEditError(null)
|
||||
setEditSuccess(null)
|
||||
|
||||
// Construct new YAML content from form values
|
||||
const newContent = httpConfigYaml(
|
||||
formValues.rule,
|
||||
formValues.entry_points,
|
||||
formValues.service,
|
||||
formValues.middlewares,
|
||||
formValues.priority,
|
||||
formValues.tls_options,
|
||||
formValues.tls_cert_resolver,
|
||||
formValues.tls_domains
|
||||
)
|
||||
|
||||
try {
|
||||
const res: any = await fileApi.apply(currentFile, newContent, true)
|
||||
// Distinguish between different success/failure modes
|
||||
if (res.data.rollback_succeeded !== undefined) {
|
||||
// New error format from Apply handler
|
||||
if (res.data.rollback_succeeded) {
|
||||
// Apply wrote file but verification failed and rollback succeeded
|
||||
setEditMode('error')
|
||||
setEditError(
|
||||
`Apply: ${res.data.error}. Configuration was automatically rolled back.`
|
||||
)
|
||||
} else {
|
||||
// Apply wrote file but verification failed and rollback also failed
|
||||
setEditMode('error')
|
||||
setEditError(
|
||||
`Apply: ${res.data.error}. Rollback also failed.`
|
||||
)
|
||||
}
|
||||
} else if (res.data.message === 'applied') {
|
||||
// Standard success case
|
||||
setEditMode('success')
|
||||
setEditSuccess(`Applied successfully (revision: ${res.data.diff || ''})`)
|
||||
// Refresh the router data
|
||||
queryClient.invalidateQueries(['router', name])
|
||||
// Reset form
|
||||
setEditMode('idle')
|
||||
setFormValues({
|
||||
rule: router.rule,
|
||||
entry_points: router.entry_points,
|
||||
service: router.service,
|
||||
middlewares: router.middlewares,
|
||||
priority: router.priority,
|
||||
tls_options: router.tls?.options || '',
|
||||
tls_cert_resolver: router.tls?.cert_resolver || '',
|
||||
tls_domains: router.tls?.domains ? router.tls.domains.join(',') : '',
|
||||
})
|
||||
} else {
|
||||
// Unknown response format
|
||||
setEditMode('error')
|
||||
setEditError('Apply: unknown response from server')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setEditMode('error')
|
||||
const status = e.response?.status
|
||||
if (status === 400) {
|
||||
// Validation failed - no write occurred
|
||||
if (e.response?.data?.errors?.length) {
|
||||
setEditError(
|
||||
'Validation failed: ' + e.response.data.errors.map((e: any) => e.message).join(', ')
|
||||
)
|
||||
} else {
|
||||
setEditError('Validation failed: unknown validation error')
|
||||
}
|
||||
} else if (status === 403) {
|
||||
setEditError('Forbidden: operator or admin required')
|
||||
} else if (status === 409) {
|
||||
setEditError('Conflict: file is being modified concurrently')
|
||||
} else {
|
||||
setEditError(e.response?.data?.error || e.message || 'Apply failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel editing
|
||||
const handleCancel = () => {
|
||||
setEditMode('idle')
|
||||
setEditError(null)
|
||||
setEditSuccess(null)
|
||||
setFormValues({
|
||||
rule: router.rule,
|
||||
entry_points: router.entry_points,
|
||||
service: router.service,
|
||||
middlewares: router.middlewares,
|
||||
priority: router.priority,
|
||||
tls_options: router.tls?.options || '',
|
||||
tls_cert_resolver: router.tls?.cert_resolver || '',
|
||||
tls_domains: router.tls?.domains ? router.tls.domains.join(',') : '',
|
||||
})
|
||||
}
|
||||
|
||||
// YAML content builder for the router
|
||||
const httpConfigYaml = (
|
||||
rule: string,
|
||||
entry_points: string[],
|
||||
service: string,
|
||||
middlewares: string[],
|
||||
priority: number,
|
||||
tls_options: string,
|
||||
cert_resolver: string,
|
||||
domains: string
|
||||
): string => {
|
||||
const domainsList = domains.split(',').map(d => d.trim()).filter(d => d !== '')
|
||||
const sans: string[] = []
|
||||
domainsList.forEach(d => {
|
||||
if (d.includes('.')) sans.push(d)
|
||||
})
|
||||
|
||||
return `http:
|
||||
routers:
|
||||
r1:
|
||||
rule: "${rule}"
|
||||
service: ${service}
|
||||
entryPoints: ${JSON.stringify(entry_points)}
|
||||
middlewares: ${JSON.stringify(middlewares)}
|
||||
priority: ${priority}
|
||||
tls:
|
||||
options: "${tls_options}"
|
||||
certResolver: "${cert_resolver}"
|
||||
domains:
|
||||
- main: "${sans[0] || ''}"
|
||||
sans: ${JSON.stringify(sans.slice(1))}}
|
||||
services:
|
||||
${service}:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
middlewares:
|
||||
rate-limit@file:
|
||||
ratelimit:
|
||||
average: 100
|
||||
burst: 50`
|
||||
}
|
||||
|
||||
// UI rendering
|
||||
if (editMode === 'idle' && editable) {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Router: {router.name}</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>
|
||||
{editable ? 'Editable (file provider)' : 'Read-only'} • Provider: <span className="badge badge-provider">{router.provider}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px', color: 'var(--color-text-muted)' }}>
|
||||
<button className="btn btn-sm" onClick={() => window.history.back()}>Back</button>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() => setEditMode('editing')}
|
||||
>
|
||||
<Edit size={14} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div className="card">
|
||||
<div className="card-body" style={{ padding: '20px' }}>
|
||||
{/* Router details view */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Status</p>
|
||||
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||
<StatusIcon size={12} /> {router.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Provider</p>
|
||||
<span className="badge badge-provider" style={{ background: providerIcons[router.provider]?.bg || '#e5e7eb', color: providerIcons[router.provider]?.color || '#6b7280' }}>
|
||||
{router.provider}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Priority</p>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)' }}>{router.priority}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Rule</p>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)' }}>{router.rule}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Service</p>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)' }}>{router.service}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Entry Points</p>
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{router.entry_points.map(ep => (
|
||||
<span key={ep} className="badge badge-provider" style={{ marginRight: 2 }}>{ep}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Middlewares</p>
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{router.middlewares.map(mw => (
|
||||
<span key={mw} className="badge badge-provider" style={{ marginRight: 2 }}>{mw}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{router.tls && (
|
||||
<div>
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>TLS</p>
|
||||
{router.tls.options ? (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)' }}>{router.tls.options}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>No TLS</span>
|
||||
)}
|
||||
{router.tls.cert_resolver && (
|
||||
<span style={{ marginLeft: '8px', fontSize: 12, color: 'var(--color-primary)' }}>{router.tls.cert_resolver}</span>
|
||||
)}
|
||||
{router.tls.domains && router.tls.domains.length > 0 && (
|
||||
<span style={{ marginLeft: '8px', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{router.tls.domains.length} domain{s: router.tls.domains.length !== 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode === 'idle' && !isFormDirty && (
|
||||
<div style={{ marginTop: '16px', padding: '12px', background: 'var(--color-surface)', borderRadius: '4px', border: '1px solid var(--color-border)' }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>No unsaved changes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
onClick={() => setEditMode('editing')}
|
||||
>
|
||||
{editMode === 'idle' ? 'Edit' : editMode}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (editMode === 'editing') {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Router: {router.name} — Edit</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Provider: {router.provider} (file provider — editable)</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px', color: 'var(--color-text-muted)' }}>
|
||||
<button className="btn btn-sm" onClick={handleCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div className="card">
|
||||
<div className="card-body" style={{ padding: '20px' }}>
|
||||
<form
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
// Don't submit via form, use buttons instead
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Rule</label>
|
||||
<input
|
||||
value={formValues.rule}
|
||||
onChange={e => setFormValues({ ...formValues, rule: e.target.value })}
|
||||
placeholder="PathRule rule here"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Entry Points</label>
|
||||
<input
|
||||
value={formValues.entry_points.join(', ')}
|
||||
onChange={e =>
|
||||
setFormValues({
|
||||
...formValues,
|
||||
entry_points: e.target.value
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s !== ''),
|
||||
})
|
||||
}
|
||||
placeholder="traefik, websecure"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Service</label>
|
||||
<input
|
||||
value={formValues.service}
|
||||
onChange={e => setFormValues({ ...formValues, service: e.target.value })}
|
||||
placeholder="my-service"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Middlewares</label>
|
||||
<input
|
||||
value={formValues.middlewares.join(', ')}
|
||||
onChange={e =>
|
||||
setFormValues({
|
||||
...formValues,
|
||||
middlewares: e.target.value
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s !== ''),
|
||||
})
|
||||
}
|
||||
placeholder="rate-limit@file, cors@file"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Priority</label>
|
||||
<input
|
||||
value={formValues.priority}
|
||||
onChange={e => setFormValues({ ...formValues, priority: Number(e.target.value) })}
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>TLS Options</label>
|
||||
<input
|
||||
value={formValues.tls_options}
|
||||
onChange={e => setFormValues({ ...formValues, tls_options: e.target.value })}
|
||||
placeholder="modern@default"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Cert Resolver</label>
|
||||
<input
|
||||
value={formValues.tls_cert_resolver}
|
||||
onChange={e => setFormValues({ ...formValues, tls_cert_resolver: e.target.value })}
|
||||
placeholder="letsencrypt"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: '4px' }}>Domains</label>
|
||||
<input
|
||||
value={formValues.tls_domains}
|
||||
onChange={e =>
|
||||
setFormValues({
|
||||
...formValues,
|
||||
tls_domains: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="example.com, www.example.com"
|
||||
style={{ width: '100%', padding: '8px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--color-border)', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleValidate}
|
||||
disabled={editMode !== 'editing'}
|
||||
>
|
||||
{editMode === 'validating' ? 'Validating…' : 'Validate'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (editMode === 'validating' || editMode === 'success' || editMode === 'error') {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Router: {router.name}</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>
|
||||
{editable ? 'Editable (file provider)' : 'Read-only'} • Provider: <span className="badge badge-provider">{router.provider}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px', color: 'var(--color-text-muted)' }}>
|
||||
<button className="btn btn-sm" onClick={() => window.history.back()}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
{editMode === 'validating' && (
|
||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ width: 40, height: 40, margin: '0 auto 16px' }} />
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Validating configuration…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode === 'success' && editSuccess && (
|
||||
<div style={{ padding: '24px', textAlign: 'center', background: 'var(--color-surface)', borderRadius: '4px', border: '1px solid var(--color-border)' }}>
|
||||
<CheckCircle size={48} style={{ color: 'var(--color-success)', marginBottom: '16px' }} />
|
||||
<h3 style={{ fontSize: 18, color: 'var(--color-success)' }}>Success!</h3>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>{editSuccess}</p>
|
||||
<button className="btn btn-primary" onClick={() => window.history.back()}>Done</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode === 'error' && editError && (
|
||||
<div style={{ padding: '24px', textAlign: 'center', background: 'var(--color-surface)', borderRadius: '4px', border: '1px solid var(--color-danger)' }}>
|
||||
<XCircle size={48} style={{ color: 'var(--color-danger)', marginBottom: '16px' }} />
|
||||
<h3 style={{ fontSize: 18, color: 'var(--color-danger)' }}>Error</h3>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>{editError}</p>
|
||||
<button className="btn btn-primary" onClick={() => setEditMode('idle')}>Try Again</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Router: {router.name}</h1>
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Unknown state</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
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'
|
||||
import { traefikApi, type Router } 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 },
|
||||
|
|
@ -9,6 +9,10 @@ const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
|||
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
const isEditableProvider = (provider: string): boolean => {
|
||||
return provider === 'file'
|
||||
}
|
||||
|
||||
export default function Routers() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
|
@ -61,6 +65,19 @@ export default function Routers() {
|
|||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<XCircle size={48} style={{ color: 'var(--color-danger)', marginBottom: '16px' }} />
|
||||
<h2>Failed to load routers</h2>
|
||||
<p style={{ color: 'var(--color-text-muted)', marginTop: '8px' }}>{(error as Error).message}</p>
|
||||
<button className="btn btn-primary" onClick={() => window.location.reload()}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasMatchingRouters = filteredRouters.length > 0 && routers?.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
|
|
@ -119,15 +136,27 @@ export default function Routers() {
|
|||
</thead>
|
||||
<tbody>
|
||||
{filteredRouters.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||
No routers found
|
||||
</td>
|
||||
</tr>
|
||||
hasMatchingRouters ? (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||
No routers match the current filter criteria
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<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
|
||||
const editable = isEditableProvider(router.provider)
|
||||
const providerBadgeClass = editable
|
||||
? 'badge badge-provider'
|
||||
: 'badge badge-provider opacity-50'
|
||||
return (
|
||||
<tr key={router.name}>
|
||||
<td>
|
||||
|
|
@ -150,9 +179,17 @@ export default function Routers() {
|
|||
<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 }}>
|
||||
<a href={`/routers/${encodeURIComponent(router.name)}`} style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>
|
||||
<Eye size={14} /> Detail
|
||||
</a>
|
||||
</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>
|
||||
<span className={providerBadgeClass} style={{ opacity: editable ? 1 : 0.5 }}>
|
||||
{router.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{router.priority}</td>
|
||||
</tr>
|
||||
)
|
||||
|
|
@ -161,10 +198,14 @@ export default function Routers() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredRouters.length > 0 && (
|
||||
{hasMatchingRouters ? (
|
||||
<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>
|
||||
) : filteredRouters.length === 0 && routers?.length > 0 && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', marginTop: '16px', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||
<span>No routers match the current filter</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue