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
Some checks failed
CI / frontend (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / backend (push) Has been cancelled

This commit is contained in:
backup 2026-09-04 00:02:26 -05:00
commit acd95a0891
7 changed files with 874 additions and 16 deletions

View file

@ -79,7 +79,8 @@ func newTestRouter(t *testing.T, db *sql.DB) (*gin.Engine, *file.Service) {
}
t.Cleanup(func() { os.RemoveAll(dir) })
// also ensure backups dir is created by service; we use filepath join
svc, err := file.NewService(filepath.Join(dir, "dynamic"), db)
auditRepo := repositories.NewAuditRepository(db)
svc, err := file.NewService(filepath.Join(dir, "dynamic"), db, auditRepo)
if err != nil {
t.Fatalf("service: %v", err)
}
@ -87,7 +88,7 @@ func newTestRouter(t *testing.T, db *sql.DB) (*gin.Engine, *file.Service) {
sessionRepo := repositories.NewSessionRepository(db)
authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo)
authHandler := NewAuthHandler(userRepo, sessionRepo, "test-secret-32-chars-minimum-length", "", false)
fileHandler := NewFileConfigHandler(svc)
fileHandler := NewFileConfigHandler(svc, auditRepo, userRepo)
r := gin.New()
r.Use(middleware.CORSMiddleware("http://localhost:5173"))

View file

@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
)
func newTestService(t *testing.T) (*Service, string, func()) {
@ -32,7 +33,8 @@ func newTestService(t *testing.T) (*Service, string, func()) {
if err != nil {
t.Fatalf("create backups: %v", err)
}
svc, err := NewService(filepath.Join(dir, "dynamic"), db)
auditRepo := repositories.NewAuditRepository(db)
svc, err := NewService(filepath.Join(dir, "dynamic"), db, auditRepo)
if err != nil {
t.Fatalf("new service: %v", err)
}

View file

@ -0,0 +1,138 @@
package repositories
import (
"database/sql"
"time"
)
type AuditLogEntry struct {
ID string
UserID string
Username string
Role string
Action string
ResourceType string
ResourceName string
Provider string
SourceFile string
ContentHash string
Timestamp time.Time
Result string
ErrorCategory string
RollbackOccurred bool
ErrorMessage string
}
type AuditRepository struct {
db *sql.DB
}
func NewAuditRepository(db *sql.DB) *AuditRepository {
return &AuditRepository{db: db}
}
func (r *AuditRepository) Create(entry *AuditLogEntry) error {
_, err := r.db.Exec(
`INSERT INTO audit_log (id, user_id, username, role, action, resource_type, resource_name, provider, source_file, content_hash, timestamp, result, error_category, rollback_occurred, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
entry.ID, entry.UserID, entry.Username, entry.Role, entry.Action, entry.ResourceType, entry.ResourceName,
entry.Provider, entry.SourceFile, entry.ContentHash, entry.Timestamp, entry.Result, entry.ErrorCategory,
entry.RollbackOccurred, entry.ErrorMessage,
)
return err
}
func (r *AuditRepository) List(opts ListAuditOptions) ([]AuditLogEntry, int, error) {
query := `SELECT id, user_id, username, role, action, resource_type, resource_name, provider, source_file, content_hash, timestamp, result, error_category, rollback_occurred, error_message FROM audit_log WHERE 1=1 `
var args []interface{}
argIdx := 1
if opts.Username != "" {
// Look up user ID by username
var userID string
err := r.db.QueryRow(`SELECT id FROM users WHERE username = ?`, opts.Username).Scan(&userID)
if err == nil && userID != "" {
opts.UserID = userID
}
// If user not found, we just won't filter by user_id (entries without a matching user_id will be excluded)
}
if opts.UserID != "" {
query += " AND user_id=? "
args = append(args, opts.UserID)
argIdx++
}
if opts.Action != "" {
query += " AND action=? "
args = append(args, opts.Action)
argIdx++
}
if opts.Result != "" {
query += " AND result=? "
args = append(args, opts.Result)
argIdx++
}
if opts.ResourceType != "" {
query += " AND resource_type=? "
args = append(args, opts.ResourceType)
argIdx++
}
if opts.StartDate != "" {
query += " AND timestamp>=? "
args = append(args, opts.StartDate)
argIdx++
}
if opts.EndDate != "" {
query += " AND timestamp<=? "
args = append(args, opts.EndDate)
argIdx++
}
countQuery := query + " LIMIT 1 "
var count int
err := r.db.QueryRow(countQuery, args...).Scan(&count)
if err != nil {
return nil, 0, err
}
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
args = append(args, opts.Limit, opts.Offset)
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var entries []AuditLogEntry
for rows.Next() {
var e AuditLogEntry
var rollbackOccurred int
err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Role, &e.Action, &e.ResourceType, &e.ResourceName,
&e.Provider, &e.SourceFile, &e.ContentHash, &e.Timestamp, &e.Result, &e.ErrorCategory, &rollbackOccurred, &e.ErrorMessage)
if err != nil {
return nil, 0, err
}
e.RollbackOccurred = rollbackOccurred != 0
entries = append(entries, e)
}
return entries, count, nil
}
type ListAuditOptions struct {
UserID string
Username string
Action string
Result string
ResourceType string
StartDate string
EndDate string
Limit int
Offset int
}
func defaultListOptions() ListAuditOptions {
return ListAuditOptions{
Limit: 50,
Offset: 0,
}
}

View file

@ -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>} />

View file

@ -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

View 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>
)
}

View file

@ -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>