From acd95a0891819d174d0863de6a1d9c9717eefcc1 Mon Sep 17 00:00:00 2001 From: backup Date: Fri, 4 Sep 2026 00:02:26 -0500 Subject: [PATCH] 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 --- .../api/handlers/handlers_rbac_test.go | 5 +- backend/internal/config/file/service_test.go | 4 +- .../internal/database/repositories/audit.go | 138 ++++ frontend/src/components/layout/Layout.tsx | 3 +- frontend/src/pages/Dashboard.tsx | 28 +- frontend/src/pages/RouterDetail.tsx | 651 ++++++++++++++++++ frontend/src/pages/Routers.tsx | 61 +- 7 files changed, 874 insertions(+), 16 deletions(-) create mode 100644 backend/internal/database/repositories/audit.go create mode 100644 frontend/src/pages/RouterDetail.tsx diff --git a/backend/internal/api/handlers/handlers_rbac_test.go b/backend/internal/api/handlers/handlers_rbac_test.go index 97dd277..ea40b6d 100644 --- a/backend/internal/api/handlers/handlers_rbac_test.go +++ b/backend/internal/api/handlers/handlers_rbac_test.go @@ -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")) diff --git a/backend/internal/config/file/service_test.go b/backend/internal/config/file/service_test.go index c0a25c0..821ea48 100644 --- a/backend/internal/config/file/service_test.go +++ b/backend/internal/config/file/service_test.go @@ -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) } diff --git a/backend/internal/database/repositories/audit.go b/backend/internal/database/repositories/audit.go new file mode 100644 index 0000000..7682789 --- /dev/null +++ b/backend/internal/database/repositories/audit.go @@ -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, + } +} \ No newline at end of file diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 2db2e81..8174c49 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 604519a..503e107 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 (
@@ -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() {

Dashboard

+ {health && !healthLoading && ( +
+
+ + + {health.healthy ? 'Traefik is healthy' : 'Traefik health check failed'} + + {health.raw?.version && ( + v{health.raw.version} + )} +
+
+ )} +
{statCards.map(card => { const Icon = card.icon diff --git a/frontend/src/pages/RouterDetail.tsx b/frontend/src/pages/RouterDetail.tsx new file mode 100644 index 0000000..e7c159b --- /dev/null +++ b/frontend/src/pages/RouterDetail.tsx @@ -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 = { + enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle }, + disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle }, + warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle }, +} + +const providerIcons: Record = { + 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('all') + const [router, setRouter] = useState(null) + const [editMode, setEditMode] = useState<'idle' | 'editing' | 'validating' | 'applying' | 'success' | 'error'>('idle') + const [editError, setEditError] = useState(null) + const [editSuccess, setEditSuccess] = useState(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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+ +

Failed to load router

+

{(error as Error).message}

+ +
+ ) + } + + const routerData = routers[0] + if (!routerData) { + return ( +
+ +

Router not found

+

No router matching "{name}" was found.

+

Check the spelling and try again.

+ +
+ ) + } + + 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('') + const [currentFile, setCurrentFile] = useState('') + + 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 ( +
+
+
+

Router: {router.name}

+

+ {editable ? 'Editable (file provider)' : 'Read-only'} • Provider: {router.provider} +

+
+
+ + {editable && ( + + )} +
+
+ +
+
+
+ {/* Router details view */} +
+
+

Status

+ + {router.status} + +
+ +
+

Provider

+ + {router.provider} + +
+ +
+

Priority

+ {router.priority} +
+ +
+

Rule

+ {router.rule} +
+ +
+

Service

+ {router.service} +
+ +
+

Entry Points

+ + {router.entry_points.map(ep => ( + {ep} + ))} + +
+ +
+

Middlewares

+ + {router.middlewares.map(mw => ( + {mw} + ))} + +
+ + {router.tls && ( +
+

TLS

+ {router.tls.options ? ( + {router.tls.options} + ) : ( + No TLS + )} + {router.tls.cert_resolver && ( + {router.tls.cert_resolver} + )} + {router.tls.domains && router.tls.domains.length > 0 && ( + + {router.tls.domains.length} domain{s: router.tls.domains.length !== 1} + + )} +
+ )} +
+
+
+ + {editMode === 'idle' && !isFormDirty && ( +
+

No unsaved changes

+
+ )} + + {editable && ( + + )} +
+
+ ) + } + + if (editMode === 'editing') { + return ( +
+
+
+

Router: {router.name} — Edit

+

Provider: {router.provider} (file provider — editable)

+
+
+ +
+
+ +
+
+
+
{ + e.preventDefault() + // Don't submit via form, use buttons instead + }} + > +
+
+ + 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' }} + /> +
+ +
+ + + 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' }} + /> +
+ +
+ + 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' }} + /> +
+ +
+ + + 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' }} + /> +
+ +
+ + 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' }} + /> +
+ +
+ + 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' }} + /> +
+ +
+ + 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' }} + /> +
+ +
+ + + 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' }} + /> +
+
+ +
+ + +
+
+
+
+
+
+ ) + } + + if (editMode === 'validating' || editMode === 'success' || editMode === 'error') { + return ( +
+
+
+

Router: {router.name}

+

+ {editable ? 'Editable (file provider)' : 'Read-only'} • Provider: {router.provider} +

+
+
+ +
+
+ +
+ {editMode === 'validating' && ( +
+
+

Validating configuration…

+
+ )} + + {editMode === 'success' && editSuccess && ( +
+ +

Success!

+

{editSuccess}

+ +
+ )} + + {editMode === 'error' && editError && ( +
+ +

Error

+

{editError}

+ +
+ )} +
+
+ ) + } + + // Fallback + return ( +
+
+

Router: {router.name}

+
+

Unknown state

+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Routers.tsx b/frontend/src/pages/Routers.tsx index 4025e2b..0181295 100644 --- a/frontend/src/pages/Routers.tsx +++ b/frontend/src/pages/Routers.tsx @@ -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 = { enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle }, @@ -9,6 +9,10 @@ const statusColors: Record = { 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('all') @@ -61,6 +65,19 @@ export default function Routers() { ) } + if (error) { + return ( +
+ +

Failed to load routers

+

{(error as Error).message}

+ +
+ ) + } + + const hasMatchingRouters = filteredRouters.length > 0 && routers?.length > 0 + return (
@@ -119,15 +136,27 @@ export default function Routers() { {filteredRouters.length === 0 ? ( - - - No routers found - - + hasMatchingRouters ? ( + + + No routers match the current filter criteria + + + ) : ( + + + No routers found + + + ) ) : ( 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 ( @@ -150,9 +179,17 @@ export default function Routers() { {ep} ))} - {router.name} + + + Detail + + {router.service} - {router.provider} + + + {router.provider} + + {router.priority} ) @@ -161,10 +198,14 @@ export default function Routers() {
- {filteredRouters.length > 0 && ( + {hasMatchingRouters ? (
Showing {filteredRouters.length} of {routers?.length} routers
+ ) : filteredRouters.length === 0 && routers?.length > 0 && ( +
+ No routers match the current filter +
)}