diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index f175d969..8a798d37 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,467 +1,1468 @@ -# Security Dashboard Live Logs - Complete Trace Analysis +# Charon UI/UX Improvement Plan +**Issue:** GitHub #409 - UI Enhancement & Design System **Date:** December 16, 2025 -**Status:** ✅ ALL ISSUES FIXED & VERIFIED -**Severity:** Was Critical (WebSocket reconnection loop) → Now Resolved +**Status:** Planning +**Stack:** React 19 + Vite + TypeScript + TanStack Query + Tailwind CSS v4 --- -## 0. FULL TRACE ANALYSIS +## Executive Summary -### File-by-File Data Flow +The current Charon UI is functional but lacks design consistency, visual polish, and systematic component architecture. This plan addresses Issue #409's recommendations to transform the interface from "bland" to professional-grade through: -| Step | File | Lines | Purpose | Status | -|------|------|-------|---------|--------| -| 1 | `frontend/src/pages/Security.tsx` | 36, 421 | Renders LiveLogViewer with memoized filters | ✅ Fixed | -| 2 | `frontend/src/components/LiveLogViewer.tsx` | 138-143, 183-268 | Manages WebSocket lifecycle in useEffect | ✅ Fixed | -| 3 | `frontend/src/api/logs.ts` | 177-237 | `connectSecurityLogs()` - builds WS URL with auth | ✅ Working | -| 4 | `backend/internal/api/routes/routes.go` | 373-394 | Registers `/cerberus/logs/ws` in protected group | ✅ Working | -| 5 | `backend/internal/api/middleware/auth.go` | 12-39 | Validates JWT from header/cookie/query param | ✅ Working | -| 6 | `backend/internal/api/handlers/cerberus_logs_ws.go` | 27-120 | WebSocket handler with filter parsing | ✅ Working | -| 7 | `backend/internal/services/log_watcher.go` | 44-237 | Tails Caddy access log, broadcasts to subscribers | ✅ Working | +1. **Design Token System** - Consistent colors, spacing, typography +2. **Component Library** - Reusable, accessible UI primitives +3. **Layout Improvements** - Better dashboards, tables, empty states +4. **Page Polish** - Systematic improvement of all pages -### Authentication Flow +--- -```text -Frontend Backend -──────── ─────── -localStorage.getItem('charon_auth_token') - │ - ▼ -Query param: ?token= ────────► AuthMiddleware: - 1. Check Authorization header - 2. Check auth_token cookie - 3. Check token query param ◄── MATCHES - │ - ▼ - ValidateToken(jwt) → OK - │ - ▼ - Upgrade to WebSocket +## 1. Current State Analysis + +### 1.1 Tailwind Configuration (tailwind.config.js) + +**Current:** +```javascript +colors: { + 'light-bg': '#f0f4f8', + 'dark-bg': '#0f172a', + 'dark-sidebar': '#020617', + 'dark-card': '#1e293b', + 'blue-active': '#1d4ed8', + 'blue-hover': '#2563eb', +} ``` -### Logic Gap Analysis +**Problems:** +- ❌ Only 6 ad-hoc color tokens +- ❌ No semantic naming (surface, border, text layers) +- ❌ No state colors (success, warning, error, info) +- ❌ No brand color scale +- ❌ No spacing scale beyond Tailwind defaults +- ❌ No typography configuration -**ANSWER: NO - There is NO logic gap between Frontend and Backend.** +### 1.2 CSS Variables (index.css) -| Question | Answer | -|----------|--------| -| Frontend auth method | Query param `?token=` from `localStorage.getItem('charon_auth_token')` | -| Backend auth method | Accepts: Header → Cookie → Query param `token` ✅ | -| Filter params | Both use `source`, `level`, `ip`, `host`, `blocked_only` ✅ | -| Data format | `SecurityLogEntry` struct matches frontend TypeScript type ✅ | - ---- - -## 1. VERIFICATION STATUS - -### ✅ localStorage Key IS Correct - -Both WebSocket functions in `frontend/src/api/logs.ts` correctly use `charon_auth_token`: - -- **Line 119-122** (`connectLiveLogs`): `localStorage.getItem('charon_auth_token')` -- **Line 178-181** (`connectSecurityLogs`): `localStorage.getItem('charon_auth_token')` - ---- - -## 2. ALL ISSUES FOUND (NOW FIXED) - -### Issue #1: CRITICAL - Object Reference Instability in Props (ROOT CAUSE) ✅ FIXED - -**Problem:** `Security.tsx` passed `securityFilters={{}}` inline, creating a new object on every render. This triggered useEffect cleanup/reconnection on every parent re-render. - -**Fix Applied:** - -```tsx -// frontend/src/pages/Security.tsx line 36 -const emptySecurityFilters = useMemo(() => ({}), []) - -// frontend/src/pages/Security.tsx line 421 - +**Current:** +```css +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + color: rgba(255, 255, 255, 0.87); + background-color: #0f172a; +} ``` -### Issue #2: Default Props Had Same Problem ✅ FIXED +**Problems:** +- ❌ Hardcoded colors, not CSS variables +- ❌ No dark/light mode toggle system +- ❌ No type scale +- ❌ Custom animations exist but no transition standards -**Problem:** Default empty objects `filters = {}` in function params created new objects on each call. +### 1.3 Existing Component Library (frontend/src/components/ui/) -**Fix Applied:** +| Component | Status | Issues | +|-----------|--------|--------| +| `Button.tsx` | ✅ Good foundation | Missing outline variant, icon support | +| `Card.tsx` | ✅ Good foundation | Missing hover states, compact variant | +| `Input.tsx` | ✅ Good foundation | No textarea, select variants | +| `Switch.tsx` | ⚠️ Functional | Hard-coded colors, no size variants | -```typescript -// frontend/src/components/LiveLogViewer.tsx lines 138-143 -const EMPTY_LIVE_FILTER: LiveLogFilter = {}; -const EMPTY_SECURITY_FILTER: SecurityLogFilter = {}; +**Missing Components:** +- Badge/Tag +- Alert/Callout +- Dialog/Modal (exists ad-hoc in pages) +- Dropdown/Select +- Tabs +- Tooltip +- Table (data table with sorting) +- Skeleton loaders +- Progress indicators -export function LiveLogViewer({ - filters = EMPTY_LIVE_FILTER, - securityFilters = EMPTY_SECURITY_FILTER, - // ... -}) -``` +### 1.4 Page-Level UI Patterns -### Issue #3: `showBlockedOnly` Toggle (INTENTIONAL) +| Page | Patterns | Issues | +|------|----------|--------| +| Dashboard | KPI cards, links | Cards lack visual hierarchy, no trend indicators | +| ProxyHosts | Data table, modals | Inline modals, inconsistent styling, no sticky headers | +| Security | Layer cards, toggles | Good theming, but cards cramped | +| Settings | Tab navigation, forms | Basic tabs, form styling inconsistent | +| AccessLists | Table with selection | Good patterns, inline confirm dialogs | -The `showBlockedOnly` state in useEffect dependencies causes reconnection when toggled. This is **intentional** for server-side filtering - not a bug. +### 1.5 Inconsistencies Found + +1. **Modal Patterns**: Some use `fixed inset-0`, some use custom positioning +2. **Button Styling**: Mix of `bg-blue-active` and `bg-blue-600` +3. **Card Borders**: Some use `border-gray-800`, others `border-gray-700` +4. **Text Colors**: Inconsistent use of gray scale (gray-400/500 for secondary) +5. **Spacing**: No consistent page gutters or section spacing +6. **Focus States**: `focus:ring-2` used but not consistently +7. **Loading States**: Custom Charon/Cerberus loaders exist but not used everywhere --- -## 3. ROOT CAUSE ANALYSIS +## 2. Design Token System -### The Reconnection Loop (Before Fix) +### 2.1 CSS Variables (index.css) -1. User navigates to Security Dashboard -2. `Security.tsx` renders with `` -3. `LiveLogViewer` mounts → useEffect runs → WebSocket connects -4. React Query refetches security status -5. `Security.tsx` re-renders → **new `{}` object created** -6. `LiveLogViewer` re-renders → useEffect sees "changed" `securityFilters` -7. useEffect cleanup runs → **WebSocket closes** -8. useEffect body runs → **WebSocket opens** -9. Repeat steps 4-8 every ~100ms +```css +@layer base { + :root { + /* ======================================== + * BRAND COLORS + * ======================================== */ + --color-brand-50: 239 246 255; /* #eff6ff */ + --color-brand-100: 219 234 254; /* #dbeafe */ + --color-brand-200: 191 219 254; /* #bfdbfe */ + --color-brand-300: 147 197 253; /* #93c5fd */ + --color-brand-400: 96 165 250; /* #60a5fa */ + --color-brand-500: 59 130 246; /* #3b82f6 - Primary */ + --color-brand-600: 37 99 235; /* #2563eb */ + --color-brand-700: 29 78 216; /* #1d4ed8 */ + --color-brand-800: 30 64 175; /* #1e40af */ + --color-brand-900: 30 58 138; /* #1e3a8a */ + --color-brand-950: 23 37 84; /* #172554 */ -### Evidence from Docker Logs (Before Fix) + /* ======================================== + * SEMANTIC COLORS - Light Mode + * ======================================== */ + /* Surfaces */ + --color-bg-base: 248 250 252; /* slate-50 */ + --color-bg-subtle: 241 245 249; /* slate-100 */ + --color-bg-muted: 226 232 240; /* slate-200 */ + --color-bg-elevated: 255 255 255; /* white */ + --color-bg-overlay: 15 23 42; /* slate-900 */ -```text -{"level":"info","msg":"Cerberus logs WebSocket connected","subscriber_id":"xxx"} -{"level":"info","msg":"Cerberus logs WebSocket client disconnected","subscriber_id":"xxx"} -{"level":"info","msg":"Cerberus logs WebSocket connected","subscriber_id":"yyy"} -{"level":"info","msg":"Cerberus logs WebSocket client disconnected","subscriber_id":"yyy"} -``` + /* Borders */ + --color-border-default: 226 232 240; /* slate-200 */ + --color-border-muted: 241 245 249; /* slate-100 */ + --color-border-strong: 203 213 225; /* slate-300 */ ---- + /* Text */ + --color-text-primary: 15 23 42; /* slate-900 */ + --color-text-secondary: 71 85 105; /* slate-600 */ + --color-text-muted: 148 163 184; /* slate-400 */ + --color-text-inverted: 255 255 255; /* white */ -## 4. COMPONENT DEEP DIVE + /* States */ + --color-success: 34 197 94; /* green-500 */ + --color-success-muted: 220 252 231; /* green-100 */ + --color-warning: 234 179 8; /* yellow-500 */ + --color-warning-muted: 254 249 195; /* yellow-100 */ + --color-error: 239 68 68; /* red-500 */ + --color-error-muted: 254 226 226; /* red-100 */ + --color-info: 59 130 246; /* blue-500 */ + --color-info-muted: 219 234 254; /* blue-100 */ -### Frontend: Security.tsx + /* ======================================== + * TYPOGRAPHY + * ======================================== */ + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; -- Renders the Security Dashboard with 4 security layer cards (CrowdSec, ACL, Coraza, Rate Limiting) -- Contains multiple `useQuery`/`useMutation` hooks that trigger re-renders -- **Line 36:** Creates stable filter reference with `useMemo` -- **Line 421:** Passes stable reference to `LiveLogViewer` + /* Type Scale (rem) */ + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px */ + --text-lg: 1.125rem; /* 18px */ + --text-xl: 1.25rem; /* 20px */ + --text-2xl: 1.5rem; /* 24px */ + --text-3xl: 1.875rem; /* 30px */ + --text-4xl: 2.25rem; /* 36px */ -### Frontend: LiveLogViewer.tsx + /* Line Heights */ + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.75; -- Dual-mode log viewer (application logs vs security logs) -- **Lines 138-139:** Stable default filter objects defined outside component -- **Lines 183-268:** useEffect that manages WebSocket lifecycle -- **Line 268:** Dependencies: `[currentMode, filters, securityFilters, maxLogs, showBlockedOnly]` -- Uses `isPausedRef` to avoid reconnection when pausing + /* Font Weights */ + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; -### Frontend: logs.ts (API Client) + /* ======================================== + * SPACING & LAYOUT + * ======================================== */ + --space-0: 0; + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ -- **`connectSecurityLogs()`** (lines 177-237): - - Builds URLSearchParams from filter object - - Gets auth token from `localStorage.getItem('charon_auth_token')` - - Appends token as query param - - Constructs URL: `wss://host/api/v1/cerberus/logs/ws?...&token=` + /* Container */ + --container-sm: 640px; + --container-md: 768px; + --container-lg: 1024px; + --container-xl: 1280px; + --container-2xl: 1536px; -### Backend: routes.go + /* Page Gutters */ + --page-gutter: var(--space-6); + --page-gutter-lg: var(--space-8); -- **Line 380-389:** Creates LogWatcher service pointing to `/var/log/caddy/access.log` -- **Line 393:** Creates `CerberusLogsHandler` -- **Line 394:** Registers route in protected group (auth required) + /* ======================================== + * EFFECTS + * ======================================== */ + /* Border Radius */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.375rem; /* 6px */ + --radius-lg: 0.5rem; /* 8px */ + --radius-xl: 0.75rem; /* 12px */ + --radius-2xl: 1rem; /* 16px */ + --radius-full: 9999px; -### Backend: auth.go (Middleware) + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); -- **Lines 14-28:** Auth flow: Header → Cookie → Query param -- **Line 25-28:** Query param fallback: `if token := c.Query("token"); token != ""` -- WebSocket connections use query param auth (browsers can't set headers on WS) + /* Transitions */ + --transition-fast: 150ms; + --transition-normal: 200ms; + --transition-slow: 300ms; + --ease-default: cubic-bezier(0.4, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); -### Backend: cerberus_logs_ws.go (Handler) + /* Focus Ring */ + --ring-width: 2px; + --ring-offset: 2px; + --ring-color: var(--color-brand-500); + } -- **Lines 42-48:** Upgrades HTTP to WebSocket -- **Lines 53-59:** Parses filter query params -- **Lines 61-62:** Subscribes to LogWatcher -- **Lines 80-109:** Main loop broadcasting filtered entries + /* ======================================== + * DARK MODE OVERRIDES + * ======================================== */ + .dark { + /* Surfaces */ + --color-bg-base: 15 23 42; /* slate-900 */ + --color-bg-subtle: 30 41 59; /* slate-800 */ + --color-bg-muted: 51 65 85; /* slate-700 */ + --color-bg-elevated: 30 41 59; /* slate-800 */ + --color-bg-overlay: 2 6 23; /* slate-950 */ -### Backend: log_watcher.go (Service) + /* Borders */ + --color-border-default: 51 65 85; /* slate-700 */ + --color-border-muted: 30 41 59; /* slate-800 */ + --color-border-strong: 71 85 105; /* slate-600 */ -- Singleton service tailing Caddy access log -- Parses JSON log lines into `SecurityLogEntry` -- Broadcasts to all WebSocket subscribers -- Detects security events (WAF, CrowdSec, ACL, rate limit) + /* Text */ + --color-text-primary: 248 250 252; /* slate-50 */ + --color-text-secondary: 203 213 225; /* slate-300 */ + --color-text-muted: 148 163 184; /* slate-400 */ + --color-text-inverted: 15 23 42; /* slate-900 */ ---- - -## 5. SUMMARY TABLE - -| Component | Status | Notes | -|-----------|--------|-------| -| localStorage key | ✅ Fixed | Now uses `charon_auth_token` | -| Auth middleware | ✅ Working | Accepts query param `token` | -| WebSocket endpoint | ✅ Working | Protected route, upgrades correctly | -| LogWatcher service | ✅ Working | Tails access.log successfully | -| **Frontend memoization** | ✅ Fixed | `useMemo` in Security.tsx | -| **Stable default props** | ✅ Fixed | Constants in LiveLogViewer.tsx | - ---- - -## 6. VERIFICATION STEPS - -After any changes, verify with: - -```bash -# 1. Rebuild and restart -docker build -t charon:local . && docker compose -f docker-compose.override.yml up -d - -# 2. Check for stable connection (should see ONE connect, no rapid cycling) -docker logs charon 2>&1 | grep -i "cerberus.*websocket" | tail -10 - -# 3. Browser DevTools → Console -# Should see: "Cerberus logs WebSocket connection established" -# Should NOT see repeated connection attempts -``` - ---- - -## 7. CONCLUSION - -**Root Cause:** React reference instability (`{}` creates new object on every render) - -**Solution Applied:** Memoize filter objects to maintain stable references - -**Logic Gap Between Frontend/Backend:** **NO** - Both are correctly aligned - -**Current Status:** ✅ All fixes applied and working - ---- - -# Health Check 401 Auth Failures - Investigation Report - -**Date:** December 16, 2025 -**Status:** ✅ ANALYZED - NOT A BUG -**Severity:** Informational (Log Noise) - ---- - -## 1. INVESTIGATION SUMMARY - -### What the User Observed - -The user reported recurring 401 auth failures in Docker logs: -``` -01:03:10 AUTH 172.20.0.1 GET / → 401 [401] 133.6ms -{ "auth_failure": true } -01:04:10 AUTH 172.20.0.1 GET / → 401 [401] 112.9ms -{ "auth_failure": true } -``` - -### Initial Hypothesis vs Reality - -| Hypothesis | Reality | -|------------|---------| -| Docker health check hitting `/` | ❌ Docker health check hits `/api/v1/health` and works correctly (200) | -| Charon backend auth issue | ❌ Charon backend auth is working fine | -| Missing health endpoint | ❌ `/api/v1/health` exists and is public | - ---- - -## 2. ROOT CAUSE IDENTIFIED - -### The 401s are FROM Plex, NOT Charon - -**Evidence from logs:** - -```json -{ - "host": "plex.hatfieldhosted.com", - "uri": "/", - "status": 401, - "resp_headers": { - "X-Plex-Protocol": ["1.0"], - "X-Plex-Content-Compressed-Length": ["157"], - "Cache-Control": ["no-cache"] + /* States - Muted versions for dark mode */ + --color-success-muted: 20 83 45; /* green-900 */ + --color-warning-muted: 113 63 18; /* yellow-900 */ + --color-error-muted: 127 29 29; /* red-900 */ + --color-info-muted: 30 58 138; /* blue-900 */ } } ``` -The 401 responses contain **Plex-specific headers** (`X-Plex-Protocol`, `X-Plex-Content-Compressed-Length`). This proves: +### 2.2 Tailwind Configuration (tailwind.config.js) -1. The request goes through Caddy to **Plex backend** -2. **Plex** returns 401 because the request has no auth token -3. Caddy logs this as a handled request - -### What's Making These Requests? - -**Charon's Uptime Monitoring Service** (`backend/internal/services/uptime_service.go`) - -The `checkMonitor()` function performs HTTP GET requests to proxied hosts: - -```go -case "http", "https": - client := http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(monitor.URL) // e.g., https://plex.hatfieldhosted.com/ -``` - -Key behaviors: -- Runs every 60 seconds (`interval: 60`) -- Checks the **public URL** of each proxy host -- Uses `Go-http-client/2.0` User-Agent (visible in logs) -- **Correctly treats 401/403 as "service is up"** (lines 471-474 of uptime_service.go) - ---- - -## 3. ARCHITECTURE FLOW - -```text -┌─────────────────────────────────────────────────────────────┐ -│ Charon Container (172.20.0.1 from Docker's perspective) │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────┐ │ -│ │ Uptime Service │ │ -│ │ (Go-http-client/2.0)│ │ -│ └──────────┬──────────┘ │ -│ │ GET https://plex.hatfieldhosted.com/ │ -│ ▼ │ -│ ┌─────────────────────┐ │ -│ │ Caddy Reverse Proxy │ │ -│ │ (ports 80/443) │ │ -│ └──────────┬──────────┘ │ -│ │ Logs request to access.log │ -└─────────────┼───────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Plex Container (172.20.0.x) │ -├─────────────────────────────────────────────────────────────┤ -│ GET / → 401 Unauthorized (no X-Plex-Token) │ -└─────────────────────────────────────────────────────────────┘ +```javascript +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + // Brand + brand: { + 50: 'rgb(var(--color-brand-50) / )', + 100: 'rgb(var(--color-brand-100) / )', + 200: 'rgb(var(--color-brand-200) / )', + 300: 'rgb(var(--color-brand-300) / )', + 400: 'rgb(var(--color-brand-400) / )', + 500: 'rgb(var(--color-brand-500) / )', + 600: 'rgb(var(--color-brand-600) / )', + 700: 'rgb(var(--color-brand-700) / )', + 800: 'rgb(var(--color-brand-800) / )', + 900: 'rgb(var(--color-brand-900) / )', + 950: 'rgb(var(--color-brand-950) / )', + }, + // Semantic Surfaces + surface: { + base: 'rgb(var(--color-bg-base) / )', + subtle: 'rgb(var(--color-bg-subtle) / )', + muted: 'rgb(var(--color-bg-muted) / )', + elevated: 'rgb(var(--color-bg-elevated) / )', + overlay: 'rgb(var(--color-bg-overlay) / )', + }, + // Semantic Borders + border: { + DEFAULT: 'rgb(var(--color-border-default) / )', + muted: 'rgb(var(--color-border-muted) / )', + strong: 'rgb(var(--color-border-strong) / )', + }, + // Semantic Text + content: { + primary: 'rgb(var(--color-text-primary) / )', + secondary: 'rgb(var(--color-text-secondary) / )', + muted: 'rgb(var(--color-text-muted) / )', + inverted: 'rgb(var(--color-text-inverted) / )', + }, + // Status Colors + success: { + DEFAULT: 'rgb(var(--color-success) / )', + muted: 'rgb(var(--color-success-muted) / )', + }, + warning: { + DEFAULT: 'rgb(var(--color-warning) / )', + muted: 'rgb(var(--color-warning-muted) / )', + }, + error: { + DEFAULT: 'rgb(var(--color-error) / )', + muted: 'rgb(var(--color-error-muted) / )', + }, + info: { + DEFAULT: 'rgb(var(--color-info) / )', + muted: 'rgb(var(--color-info-muted) / )', + }, + // Legacy support (deprecate over time) + 'dark-bg': '#0f172a', + 'dark-sidebar': '#020617', + 'dark-card': '#1e293b', + 'blue-active': '#1d4ed8', + 'blue-hover': '#2563eb', + }, + fontFamily: { + sans: ['var(--font-sans)'], + mono: ['var(--font-mono)'], + }, + fontSize: { + xs: ['var(--text-xs)', { lineHeight: 'var(--leading-normal)' }], + sm: ['var(--text-sm)', { lineHeight: 'var(--leading-normal)' }], + base: ['var(--text-base)', { lineHeight: 'var(--leading-normal)' }], + lg: ['var(--text-lg)', { lineHeight: 'var(--leading-normal)' }], + xl: ['var(--text-xl)', { lineHeight: 'var(--leading-tight)' }], + '2xl': ['var(--text-2xl)', { lineHeight: 'var(--leading-tight)' }], + '3xl': ['var(--text-3xl)', { lineHeight: 'var(--leading-tight)' }], + '4xl': ['var(--text-4xl)', { lineHeight: 'var(--leading-tight)' }], + }, + borderRadius: { + sm: 'var(--radius-sm)', + DEFAULT: 'var(--radius-md)', + md: 'var(--radius-md)', + lg: 'var(--radius-lg)', + xl: 'var(--radius-xl)', + '2xl': 'var(--radius-2xl)', + }, + boxShadow: { + sm: 'var(--shadow-sm)', + DEFAULT: 'var(--shadow-md)', + md: 'var(--shadow-md)', + lg: 'var(--shadow-lg)', + xl: 'var(--shadow-xl)', + }, + transitionDuration: { + fast: 'var(--transition-fast)', + normal: 'var(--transition-normal)', + slow: 'var(--transition-slow)', + }, + spacing: { + 'page': 'var(--page-gutter)', + 'page-lg': 'var(--page-gutter-lg)', + }, + }, + }, + plugins: [], +} ``` --- -## 4. DOCKER HEALTH CHECK STATUS +## 3. Component Library Specifications -### ✅ Docker Health Check is WORKING CORRECTLY - -**Configuration** (from all docker-compose files): - -```yaml -healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s -``` - -**Evidence:** +### 3.1 Directory Structure ``` -[GIN] 2025/12/16 - 01:04:45 | 200 | 304.212µs | ::1 | GET "/api/v1/health" +frontend/src/components/ui/ +├── index.ts # Barrel exports +├── Button.tsx # ✅ Exists - enhance +├── Card.tsx # ✅ Exists - enhance +├── Input.tsx # ✅ Exists - enhance +├── Switch.tsx # ✅ Exists - enhance +├── Badge.tsx # 🆕 New +├── Alert.tsx # 🆕 New +├── Dialog.tsx # 🆕 New +├── Select.tsx # 🆕 New +├── Tabs.tsx # 🆕 New +├── Tooltip.tsx # 🆕 New +├── DataTable.tsx # 🆕 New +├── Skeleton.tsx # 🆕 New +├── Progress.tsx # 🆕 New +├── Checkbox.tsx # 🆕 New +├── Label.tsx # 🆕 New +├── Textarea.tsx # 🆕 New +└── __tests__/ # Component tests ``` -- Hits `/api/v1/health` (not `/`) -- Returns `200` (not `401`) -- Source IP is `::1` (localhost) -- Interval is 30s (matches config) +### 3.2 Component Specifications -### Health Endpoint Details +#### Badge Component -**Route Registration** ([routes.go#L86](backend/internal/api/routes/routes.go#L86)): +```tsx +// frontend/src/components/ui/Badge.tsx +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' -```go -router.GET("/api/v1/health", handlers.HealthHandler) +const badgeVariants = cva( + 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors', + { + variants: { + variant: { + default: 'bg-surface-muted text-content-primary', + primary: 'bg-brand-500/10 text-brand-500', + success: 'bg-success-muted text-success', + warning: 'bg-warning-muted text-warning', + error: 'bg-error-muted text-error', + outline: 'border border-border text-content-secondary', + }, + size: { + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-0.5 text-xs', + lg: 'px-3 py-1 text-sm', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, + } +) + +interface BadgeProps + extends React.HTMLAttributes, + VariantProps { + icon?: React.ReactNode +} + +export function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) { + return ( + + {icon && {icon}} + {children} + + ) +} ``` -This is registered **before** any auth middleware, making it a public endpoint. +#### Alert Component -**Handler Response** ([health_handler.go#L29-L37](backend/internal/api/handlers/health_handler.go#L29-L37)): +```tsx +// frontend/src/components/ui/Alert.tsx +import { cva, type VariantProps } from 'class-variance-authority' +import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react' +import { cn } from '../../utils/cn' -```go -func HealthHandler(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "service": version.Name, - "version": version.Version, - "git_commit": version.GitCommit, - "build_time": version.BuildTime, - "internal_ip": getLocalIP(), +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11', + { + variants: { + variant: { + default: 'bg-surface-subtle border-border text-content-primary', + info: 'bg-info-muted border-info/20 text-info [&>svg]:text-info', + success: 'bg-success-muted border-success/20 text-success [&>svg]:text-success', + warning: 'bg-warning-muted border-warning/20 text-warning [&>svg]:text-warning', + error: 'bg-error-muted border-error/20 text-error [&>svg]:text-error', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +const iconMap = { + default: Info, + info: Info, + success: CheckCircle, + warning: AlertTriangle, + error: AlertCircle, +} + +interface AlertProps + extends React.HTMLAttributes, + VariantProps { + title?: string + onDismiss?: () => void +} + +export function Alert({ + className, + variant = 'default', + title, + children, + onDismiss, + ...props +}: AlertProps) { + const Icon = iconMap[variant || 'default'] + + return ( +
+ +
+ {title &&
{title}
} +
{children}
+
+ {onDismiss && ( + + )} +
+ ) +} +``` + +#### Dialog Component + +```tsx +// frontend/src/components/ui/Dialog.tsx +import { Fragment, type ReactNode } from 'react' +import { X } from 'lucide-react' +import { cn } from '../../utils/cn' + +interface DialogProps { + open: boolean + onClose: () => void + children: ReactNode + className?: string +} + +export function Dialog({ open, onClose, children, className }: DialogProps) { + if (!open) return null + + return ( +
+ {/* Backdrop */} + + ) +} + +export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +export function DialogTitle({ children, className }: { children: ReactNode; className?: string }) { + return ( +

+ {children} +

+ ) +} + +export function DialogClose({ onClose }: { onClose: () => void }) { + return ( + + ) +} + +export function DialogContent({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} +``` + +#### Enhanced Button Component + +```tsx +// frontend/src/components/ui/Button.tsx (enhanced) +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { Loader2 } from 'lucide-react' +import { cn } from '../../utils/cn' + +const buttonVariants = cva( + [ + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg', + 'text-sm font-medium transition-all duration-fast', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2', + 'disabled:pointer-events-none disabled:opacity-50', + 'active:scale-[0.98]', + ], + { + variants: { + variant: { + primary: 'bg-brand-600 text-white hover:bg-brand-700 shadow-sm', + secondary: 'bg-surface-muted text-content-primary hover:bg-surface-subtle border border-border', + danger: 'bg-error text-white hover:bg-red-600 shadow-sm', + ghost: 'text-content-secondary hover:text-content-primary hover:bg-surface-muted', + outline: 'border border-border text-content-primary hover:bg-surface-muted', + link: 'text-brand-500 hover:text-brand-600 underline-offset-4 hover:underline', + }, + size: { + sm: 'h-8 px-3 text-xs', + md: 'h-10 px-4 text-sm', + lg: 'h-12 px-6 text-base', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + } +) + +interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + isLoading?: boolean + leftIcon?: ReactNode + rightIcon?: ReactNode +} + +export const Button = forwardRef( + ({ className, variant, size, isLoading, leftIcon, rightIcon, children, disabled, ...props }, ref) => { + return ( + + ) + } +) + +Button.displayName = 'Button' +``` + +#### Skeleton Component + +```tsx +// frontend/src/components/ui/Skeleton.tsx +import { cn } from '../../utils/cn' + +interface SkeletonProps extends React.HTMLAttributes { + variant?: 'default' | 'circular' | 'text' +} + +export function Skeleton({ className, variant = 'default', ...props }: SkeletonProps) { + return ( +
+ ) +} + +// Pre-built skeleton patterns +export function SkeletonCard() { + return ( +
+ + +
+ + +
+
+ ) +} + +export function SkeletonTable({ rows = 5 }: { rows?: number }) { + return ( +
+
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+ {[1, 2, 3, 4].map((j) => ( + + ))} +
+ ))} +
+
+ ) +} +``` + +### 3.3 Dependencies to Add + +```json +{ + "dependencies": { + "class-variance-authority": "^0.7.0", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-progress": "^1.0.3" + } +} +``` + +--- + +## 4. Layout Improvements + +### 4.1 Page Shell Component + +```tsx +// frontend/src/components/layout/PageShell.tsx +import { type ReactNode } from 'react' +import { cn } from '../../utils/cn' + +interface PageShellProps { + title: string + description?: string + actions?: ReactNode + children: ReactNode + className?: string +} + +export function PageShell({ title, description, actions, children, className }: PageShellProps) { + return ( +
+
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ {children} +
+ ) +} +``` + +### 4.2 Stats Card Component + +```tsx +// frontend/src/components/ui/StatsCard.tsx +import { type ReactNode } from 'react' +import { cn } from '../../utils/cn' +import { TrendingUp, TrendingDown, Minus } from 'lucide-react' + +interface StatsCardProps { + title: string + value: string | number + change?: { + value: number + trend: 'up' | 'down' | 'neutral' + label?: string + } + icon?: ReactNode + href?: string + className?: string +} + +export function StatsCard({ title, value, change, icon, href, className }: StatsCardProps) { + const Wrapper = href ? 'a' : 'div' + const wrapperProps = href ? { href } : {} + + const TrendIcon = change?.trend === 'up' ? TrendingUp : change?.trend === 'down' ? TrendingDown : Minus + const trendColor = change?.trend === 'up' ? 'text-success' : change?.trend === 'down' ? 'text-error' : 'text-content-muted' + + return ( + +
+
+

