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
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
138
backend/internal/database/repositories/audit.go
Normal file
138
backend/internal/database/repositories/audit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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