{title}

+

{value}

+ {change && ( +
+ + {change.value}% + {change.label && {change.label}} +
+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ) +} +``` + +### 4.3 Empty State Component (Enhanced) + +```tsx +// frontend/src/components/ui/EmptyState.tsx +import { type ReactNode } from 'react' +import { cn } from '../../utils/cn' +import { Button } from './Button' + +interface EmptyStateProps { + icon?: ReactNode + title: string + description: string + action?: { + label: string + onClick: () => void + variant?: 'primary' | 'secondary' + } + secondaryAction?: { + label: string + onClick: () => void + } + className?: string +} + +export function EmptyState({ + icon, + title, + description, + action, + secondaryAction, + className, +}: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{title}

+

{description}

+ {(action || secondaryAction) && ( +
+ {action && ( + + )} + {secondaryAction && ( + + )} +
+ )} +
+ ) +} +``` + +### 4.4 Data Table Component + +```tsx +// frontend/src/components/ui/DataTable.tsx +import { type ReactNode, useState } from 'react' +import { cn } from '../../utils/cn' +import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' +import { Checkbox } from './Checkbox' + +interface Column { + key: string + header: string + cell: (row: T) => ReactNode + sortable?: boolean + width?: string +} + +interface DataTableProps { + data: T[] + columns: Column[] + rowKey: (row: T) => string + selectable?: boolean + selectedKeys?: Set + onSelectionChange?: (keys: Set) => void + onRowClick?: (row: T) => void + emptyState?: ReactNode + isLoading?: boolean + stickyHeader?: boolean + className?: string +} + +export function DataTable({ + data, + columns, + rowKey, + selectable, + selectedKeys = new Set(), + onSelectionChange, + onRowClick, + emptyState, + isLoading, + stickyHeader = true, + className, +}: DataTableProps) { + const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null) + + const handleSort = (key: string) => { + setSortConfig((prev) => { + if (prev?.key === key) { + return prev.direction === 'asc' ? { key, direction: 'desc' } : null + } + return { key, direction: 'asc' } }) + } + + const handleSelectAll = () => { + if (!onSelectionChange) return + if (selectedKeys.size === data.length) { + onSelectionChange(new Set()) + } else { + onSelectionChange(new Set(data.map(rowKey))) + } + } + + const handleSelectRow = (key: string) => { + if (!onSelectionChange) return + const newKeys = new Set(selectedKeys) + if (newKeys.has(key)) { + newKeys.delete(key) + } else { + newKeys.add(key) + } + onSelectionChange(newKeys) + } + + const allSelected = data.length > 0 && selectedKeys.size === data.length + const someSelected = selectedKeys.size > 0 && selectedKeys.size < data.length + + return ( +
+
+ + + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + + + + {data.length === 0 && !isLoading ? ( + + + + ) : ( + data.map((row) => { + const key = rowKey(row) + const isSelected = selectedKeys.has(key) + + return ( + onRowClick?.(row)} + > + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + + ) + }) + )} + +
+ + col.sortable && handleSort(col.key)} + > +
+ {col.header} + {col.sortable && ( + + {sortConfig?.key === col.key ? ( + sortConfig.direction === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + )} +
+
+ {emptyState || ( +
No data available
+ )} +
e.stopPropagation()}> + handleSelectRow(key)} + /> + + {col.cell(row)} +
+
+
+ ) } ``` --- -## 5. WHY THIS IS NOT A BUG +## 5. Implementation Phases -### Uptime Service Design is Correct +### Phase 1: Design Tokens Foundation (Week 1) -From [uptime_service.go#L471-L474](backend/internal/services/uptime_service.go#L471-L474): +**Files to Modify:** +- [frontend/src/index.css](frontend/src/index.css) - Add CSS variables +- [frontend/tailwind.config.js](frontend/tailwind.config.js) - Add semantic color mapping -```go -// Accept 2xx, 3xx, and 401/403 (Unauthorized/Forbidden often means the service is up but protected) -if (resp.StatusCode >= 200 && resp.StatusCode < 400) || resp.StatusCode == 401 || resp.StatusCode == 403 { - success = true - msg = fmt.Sprintf("HTTP %d", resp.StatusCode) -} -``` +**Files to Create:** +- None (modify existing) -**Rationale:** A 401 response proves: -- The service is running -- The network path is functional -- The application is responding +**Tasks:** +1. Add CSS custom properties to `:root` and `.dark` in index.css +2. Update tailwind.config.js with new color tokens +3. Test light/dark mode switching +4. Verify no visual regressions -This is industry-standard practice for uptime monitoring of auth-protected services. +**Testing:** +- Visual regression test for Dashboard, Security, ProxyHosts +- Dark/light mode toggle verification +- Build succeeds without errors --- -## 6. RECOMMENDATIONS +### Phase 2: Core Component Library (Weeks 2-3) -### Option A: Do Nothing (Recommended) +**Files to Create:** +- [frontend/src/components/ui/Badge.tsx](frontend/src/components/ui/Badge.tsx) +- [frontend/src/components/ui/Alert.tsx](frontend/src/components/ui/Alert.tsx) +- [frontend/src/components/ui/Dialog.tsx](frontend/src/components/ui/Dialog.tsx) +- [frontend/src/components/ui/Select.tsx](frontend/src/components/ui/Select.tsx) +- [frontend/src/components/ui/Tabs.tsx](frontend/src/components/ui/Tabs.tsx) +- [frontend/src/components/ui/Tooltip.tsx](frontend/src/components/ui/Tooltip.tsx) +- [frontend/src/components/ui/Skeleton.tsx](frontend/src/components/ui/Skeleton.tsx) +- [frontend/src/components/ui/Progress.tsx](frontend/src/components/ui/Progress.tsx) +- [frontend/src/components/ui/Checkbox.tsx](frontend/src/components/ui/Checkbox.tsx) +- [frontend/src/components/ui/Label.tsx](frontend/src/components/ui/Label.tsx) +- [frontend/src/components/ui/Textarea.tsx](frontend/src/components/ui/Textarea.tsx) +- [frontend/src/components/ui/index.ts](frontend/src/components/ui/index.ts) - Barrel exports -The current behavior is correct: -- Docker health checks work ✅ -- Uptime monitoring works ✅ -- Plex is correctly marked as "up" despite 401 ✅ +**Files to Modify:** +- [frontend/src/components/ui/Button.tsx](frontend/src/components/ui/Button.tsx) - Enhance with variants +- [frontend/src/components/ui/Card.tsx](frontend/src/components/ui/Card.tsx) - Add hover, variants +- [frontend/src/components/ui/Input.tsx](frontend/src/components/ui/Input.tsx) - Enhance styling +- [frontend/src/components/ui/Switch.tsx](frontend/src/components/ui/Switch.tsx) - Use tokens -The 401s in Caddy access logs are informational noise, not errors. +**Dependencies to Add:** +```bash +npm install class-variance-authority @radix-ui/react-dialog @radix-ui/react-tooltip @radix-ui/react-tabs @radix-ui/react-select @radix-ui/react-checkbox @radix-ui/react-progress +``` -### Option B: Reduce Log Verbosity (Optional) +**Testing:** +- Unit tests for each new component +- Storybook-style visual verification (manual) +- Accessibility audit (keyboard nav, screen reader) -If the log noise is undesirable, options include: +--- -1. **Configure Caddy to not log uptime checks:** - Add a log filter for `Go-http-client` User-Agent +### Phase 3: Layout Components (Week 4) -2. **Use backend health endpoints:** - Some services like Plex have health endpoints (`/identity`, `/status`) that don't require auth +**Files to Create:** +- [frontend/src/components/layout/PageShell.tsx](frontend/src/components/layout/PageShell.tsx) +- [frontend/src/components/ui/StatsCard.tsx](frontend/src/components/ui/StatsCard.tsx) +- [frontend/src/components/ui/EmptyState.tsx](frontend/src/components/ui/EmptyState.tsx) (enhance existing) +- [frontend/src/components/ui/DataTable.tsx](frontend/src/components/ui/DataTable.tsx) -3. **Add per-monitor health path option:** - Extend `UptimeMonitor` model to allow custom health check paths +**Files to Modify:** +- [frontend/src/components/Layout.tsx](frontend/src/components/Layout.tsx) - Apply token system -### Option C: Already Implemented +**Testing:** +- Responsive layout tests +- Mobile sidebar behavior +- Table scrolling with sticky headers -The Uptime Service already logs status changes only, not every check: +--- -```go -if statusChanged { - logger.Log().WithFields(map[string]interface{}{ - "host_name": host.Name, - // ... - }).Info("Host status changed") -} +### Phase 4: Page-by-Page Polish (Weeks 5-7) + +#### 4.1 Dashboard (Week 5) + +**Files to Modify:** +- [frontend/src/pages/Dashboard.tsx](frontend/src/pages/Dashboard.tsx) + +**Changes:** +- Replace link cards with `StatsCard` component +- Add trend indicators +- Improve UptimeWidget styling +- Add skeleton loading states +- Consistent page padding + +#### 4.2 ProxyHosts (Week 5) + +**Files to Modify:** +- [frontend/src/pages/ProxyHosts.tsx](frontend/src/pages/ProxyHosts.tsx) +- [frontend/src/components/ProxyHostForm.tsx](frontend/src/components/ProxyHostForm.tsx) + +**Changes:** +- Replace inline table with `DataTable` component +- Replace inline modals with `Dialog` component +- Use `Badge` for SSL/WS/ACL indicators +- Use `Alert` for error states +- Add `EmptyState` for no hosts + +#### 4.3 Security Dashboard (Week 6) + +**Files to Modify:** +- [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx) + +**Changes:** +- Use enhanced `Card` with hover states +- Use `Badge` for status indicators +- Improve layer card spacing +- Consistent button variants + +#### 4.4 Settings (Week 6) + +**Files to Modify:** +- [frontend/src/pages/Settings.tsx](frontend/src/pages/Settings.tsx) +- [frontend/src/pages/SystemSettings.tsx](frontend/src/pages/SystemSettings.tsx) +- [frontend/src/pages/SMTPSettings.tsx](frontend/src/pages/SMTPSettings.tsx) +- [frontend/src/pages/Account.tsx](frontend/src/pages/Account.tsx) + +**Changes:** +- Replace tab links with `Tabs` component +- Improve form field styling with `Label` +- Use `Alert` for validation errors +- Consistent page shell + +#### 4.5 AccessLists (Week 7) + +**Files to Modify:** +- [frontend/src/pages/AccessLists.tsx](frontend/src/pages/AccessLists.tsx) +- [frontend/src/components/AccessListForm.tsx](frontend/src/components/AccessListForm.tsx) + +**Changes:** +- Replace inline table with `DataTable` +- Replace confirm dialogs with `Dialog` +- Use `Alert` for CGNAT warning +- Use `Badge` for ACL types + +#### 4.6 Other Pages (Week 7) + +**Files to Review/Modify:** +- [frontend/src/pages/Certificates.tsx](frontend/src/pages/Certificates.tsx) +- [frontend/src/pages/RemoteServers.tsx](frontend/src/pages/RemoteServers.tsx) +- [frontend/src/pages/Logs.tsx](frontend/src/pages/Logs.tsx) +- [frontend/src/pages/Backups.tsx](frontend/src/pages/Backups.tsx) + +**Changes:** +- Apply consistent `PageShell` wrapper +- Use new component library throughout +- Add loading skeletons +- Improve empty states + +--- + +## 6. Page-by-Page Improvement Checklist + +### Dashboard +- [ ] Replace link cards with `StatsCard` +- [ ] Add trend indicators (up/down arrows) +- [ ] Skeleton loading states +- [ ] Consistent spacing (page gutter) +- [ ] Improve CertificateStatusCard styling + +### ProxyHosts +- [ ] `DataTable` with sticky header +- [ ] `Dialog` for add/edit forms +- [ ] `Badge` for SSL/WS/ACL status +- [ ] `EmptyState` when no hosts +- [ ] Bulk action bar styling +- [ ] Loading skeleton + +### Security +- [ ] Improved layer cards with consistent padding +- [ ] `Badge` for status indicators +- [ ] Better disabled state styling +- [ ] `Alert` for Cerberus disabled message +- [ ] Consistent button variants + +### Settings +- [ ] `Tabs` component for navigation +- [ ] Form field consistency +- [ ] `Alert` for validation +- [ ] Success toast styling + +### AccessLists +- [ ] `DataTable` with selection +- [ ] `Dialog` for confirmations +- [ ] `Alert` for CGNAT warning +- [ ] `Badge` for ACL types +- [ ] `EmptyState` when none exist + +### Certificates +- [ ] `DataTable` for certificate list +- [ ] `Badge` for status (valid/expiring/expired) +- [ ] `Dialog` for upload form +- [ ] Improved certificate details view + +### Logs +- [ ] Improved filter styling +- [ ] `Badge` for log levels +- [ ] Better table density +- [ ] Skeleton during load + +### Backups +- [ ] `DataTable` for backup list +- [ ] `Dialog` for restore confirmation +- [ ] `Badge` for backup type +- [ ] `EmptyState` when none exist + +--- + +## 7. Testing Requirements + +### Unit Tests +Each new component needs: +- Render test (renders without crashing) +- Variant tests (all variants render correctly) +- Interaction tests (onClick, onChange work) +- Accessibility tests (aria labels, keyboard nav) + +### Integration Tests +- Dark/light mode toggle persists +- Page navigation maintains theme +- Forms submit correctly with new components +- Modals open/close properly + +### Visual Regression +- Screenshot comparison for: + - Dashboard (light + dark) + - ProxyHosts table (empty + populated) + - Security dashboard (enabled + disabled) + - Settings tabs + +### Accessibility +- WCAG 2.1 AA compliance +- Keyboard navigation throughout +- Focus visible on all interactive elements +- Screen reader compatibility + +--- + +## 8. Migration Strategy + +### Backward Compatibility +1. Keep legacy color tokens (`dark-bg`, `dark-card`, etc.) during transition +2. Gradually replace hardcoded colors with semantic tokens +3. Use `cn()` utility for all className merging +4. Create new components alongside existing, migrate pages incrementally + +### Rollout Order +1. **Token system** - No visual change, foundation only +2. **New components** - Available but not used +3. **Dashboard** - High visibility, validates approach +4. **ProxyHosts** - Most complex, proves scalability +5. **Remaining pages** - Systematic cleanup + +### Deprecation Path +After all pages migrated: +1. Remove legacy color tokens from tailwind.config.js +2. Remove inline modal patterns +3. Remove ad-hoc button styling +4. Clean up unused CSS + +--- + +## 9. Success Metrics + +| Metric | Current | Target | +|--------|---------|--------| +| Unique color values in CSS | 50+ hardcoded | <20 via tokens | +| Component reuse | ~20% | >80% | +| Inline styles | Prevalent | Eliminated | +| Accessibility score (Lighthouse) | Unknown | 90+ | +| Dark/light mode support | Partial | Complete | +| Loading states coverage | ~30% | 100% | +| Empty states coverage | ~50% | 100% | + +--- + +## 10. Open Questions / Decisions Needed + +1. **Font loading strategy**: Should we self-host Inter/JetBrains Mono or use CDN? +2. **Animation library**: Use Framer Motion for complex animations or keep CSS-only? +3. **Form library integration**: Deeper react-hook-form integration with new Input components? +4. **Icon library**: Stick with lucide-react or consider alternatives? +5. **Radix UI scope**: All primitives or selective use for accessibility-critical components? + +--- + +## Appendix A: File Change Summary + +### New Files (23) +``` +frontend/src/components/ui/Badge.tsx +frontend/src/components/ui/Alert.tsx +frontend/src/components/ui/Dialog.tsx +frontend/src/components/ui/Select.tsx +frontend/src/components/ui/Tabs.tsx +frontend/src/components/ui/Tooltip.tsx +frontend/src/components/ui/Skeleton.tsx +frontend/src/components/ui/Progress.tsx +frontend/src/components/ui/Checkbox.tsx +frontend/src/components/ui/Label.tsx +frontend/src/components/ui/Textarea.tsx +frontend/src/components/ui/StatsCard.tsx +frontend/src/components/ui/EmptyState.tsx +frontend/src/components/ui/DataTable.tsx +frontend/src/components/ui/index.ts +frontend/src/components/layout/PageShell.tsx +frontend/src/components/ui/__tests__/Badge.test.tsx +frontend/src/components/ui/__tests__/Alert.test.tsx +frontend/src/components/ui/__tests__/Dialog.test.tsx +frontend/src/components/ui/__tests__/Skeleton.test.tsx +frontend/src/components/ui/__tests__/DataTable.test.tsx +frontend/src/components/ui/__tests__/EmptyState.test.tsx +frontend/src/components/ui/__tests__/StatsCard.test.tsx +``` + +### Modified Files (20+) +``` +frontend/src/index.css +frontend/tailwind.config.js +frontend/package.json +frontend/src/components/ui/Button.tsx +frontend/src/components/ui/Card.tsx +frontend/src/components/ui/Input.tsx +frontend/src/components/ui/Switch.tsx +frontend/src/components/Layout.tsx +frontend/src/pages/Dashboard.tsx +frontend/src/pages/ProxyHosts.tsx +frontend/src/pages/Security.tsx +frontend/src/pages/Settings.tsx +frontend/src/pages/AccessLists.tsx +frontend/src/pages/Certificates.tsx +frontend/src/pages/RemoteServers.tsx +frontend/src/pages/Logs.tsx +frontend/src/pages/Backups.tsx +frontend/src/pages/SystemSettings.tsx +frontend/src/pages/SMTPSettings.tsx +frontend/src/pages/Account.tsx ``` --- -## 7. SUMMARY TABLE - -| Question | Answer | -|----------|--------| -| What is making the requests? | Charon's Uptime Service (`Go-http-client/2.0`) | -| Should `/` be accessible without auth? | N/A - this is hitting proxied backends, not Charon | -| Is there a dedicated health endpoint? | Yes: `/api/v1/health` (public, returns 200) | -| Is Docker health check working? | ✅ Yes, every 30s, returns 200 | -| Are the 401s a bug? | ❌ No, they're expected from auth-protected backends | -| What's the fix? | None needed - working as designed | - ---- - -## 8. CONCLUSION - -**The 401s are NOT from Docker health checks or Charon auth failures.** - -They are normal responses from **auth-protected backend services** (like Plex) being monitored by Charon's uptime service. The uptime service correctly interprets 401/403 as "service is up but requires authentication." - -**No fix required.** The system is working as designed. +*Plan created: December 16, 2025* +*Estimated completion: 7 weeks* +*Issue reference: GitHub #409* diff --git a/docs/plans/prev_spec_websocket_fix_dec16.md b/docs/plans/prev_spec_websocket_fix_dec16.md new file mode 100644 index 00000000..f175d969 --- /dev/null +++ b/docs/plans/prev_spec_websocket_fix_dec16.md @@ -0,0 +1,467 @@ +# Security Dashboard Live Logs - Complete Trace Analysis + +**Date:** December 16, 2025 +**Status:** ✅ ALL ISSUES FIXED & VERIFIED +**Severity:** Was Critical (WebSocket reconnection loop) → Now Resolved + +--- + +## 0. FULL TRACE ANALYSIS + +### File-by-File Data Flow + +| Step | File | Lines | Purpose | Status | +|------|------|-------|---------|--------| +| 1 | `frontend/src/pages/Security.tsx` | 36, 421 | Renders LiveLogViewer with memoized filters | ✅ Fixed | +| 2 | `frontend/src/components/LiveLogViewer.tsx` | 138-143, 183-268 | Manages WebSocket lifecycle in useEffect | ✅ Fixed | +| 3 | `frontend/src/api/logs.ts` | 177-237 | `connectSecurityLogs()` - builds WS URL with auth | ✅ Working | +| 4 | `backend/internal/api/routes/routes.go` | 373-394 | Registers `/cerberus/logs/ws` in protected group | ✅ Working | +| 5 | `backend/internal/api/middleware/auth.go` | 12-39 | Validates JWT from header/cookie/query param | ✅ Working | +| 6 | `backend/internal/api/handlers/cerberus_logs_ws.go` | 27-120 | WebSocket handler with filter parsing | ✅ Working | +| 7 | `backend/internal/services/log_watcher.go` | 44-237 | Tails Caddy access log, broadcasts to subscribers | ✅ Working | + +### Authentication Flow + +```text +Frontend Backend +──────── ─────── +localStorage.getItem('charon_auth_token') + │ + ▼ +Query param: ?token= ────────► AuthMiddleware: + 1. Check Authorization header + 2. Check auth_token cookie + 3. Check token query param ◄── MATCHES + │ + ▼ + ValidateToken(jwt) → OK + │ + ▼ + Upgrade to WebSocket +``` + +### Logic Gap Analysis + +**ANSWER: NO - There is NO logic gap between Frontend and Backend.** + +| Question | Answer | +|----------|--------| +| Frontend auth method | Query param `?token=` from `localStorage.getItem('charon_auth_token')` | +| Backend auth method | Accepts: Header → Cookie → Query param `token` ✅ | +| Filter params | Both use `source`, `level`, `ip`, `host`, `blocked_only` ✅ | +| Data format | `SecurityLogEntry` struct matches frontend TypeScript type ✅ | + +--- + +## 1. VERIFICATION STATUS + +### ✅ localStorage Key IS Correct + +Both WebSocket functions in `frontend/src/api/logs.ts` correctly use `charon_auth_token`: + +- **Line 119-122** (`connectLiveLogs`): `localStorage.getItem('charon_auth_token')` +- **Line 178-181** (`connectSecurityLogs`): `localStorage.getItem('charon_auth_token')` + +--- + +## 2. ALL ISSUES FOUND (NOW FIXED) + +### Issue #1: CRITICAL - Object Reference Instability in Props (ROOT CAUSE) ✅ FIXED + +**Problem:** `Security.tsx` passed `securityFilters={{}}` inline, creating a new object on every render. This triggered useEffect cleanup/reconnection on every parent re-render. + +**Fix Applied:** + +```tsx +// frontend/src/pages/Security.tsx line 36 +const emptySecurityFilters = useMemo(() => ({}), []) + +// frontend/src/pages/Security.tsx line 421 + +``` + +### Issue #2: Default Props Had Same Problem ✅ FIXED + +**Problem:** Default empty objects `filters = {}` in function params created new objects on each call. + +**Fix Applied:** + +```typescript +// frontend/src/components/LiveLogViewer.tsx lines 138-143 +const EMPTY_LIVE_FILTER: LiveLogFilter = {}; +const EMPTY_SECURITY_FILTER: SecurityLogFilter = {}; + +export function LiveLogViewer({ + filters = EMPTY_LIVE_FILTER, + securityFilters = EMPTY_SECURITY_FILTER, + // ... +}) +``` + +### Issue #3: `showBlockedOnly` Toggle (INTENTIONAL) + +The `showBlockedOnly` state in useEffect dependencies causes reconnection when toggled. This is **intentional** for server-side filtering - not a bug. + +--- + +## 3. ROOT CAUSE ANALYSIS + +### The Reconnection Loop (Before Fix) + +1. User navigates to Security Dashboard +2. `Security.tsx` renders with `` +3. `LiveLogViewer` mounts → useEffect runs → WebSocket connects +4. React Query refetches security status +5. `Security.tsx` re-renders → **new `{}` object created** +6. `LiveLogViewer` re-renders → useEffect sees "changed" `securityFilters` +7. useEffect cleanup runs → **WebSocket closes** +8. useEffect body runs → **WebSocket opens** +9. Repeat steps 4-8 every ~100ms + +### Evidence from Docker Logs (Before Fix) + +```text +{"level":"info","msg":"Cerberus logs WebSocket connected","subscriber_id":"xxx"} +{"level":"info","msg":"Cerberus logs WebSocket client disconnected","subscriber_id":"xxx"} +{"level":"info","msg":"Cerberus logs WebSocket connected","subscriber_id":"yyy"} +{"level":"info","msg":"Cerberus logs WebSocket client disconnected","subscriber_id":"yyy"} +``` + +--- + +## 4. COMPONENT DEEP DIVE + +### Frontend: Security.tsx + +- Renders the Security Dashboard with 4 security layer cards (CrowdSec, ACL, Coraza, Rate Limiting) +- Contains multiple `useQuery`/`useMutation` hooks that trigger re-renders +- **Line 36:** Creates stable filter reference with `useMemo` +- **Line 421:** Passes stable reference to `LiveLogViewer` + +### Frontend: LiveLogViewer.tsx + +- Dual-mode log viewer (application logs vs security logs) +- **Lines 138-139:** Stable default filter objects defined outside component +- **Lines 183-268:** useEffect that manages WebSocket lifecycle +- **Line 268:** Dependencies: `[currentMode, filters, securityFilters, maxLogs, showBlockedOnly]` +- Uses `isPausedRef` to avoid reconnection when pausing + +### Frontend: logs.ts (API Client) + +- **`connectSecurityLogs()`** (lines 177-237): + - Builds URLSearchParams from filter object + - Gets auth token from `localStorage.getItem('charon_auth_token')` + - Appends token as query param + - Constructs URL: `wss://host/api/v1/cerberus/logs/ws?...&token=` + +### Backend: routes.go + +- **Line 380-389:** Creates LogWatcher service pointing to `/var/log/caddy/access.log` +- **Line 393:** Creates `CerberusLogsHandler` +- **Line 394:** Registers route in protected group (auth required) + +### Backend: auth.go (Middleware) + +- **Lines 14-28:** Auth flow: Header → Cookie → Query param +- **Line 25-28:** Query param fallback: `if token := c.Query("token"); token != ""` +- WebSocket connections use query param auth (browsers can't set headers on WS) + +### Backend: cerberus_logs_ws.go (Handler) + +- **Lines 42-48:** Upgrades HTTP to WebSocket +- **Lines 53-59:** Parses filter query params +- **Lines 61-62:** Subscribes to LogWatcher +- **Lines 80-109:** Main loop broadcasting filtered entries + +### Backend: log_watcher.go (Service) + +- Singleton service tailing Caddy access log +- Parses JSON log lines into `SecurityLogEntry` +- Broadcasts to all WebSocket subscribers +- Detects security events (WAF, CrowdSec, ACL, rate limit) + +--- + +## 5. SUMMARY TABLE + +| Component | Status | Notes | +|-----------|--------|-------| +| localStorage key | ✅ Fixed | Now uses `charon_auth_token` | +| Auth middleware | ✅ Working | Accepts query param `token` | +| WebSocket endpoint | ✅ Working | Protected route, upgrades correctly | +| LogWatcher service | ✅ Working | Tails access.log successfully | +| **Frontend memoization** | ✅ Fixed | `useMemo` in Security.tsx | +| **Stable default props** | ✅ Fixed | Constants in LiveLogViewer.tsx | + +--- + +## 6. VERIFICATION STEPS + +After any changes, verify with: + +```bash +# 1. Rebuild and restart +docker build -t charon:local . && docker compose -f docker-compose.override.yml up -d + +# 2. Check for stable connection (should see ONE connect, no rapid cycling) +docker logs charon 2>&1 | grep -i "cerberus.*websocket" | tail -10 + +# 3. Browser DevTools → Console +# Should see: "Cerberus logs WebSocket connection established" +# Should NOT see repeated connection attempts +``` + +--- + +## 7. CONCLUSION + +**Root Cause:** React reference instability (`{}` creates new object on every render) + +**Solution Applied:** Memoize filter objects to maintain stable references + +**Logic Gap Between Frontend/Backend:** **NO** - Both are correctly aligned + +**Current Status:** ✅ All fixes applied and working + +--- + +# Health Check 401 Auth Failures - Investigation Report + +**Date:** December 16, 2025 +**Status:** ✅ ANALYZED - NOT A BUG +**Severity:** Informational (Log Noise) + +--- + +## 1. INVESTIGATION SUMMARY + +### What the User Observed + +The user reported recurring 401 auth failures in Docker logs: +``` +01:03:10 AUTH 172.20.0.1 GET / → 401 [401] 133.6ms +{ "auth_failure": true } +01:04:10 AUTH 172.20.0.1 GET / → 401 [401] 112.9ms +{ "auth_failure": true } +``` + +### Initial Hypothesis vs Reality + +| Hypothesis | Reality | +|------------|---------| +| Docker health check hitting `/` | ❌ Docker health check hits `/api/v1/health` and works correctly (200) | +| Charon backend auth issue | ❌ Charon backend auth is working fine | +| Missing health endpoint | ❌ `/api/v1/health` exists and is public | + +--- + +## 2. ROOT CAUSE IDENTIFIED + +### The 401s are FROM Plex, NOT Charon + +**Evidence from logs:** + +```json +{ + "host": "plex.hatfieldhosted.com", + "uri": "/", + "status": 401, + "resp_headers": { + "X-Plex-Protocol": ["1.0"], + "X-Plex-Content-Compressed-Length": ["157"], + "Cache-Control": ["no-cache"] + } +} +``` + +The 401 responses contain **Plex-specific headers** (`X-Plex-Protocol`, `X-Plex-Content-Compressed-Length`). This proves: + +1. The request goes through Caddy to **Plex backend** +2. **Plex** returns 401 because the request has no auth token +3. Caddy logs this as a handled request + +### What's Making These Requests? + +**Charon's Uptime Monitoring Service** (`backend/internal/services/uptime_service.go`) + +The `checkMonitor()` function performs HTTP GET requests to proxied hosts: + +```go +case "http", "https": + client := http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(monitor.URL) // e.g., https://plex.hatfieldhosted.com/ +``` + +Key behaviors: +- Runs every 60 seconds (`interval: 60`) +- Checks the **public URL** of each proxy host +- Uses `Go-http-client/2.0` User-Agent (visible in logs) +- **Correctly treats 401/403 as "service is up"** (lines 471-474 of uptime_service.go) + +--- + +## 3. ARCHITECTURE FLOW + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Charon Container (172.20.0.1 from Docker's perspective) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ │ +│ │ Uptime Service │ │ +│ │ (Go-http-client/2.0)│ │ +│ └──────────┬──────────┘ │ +│ │ GET https://plex.hatfieldhosted.com/ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ Caddy Reverse Proxy │ │ +│ │ (ports 80/443) │ │ +│ └──────────┬──────────┘ │ +│ │ Logs request to access.log │ +└─────────────┼───────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Plex Container (172.20.0.x) │ +├─────────────────────────────────────────────────────────────┤ +│ GET / → 401 Unauthorized (no X-Plex-Token) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. DOCKER HEALTH CHECK STATUS + +### ✅ Docker Health Check is WORKING CORRECTLY + +**Configuration** (from all docker-compose files): + +```yaml +healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s +``` + +**Evidence:** + +``` +[GIN] 2025/12/16 - 01:04:45 | 200 | 304.212µs | ::1 | GET "/api/v1/health" +``` + +- Hits `/api/v1/health` (not `/`) +- Returns `200` (not `401`) +- Source IP is `::1` (localhost) +- Interval is 30s (matches config) + +### Health Endpoint Details + +**Route Registration** ([routes.go#L86](backend/internal/api/routes/routes.go#L86)): + +```go +router.GET("/api/v1/health", handlers.HealthHandler) +``` + +This is registered **before** any auth middleware, making it a public endpoint. + +**Handler Response** ([health_handler.go#L29-L37](backend/internal/api/handlers/health_handler.go#L29-L37)): + +```go +func HealthHandler(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "service": version.Name, + "version": version.Version, + "git_commit": version.GitCommit, + "build_time": version.BuildTime, + "internal_ip": getLocalIP(), + }) +} +``` + +--- + +## 5. WHY THIS IS NOT A BUG + +### Uptime Service Design is Correct + +From [uptime_service.go#L471-L474](backend/internal/services/uptime_service.go#L471-L474): + +```go +// Accept 2xx, 3xx, and 401/403 (Unauthorized/Forbidden often means the service is up but protected) +if (resp.StatusCode >= 200 && resp.StatusCode < 400) || resp.StatusCode == 401 || resp.StatusCode == 403 { + success = true + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) +} +``` + +**Rationale:** A 401 response proves: +- The service is running +- The network path is functional +- The application is responding + +This is industry-standard practice for uptime monitoring of auth-protected services. + +--- + +## 6. RECOMMENDATIONS + +### Option A: Do Nothing (Recommended) + +The current behavior is correct: +- Docker health checks work ✅ +- Uptime monitoring works ✅ +- Plex is correctly marked as "up" despite 401 ✅ + +The 401s in Caddy access logs are informational noise, not errors. + +### Option B: Reduce Log Verbosity (Optional) + +If the log noise is undesirable, options include: + +1. **Configure Caddy to not log uptime checks:** + Add a log filter for `Go-http-client` User-Agent + +2. **Use backend health endpoints:** + Some services like Plex have health endpoints (`/identity`, `/status`) that don't require auth + +3. **Add per-monitor health path option:** + Extend `UptimeMonitor` model to allow custom health check paths + +### Option C: Already Implemented + +The Uptime Service already logs status changes only, not every check: + +```go +if statusChanged { + logger.Log().WithFields(map[string]interface{}{ + "host_name": host.Name, + // ... + }).Info("Host status changed") +} +``` + +--- + +## 7. SUMMARY TABLE + +| Question | Answer | +|----------|--------| +| What is making the requests? | Charon's Uptime Service (`Go-http-client/2.0`) | +| Should `/` be accessible without auth? | N/A - this is hitting proxied backends, not Charon | +| Is there a dedicated health endpoint? | Yes: `/api/v1/health` (public, returns 200) | +| Is Docker health check working? | ✅ Yes, every 30s, returns 200 | +| Are the 401s a bug? | ❌ No, they're expected from auth-protected backends | +| What's the fix? | None needed - working as designed | + +--- + +## 8. CONCLUSION + +**The 401s are NOT from Docker health checks or Charon auth failures.** + +They are normal responses from **auth-protected backend services** (like Plex) being monitored by Charon's uptime service. The uptime service correctly interprets 401/403 as "service is up but requires authentication." + +**No fix required.** The system is working as designed.