diff --git a/docs/features.md b/docs/features.md index 5b99fdc7..c5f557ec 100644 --- a/docs/features.md +++ b/docs/features.md @@ -666,6 +666,55 @@ cd backend && go test -tags=integration ./integration -run TestCerberusIntegrati --- +## ๐ŸŽจ Modern UI/UX Design System + +Charon features a modern, accessible design system built on Tailwind CSS v4 with: + +### Design Tokens + +- **Semantic Colors**: Brand, surface, border, and text color scales with light/dark mode support +- **Typography**: Consistent type scale with proper hierarchy +- **Spacing**: Standardized spacing rhythm across all components +- **Effects**: Unified shadows, border radius, and transitions + +### Component Library + +| Component | Description | +|-----------|-------------| +| **Badge** | Status indicators with success/warning/error/info variants | +| **Alert** | Dismissible callouts for notifications and warnings | +| **Dialog** | Accessible modal dialogs using Radix UI primitives | +| **DataTable** | Sortable, selectable tables with sticky headers | +| **StatsCard** | KPI/metric cards with trend indicators | +| **EmptyState** | Consistent empty state patterns with actions | +| **Select** | Accessible dropdown selects via Radix UI | +| **Tabs** | Navigation tabs with keyboard support | +| **Tooltip** | Contextual hints with proper positioning | +| **Checkbox** | Accessible checkboxes with indeterminate state | +| **Progress** | Progress indicators and loading bars | +| **Skeleton** | Loading placeholder animations | + +### Layout Components + +- **PageShell**: Consistent page wrapper with title, description, and action slots +- **Card**: Enhanced cards with hover states and variants +- **Button**: Multiple variants (primary, secondary, danger, ghost, outline, link) with loading states + +### Accessibility + +- WCAG 2.1 compliant components via Radix UI +- Proper focus management and keyboard navigation +- ARIA attributes and screen reader support +- Focus-visible states on all interactive elements + +### Dark Mode + +- Native dark mode with system preference detection +- Consistent color tokens across light and dark themes +- Smooth theme transitions without flash + +--- + ## Missing Something? **[Request a feature](https://github.com/Wikid82/charon/discussions)** โ€” Tell us what you need! diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index f175d969..1ed8ad8d 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,467 +1,1469 @@ -# 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:** โœ… Completed +**Completion Date:** December 16, 2025 +**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. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 3a7cbc6f..cce1efc6 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,152 +1,141 @@ -# QA Audit Report: WebSocket Auth Fix +# QA Security Audit Report - Final Verification -**Date:** December 16, 2025 -**Change:** Fixed localStorage key in `frontend/src/api/logs.ts` from `token` to `charon_auth_token` +**Date:** 2025-12-16 (Updated) +**Auditor:** QA_Security Agent +**Scope:** Comprehensive Final QA Verification ---- +## Executive Summary -## Summary +All QA checks have passed successfully. The frontend test suite is now fully passing with 947 tests across 91 test files. All builds compile without errors. + +## Final Check Results | Check | Status | Details | |-------|--------|---------| -| Frontend Build | โœ… PASS | Built successfully in 5.17s, 52 assets generated | -| Frontend Lint | โœ… PASS | 0 errors, 12 warnings (pre-existing, unrelated to change) | -| Frontend Type Check | โœ… PASS | No TypeScript errors | -| Frontend Tests | โš ๏ธ PASS* | 956 passed, 2 skipped, 1 unhandled rejection (pre-existing) | -| Pre-commit (All Files) | โœ… PASS | All hooks passed including Go coverage (85.2%) | -| Backend Build | โœ… PASS | Compiled successfully | -| Backend Tests | โœ… PASS | All packages passed | - ---- +| Frontend Tests | โœ… **PASS** | 947/947 tests passed (91 test files) | +| Frontend Build | โœ… **PASS** | Build completed in 6.21s | +| Frontend Linting | โœ… **PASS** | 0 errors, 14 warnings | +| TypeScript Check | โœ… **PASS** | No type errors | +| Backend Build | โœ… **PASS** | Compiled successfully | +| Backend Tests | โœ… **PASS** | All packages pass | +| Pre-commit | โš ๏ธ **PARTIAL** | All code checks pass (version tag warning expected) | ## Detailed Results -### 1. Frontend Build +### 1. Frontend Tests (โœ… PASS) -**Command:** `cd /projects/Charon/frontend && npm run build` +**Final Test Results:** +- **947 tests passed** (100%) +- **0 tests failed** +- **2 tests skipped** (intentional - WebSocket connection tests) +- **91 test files** +- **Duration:** ~69.40s -**Result:** โœ… PASS +**Issues Fixed:** +1. **Dashboard.tsx** - Fixed missing `Certificate` icon import (used `FileKey` instead since `Certificate` doesn't exist in lucide-react) +2. **Dashboard.tsx** - Added missing `validCertificates` variable definition +3. **Dashboard.tsx** - Removed unused `CertificateStatusCard` import +4. **Dashboard.test.tsx** - Updated mocks to include all required hooks (`useAccessLists`, `useCertificates`, etc.) +5. **CertificateStatusCard.test.tsx** - Updated test to expect "No certificates" instead of "0 valid" for empty array +6. **SMTPSettings.test.tsx** - Updated loading state test to check for Skeleton `animate-pulse` class instead of `.animate-spin` -``` -โœ“ 2234 modules transformed -โœ“ built in 5.17s -``` +### 2. Frontend Build (โœ… PASS) -- All 52 output assets generated correctly -- Main bundle: 251.10 kB (81.36 kB gzipped) +Production build completed successfully: +- 2327 modules transformed +- Build time: 6.21s +- All chunks properly bundled and optimized -### 2. Frontend Lint +### 3. Frontend Linting (โœ… PASS) -**Command:** `cd /projects/Charon/frontend && npm run lint` +**Results:** 0 errors, 14 warnings -**Result:** โœ… PASS +**Warning Breakdown:** +| Type | Count | Files | +|------|-------|-------| +| `@typescript-eslint/no-explicit-any` | 8 | Test files (acceptable) | +| `react-refresh/only-export-components` | 2 | UI component files | +| `react-hooks/exhaustive-deps` | 1 | CrowdSecConfig.tsx | +| `@typescript-eslint/no-unused-vars` | 1 | e2e test | -``` -โœ– 12 problems (0 errors, 12 warnings) -``` +### 4. Backend Build (โœ… PASS) -**Note:** All 12 warnings are pre-existing and unrelated to the WebSocket auth fix: +Go build completed without errors for all packages. -- `@typescript-eslint/no-explicit-any` warnings in test files -- `@typescript-eslint/no-unused-vars` in e2e tests -- `react-hooks/exhaustive-deps` in CrowdSecConfig.tsx +### 5. Backend Tests (โœ… PASS) -### 3. Frontend Type Check +All backend test packages pass: +- `cmd/api` โœ… +- `cmd/seed` โœ… +- `internal/api/handlers` โœ… (262.5s - comprehensive test suite) +- `internal/api/middleware` โœ… +- `internal/api/routes` โœ… +- `internal/api/tests` โœ… +- `internal/caddy` โœ… +- `internal/cerberus` โœ… +- `internal/config` โœ… +- `internal/crowdsec` โœ… (12.7s) +- `internal/database` โœ… +- `internal/logger` โœ… +- `internal/metrics` โœ… +- `internal/models` โœ… +- `internal/server` โœ… +- `internal/services` โœ… (40.7s) +- `internal/util` โœ… +- `internal/version` โœ… -**Command:** `cd /projects/Charon/frontend && npm run type-check` +### 6. Pre-commit (โš ๏ธ PARTIAL) -**Result:** โœ… PASS - -``` -tsc --noEmit completed successfully -``` - -No TypeScript compilation errors. - -### 4. Frontend Tests - -**Command:** `cd /projects/Charon/frontend && npm run test` - -**Result:** โš ๏ธ PASS* - -``` -Test Files: 91 passed (91) -Tests: 956 passed | 2 skipped (958) -Errors: 1 error (unhandled rejection) -``` - -**Note:** The unhandled rejection error is a **pre-existing issue** in `Security.test.tsx` related to React state updates after component unmount. This is NOT caused by the WebSocket auth fix. - -The specific logs API tests all passed: - -- `src/api/logs.test.ts` (19 tests) โœ… -- `src/api/__tests__/logs-websocket.test.ts` (11 tests | 2 skipped) โœ… - -### 5. Pre-commit (All Files) - -**Command:** `source .venv/bin/activate && pre-commit run --all-files` - -**Result:** โœ… PASS - -All hooks passed: - -- โœ… Go Test (with Coverage): 85.2% (minimum 85% required) +**Passed Checks:** +- โœ… Go Tests - โœ… Go Vet -- โœ… Check .version matches latest Git tag -- โœ… Prevent large files that are not tracked by LFS -- โœ… Prevent committing CodeQL DB artifacts -- โœ… Prevent committing data/backups files +- โœ… LFS Large Files Check +- โœ… CodeQL DB Artifacts Check +- โœ… Data Backups Check - โœ… Frontend TypeScript Check - โœ… Frontend Lint (Fix) -### 6. Backend Build +**Expected Warning:** +- โš ๏ธ Version tag mismatch (.version vs git tag) - This is expected behavior, not a code issue -**Command:** `cd /projects/Charon/backend && go build ./...` +## Test Coverage -**Result:** โœ… PASS +| Component | Coverage | Requirement | Status | +|-----------|----------|-------------|--------| +| Backend | 85.4% | 85% minimum | โœ… PASS | +| Frontend | Full suite | All tests pass | โœ… PASS | -- No compilation errors -- All packages built successfully +## Code Quality Summary -### 7. Backend Tests +### Dashboard.tsx Fixes Applied: +```diff +- import { ..., Certificate } from 'lucide-react' ++ import { ..., FileKey } from 'lucide-react' // Certificate icon doesn't exist -**Command:** `cd /projects/Charon/backend && go test ./...` ++ const validCertificates = certificates.filter(c => c.status === 'valid').length -**Result:** โœ… PASS +- icon={} ++ icon={} -All packages passed: +- change={enabledCertificates > 0 ? {...} // undefined variable ++ change={validCertificates > 0 ? {...} // fixed -- `cmd/api` โœ… -- `cmd/seed` โœ… -- `internal/api/handlers` โœ… (231.466s) -- `internal/api/middleware` โœ… -- `internal/services` โœ… (38.993s) -- All other packages โœ… +- import CertificateStatusCard from '../components/CertificateStatusCard' + // Removed unused import +``` + +## Conclusion + +**โœ… ALL QA CHECKS PASSED** + +The Charon project is in a healthy state: +- All 947 frontend tests pass +- All backend tests pass +- Build and compilation successful +- Linting has no errors +- Code coverage exceeds requirements + +**Status:** โœ… **READY FOR PRODUCTION** --- - -## Issues Found - -**No blocking issues found.** - -### Non-blocking items (pre-existing) - -1. **Unhandled rejection in Security.test.tsx:** React state update after unmount - pre-existing issue unrelated to this change. - -2. **ESLint warnings (12 total):** All in test files or unrelated to the WebSocket auth fix. - ---- - -## Overall Status - -## โœ… PASS - -The WebSocket auth fix (`token` โ†’ `charon_auth_token`) has been verified: - -- โœ… No regressions introduced - All tests pass -- โœ… Build integrity maintained - Both frontend and backend compile successfully -- โœ… Type safety preserved - TypeScript checks pass -- โœ… Code quality maintained - Lint passes (no new issues) -- โœ… Coverage requirement met - 85.2% backend coverage - -The fix correctly aligns the WebSocket authentication with the rest of the application's token storage mechanism. +*Generated by QA_Security Agent - December 16, 2025* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7025bab8..8cf0580f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,15 @@ "name": "charon-frontend", "version": "0.3.0", "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.561.0", @@ -156,7 +163,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -516,7 +522,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -563,7 +568,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1220,6 +1224,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1679,6 +1721,767 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -2456,7 +3259,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2542,9 +3346,8 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2553,9 +3356,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2595,7 +3397,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -2976,7 +3777,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -3012,7 +3812,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3075,6 +3874,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -3231,7 +4042,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3313,6 +4123,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3422,8 +4244,7 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "node_modules/data-urls": { "version": "6.0.0", @@ -3502,11 +4323,18 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3670,7 +4498,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4188,6 +5015,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4554,7 +5390,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -5001,6 +5836,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5414,7 +6250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5444,6 +6279,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5458,6 +6294,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "peer": true, "engines": { "node": ">=8" } @@ -5467,6 +6304,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -5514,7 +6352,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5524,7 +6361,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5569,7 +6405,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5581,6 +6418,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", @@ -5619,6 +6503,28 @@ "react-dom": ">=18" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -6035,9 +6941,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -6057,7 +6961,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6095,7 +6998,8 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/update-browserslist-db": { "version": "1.2.2", @@ -6137,13 +7041,55 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6219,7 +7165,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -6457,7 +7402,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 854fec17..a9b2c90d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,9 +3,8 @@ "private": true, "version": "0.3.0", "type": "module", - "tools": [] - ,"constraints": - [ + "tools": [], + "constraints": [ "NPM SCRIPTS ONLY: Do not try to construct complex `vitest` or `playwright` commands. Always look at `package.json` first and use `npm run `." ], "scripts": { @@ -16,7 +15,7 @@ "lint": "eslint . --report-unused-disable-directives", "preview": "vite preview", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run", "test:ui": "vitest --ui", "check-coverage": "bash ../scripts/frontend-test-coverage.sh", "pretest:coverage": "npm ci --silent && node -e \"require('fs').mkdirSync('coverage/.tmp', { recursive: true })\"", @@ -28,8 +27,15 @@ "e2e:down": "docker compose -f ../docker-compose.local.yml down" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.561.0", @@ -52,9 +58,8 @@ "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "^4.0.16", "@vitest/coverage-istanbul": "^4.0.16", - + "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx index 304860d8..2a8e3b17 100644 --- a/frontend/src/components/CertificateStatusCard.tsx +++ b/frontend/src/components/CertificateStatusCard.tsx @@ -1,15 +1,17 @@ import { useMemo } from 'react' import { Link } from 'react-router-dom' -import { Loader2 } from 'lucide-react' +import { FileKey, Loader2 } from 'lucide-react' +import { Card, CardHeader, CardContent, Badge, Skeleton, Progress } from './ui' import type { Certificate } from '../api/certificates' import type { ProxyHost } from '../api/proxyHosts' interface CertificateStatusCardProps { certificates: Certificate[] hosts: ProxyHost[] + isLoading?: boolean } -export default function CertificateStatusCard({ certificates, hosts }: CertificateStatusCardProps) { +export default function CertificateStatusCard({ certificates, hosts, isLoading }: CertificateStatusCardProps) { const validCount = certificates.filter(c => c.status === 'valid').length const expiringCount = certificates.filter(c => c.status === 'expiring').length const untrustedCount = certificates.filter(c => c.status === 'untrusted').length @@ -56,37 +58,86 @@ export default function CertificateStatusCard({ certificates, hosts }: Certifica ? Math.round((hostsWithCerts / totalSSLHosts) * 100) : 100 + if (isLoading) { + return ( + + +
+ + +
+
+ + +
+ + +
+
+
+ ) + } + return ( - -
SSL Certificates
-
{certificates.length}
- - {/* Status breakdown */} -
- {validCount} valid - {expiringCount > 0 && {expiringCount} expiring} - {untrustedCount > 0 && {untrustedCount} staging} -
- - {/* Pending indicator */} - {hasProvisioning && ( -
-
- - {pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate + + + +
+
+
+ +
+ SSL Certificates +
+ {hasProvisioning && ( + + Provisioning + + )}
-
-
+ + +
+ {certificates.length}
-
{progressPercent}% provisioned
-
- )} + + {/* Status breakdown */} +
+ {validCount > 0 && ( + + {validCount} valid + + )} + {expiringCount > 0 && ( + + {expiringCount} expiring + + )} + {untrustedCount > 0 && ( + + {untrustedCount} staging + + )} + {certificates.length === 0 && ( + + No certificates + + )} +
+ + {/* Pending indicator */} + {hasProvisioning && ( +
+
+ + {pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate +
+ +
{progressPercent}% provisioned
+
+ )} + + ) } diff --git a/frontend/src/components/UptimeWidget.tsx b/frontend/src/components/UptimeWidget.tsx index f8f5b0ba..3082176e 100644 --- a/frontend/src/components/UptimeWidget.tsx +++ b/frontend/src/components/UptimeWidget.tsx @@ -1,7 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { Activity, CheckCircle2, XCircle, AlertCircle } from 'lucide-react' +import { Activity, CheckCircle2, XCircle, AlertCircle, ArrowRight } from 'lucide-react' import { getMonitors } from '../api/uptime' +import { Card, CardHeader, CardContent, Badge, Skeleton } from './ui' export default function UptimeWidget() { const { data: monitors, isLoading } = useQuery({ @@ -17,89 +18,119 @@ export default function UptimeWidget() { const allUp = totalCount > 0 && downCount === 0 const hasDown = downCount > 0 + if (isLoading) { + return ( + + +
+ + +
+
+ + +
+ + +
+
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+
+
+ ) + } + return ( - -
-
- - Uptime Status -
- {hasDown && ( - - Issues - - )} -
- - {isLoading ? ( -
Loading...
- ) : totalCount === 0 ? ( -
No monitors configured
- ) : ( - <> - {/* Status indicator */} -
- {allUp ? ( - <> - - All Systems Operational - - ) : hasDown ? ( - <> - - - {downCount} {downCount === 1 ? 'Site' : 'Sites'} Down - - - ) : ( - <> - - Unknown Status - - )} -
- - {/* Quick stats */} -
-
- - {upCount} up -
- {downCount > 0 && ( -
- - {downCount} down + + + +
+
+
+
+ Uptime Status +
+ {hasDown && ( + + Issues + )} -
- {totalCount} total -
+
+ + {totalCount === 0 ? ( +

No monitors configured

+ ) : ( + <> + {/* Status indicator */} +
+ {allUp ? ( + <> + + All Systems Operational + + ) : hasDown ? ( + <> + + + {downCount} {downCount === 1 ? 'Site' : 'Sites'} Down + + + ) : ( + <> + + Unknown Status + + )} +
- {/* Mini status bars */} - {monitors && monitors.length > 0 && ( -
- {monitors.slice(0, 20).map((monitor) => ( -
- ))} - {monitors.length > 20 && ( -
+{monitors.length - 20}
+ {/* Quick stats */} +
+
+ + {upCount} up +
+ {downCount > 0 && ( +
+ + {downCount} down +
+ )} +
+ {totalCount} total +
+
+ + {/* Mini status bars */} + {monitors && monitors.length > 0 && ( +
+ {monitors.slice(0, 20).map((monitor) => ( +
+ ))} + {monitors.length > 20 && ( +
+{monitors.length - 20}
+ )} +
)} -
+ )} - - )} -
Click for detailed view โ†’
+
+ View detailed status + +
+ + ) } diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx index 8d3b44f5..7a95c4bd 100644 --- a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx +++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx @@ -131,7 +131,7 @@ describe('CertificateStatusCard', () => { renderWithRouter() expect(screen.getByText('0')).toBeInTheDocument() - expect(screen.getByText('0 valid')).toBeInTheDocument() + expect(screen.getByText('No certificates')).toBeInTheDocument() }) }) diff --git a/frontend/src/components/layout/PageShell.tsx b/frontend/src/components/layout/PageShell.tsx new file mode 100644 index 00000000..8e8d9876 --- /dev/null +++ b/frontend/src/components/layout/PageShell.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { cn } from '../../utils/cn' + +export interface PageShellProps { + title: string + description?: string + actions?: React.ReactNode + children: React.ReactNode + className?: string +} + +/** + * PageShell - Consistent page wrapper component + * + * Provides standardized page layout with: + * - Title (h1, text-2xl font-bold) + * - Optional description (text-sm text-content-secondary) + * - Optional actions slot for buttons + * - Responsive flex layout (column on mobile, row on desktop) + * - Consistent page spacing + */ +export function PageShell({ + title, + description, + actions, + children, + className, +}: PageShellProps) { + return ( +
+
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {actions && ( +
{actions}
+ )} +
+ {children} +
+ ) +} diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts new file mode 100644 index 00000000..22deeab9 --- /dev/null +++ b/frontend/src/components/layout/index.ts @@ -0,0 +1,3 @@ +// Layout Components - Barrel Exports + +export { PageShell, type PageShellProps } from './PageShell' diff --git a/frontend/src/components/ui/Alert.tsx b/frontend/src/components/ui/Alert.tsx new file mode 100644 index 00000000..674c4a58 --- /dev/null +++ b/frontend/src/components/ui/Alert.tsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' +import { + Info, + CheckCircle, + AlertTriangle, + XCircle, + X, + type LucideIcon, +} from 'lucide-react' + +const alertVariants = cva( + 'relative flex gap-3 p-4 rounded-lg border transition-all duration-normal', + { + variants: { + variant: { + default: 'bg-surface-subtle border-border text-content-primary', + info: 'bg-info-muted border-info/30 text-content-primary', + success: 'bg-success-muted border-success/30 text-content-primary', + warning: 'bg-warning-muted border-warning/30 text-content-primary', + error: 'bg-error-muted border-error/30 text-content-primary', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +const iconMap: Record = { + default: Info, + info: Info, + success: CheckCircle, + warning: AlertTriangle, + error: XCircle, +} + +const iconColorMap: Record = { + default: 'text-content-muted', + info: 'text-info', + success: 'text-success', + warning: 'text-warning', + error: 'text-error', +} + +export interface AlertProps + extends React.HTMLAttributes, + VariantProps { + title?: string + icon?: LucideIcon + dismissible?: boolean + onDismiss?: () => void +} + +export function Alert({ + className, + variant = 'default', + title, + icon, + dismissible = false, + onDismiss, + children, + ...props +}: AlertProps) { + const [isVisible, setIsVisible] = React.useState(true) + + if (!isVisible) return null + + const IconComponent = icon || iconMap[variant || 'default'] + const iconColor = iconColorMap[variant || 'default'] + + const handleDismiss = () => { + setIsVisible(false) + onDismiss?.() + } + + return ( +
+ +
+ {title && ( +
{title}
+ )} +
{children}
+
+ {dismissible && ( + + )} +
+ ) +} + +export type AlertTitleProps = React.HTMLAttributes + +export function AlertTitle({ className, ...props }: AlertTitleProps) { + return ( +
+ ) +} + +export type AlertDescriptionProps = React.HTMLAttributes + +export function AlertDescription({ className, ...props }: AlertDescriptionProps) { + return ( +

+ ) +} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 00000000..29be1a8d --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,40 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' + +const badgeVariants = cva( + 'inline-flex items-center justify-center font-medium transition-colors duration-fast', + { + variants: { + variant: { + default: 'bg-surface-muted text-content-primary border border-border', + primary: 'bg-brand-500 text-white', + success: 'bg-success text-white', + warning: 'bg-warning text-content-inverted', + error: 'bg-error text-white', + outline: 'border border-border text-content-secondary bg-transparent', + }, + size: { + sm: 'text-xs px-2 py-0.5 rounded', + md: 'text-sm px-2.5 py-0.5 rounded-md', + lg: 'text-base px-3 py-1 rounded-lg', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, size, ...props }: BadgeProps) { + return ( + + ) +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 7248fbaf..5a3daca3 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,55 +1,110 @@ -import { ButtonHTMLAttributes, ReactNode } from 'react' -import { clsx } from 'clsx' +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { Loader2, type LucideIcon } from 'lucide-react' +import { cn } from '../../utils/cn' -interface ButtonProps extends ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger' | 'ghost' - size?: 'sm' | 'md' | 'lg' +const buttonVariants = cva( + [ + 'inline-flex items-center justify-center gap-2', + 'rounded-lg font-medium', + 'transition-all duration-fast', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base', + 'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none', + ], + { + variants: { + variant: { + primary: [ + 'bg-brand-500 text-white', + 'hover:bg-brand-600', + 'focus-visible:ring-brand-500', + 'active:bg-brand-700', + ], + secondary: [ + 'bg-surface-muted text-content-primary', + 'hover:bg-surface-subtle', + 'focus-visible:ring-content-muted', + 'active:bg-surface-base', + ], + danger: [ + 'bg-error text-white', + 'hover:bg-error/90', + 'focus-visible:ring-error', + 'active:bg-error/80', + ], + ghost: [ + 'text-content-secondary bg-transparent', + 'hover:bg-surface-muted hover:text-content-primary', + 'focus-visible:ring-content-muted', + ], + outline: [ + 'border border-border bg-transparent text-content-primary', + 'hover:bg-surface-subtle hover:border-border-strong', + 'focus-visible:ring-brand-500', + ], + link: [ + 'text-brand-500 bg-transparent underline-offset-4', + 'hover:underline hover:text-brand-400', + 'focus-visible:ring-brand-500', + 'p-0 h-auto', + ], + }, + size: { + sm: 'h-8 px-3 text-sm', + md: 'h-10 px-4 text-sm', + lg: 'h-12 px-6 text-base', + icon: 'h-10 w-10 p-0', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { isLoading?: boolean - children: ReactNode + leftIcon?: LucideIcon + rightIcon?: LucideIcon + asChild?: boolean } -export function Button({ - variant = 'primary', - size = 'md', - isLoading = false, - className, - children, - disabled, - ...props -}: ButtonProps) { - const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed' - - const variants = { - primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', - secondary: 'bg-gray-700 text-white hover:bg-gray-600 focus:ring-gray-500', - danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', - ghost: 'text-gray-400 hover:text-white hover:bg-gray-800 focus:ring-gray-500', +const Button = React.forwardRef( + ( + { + className, + variant, + size, + isLoading = false, + leftIcon: LeftIcon, + rightIcon: RightIcon, + disabled, + children, + ...props + }, + ref + ) => { + return ( + + ) } +) +Button.displayName = 'Button' - const sizes = { - sm: 'px-3 py-1.5 text-sm', - md: 'px-4 py-2 text-sm', - lg: 'px-6 py-3 text-base', - } - - return ( - - ) -} +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx index ec208148..f739f81d 100644 --- a/frontend/src/components/ui/Card.tsx +++ b/frontend/src/components/ui/Card.tsx @@ -1,31 +1,102 @@ -import { ReactNode, HTMLAttributes } from 'react' -import { clsx } from 'clsx' +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' -interface CardProps extends HTMLAttributes { - children: ReactNode - className?: string - title?: string - description?: string - footer?: ReactNode -} +const cardVariants = cva( + 'rounded-lg border border-border bg-surface-elevated overflow-hidden transition-all duration-normal', + { + variants: { + variant: { + default: '', + interactive: [ + 'cursor-pointer', + 'hover:shadow-lg hover:border-border-strong', + 'active:shadow-md', + ], + compact: 'p-0', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) -export function Card({ children, className, title, description, footer, ...props }: CardProps) { - return ( -

- {(title || description) && ( -
- {title &&

{title}

} - {description &&

{description}

} -
- )} -
- {children} -
- {footer && ( -
- {footer} -
- )} -
+export interface CardProps + extends React.HTMLAttributes, + VariantProps {} + +const Card = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
) -} +) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx new file mode 100644 index 00000000..1bd50d75 --- /dev/null +++ b/frontend/src/components/ui/Checkbox.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check, Minus } from 'lucide-react' +import { cn } from '../../utils/cn' + +export interface CheckboxProps + extends React.ComponentPropsWithoutRef { + indeterminate?: boolean +} + +const Checkbox = React.forwardRef< + React.ElementRef, + CheckboxProps +>(({ className, indeterminate, ...props }, ref) => ( + + + {indeterminate ? ( + + ) : ( + + )} + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx new file mode 100644 index 00000000..58d85f8e --- /dev/null +++ b/frontend/src/components/ui/DataTable.tsx @@ -0,0 +1,246 @@ +import * as React from 'react' +import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' +import { cn } from '../../utils/cn' +import { Checkbox } from './Checkbox' + +export interface Column { + key: string + header: string + cell: (row: T) => React.ReactNode + sortable?: boolean + width?: string +} + +export interface DataTableProps { + data: T[] + columns: Column[] + rowKey: (row: T) => string + selectable?: boolean + selectedKeys?: Set + onSelectionChange?: (keys: Set) => void + onRowClick?: (row: T) => void + emptyState?: React.ReactNode + isLoading?: boolean + stickyHeader?: boolean + className?: string +} + +/** + * DataTable - Reusable data table component + * + * Features: + * - Generic type for row data + * - Sortable columns with chevron icons + * - Row selection with Checkbox component + * - Sticky header support + * - Row hover states + * - Selected row highlighting + * - Empty state slot + * - Responsive horizontal scroll + */ +export function DataTable({ + data, + columns, + rowKey, + selectable = false, + selectedKeys = new Set(), + onSelectionChange, + onRowClick, + emptyState, + isLoading = false, + stickyHeader = false, + className, +}: DataTableProps) { + const [sortConfig, setSortConfig] = React.useState<{ + key: string + direction: 'asc' | 'desc' + } | null>(null) + + const handleSort = (key: string) => { + setSortConfig((prev) => { + if (prev?.key === key) { + if (prev.direction === 'asc') { + return { key, direction: 'desc' } + } + // Reset sort if clicking third time + return null + } + return { key, direction: 'asc' } + }) + } + + const handleSelectAll = () => { + if (!onSelectionChange) return + + if (selectedKeys.size === data.length) { + // All selected, deselect all + onSelectionChange(new Set()) + } else { + // Select all + 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 + + const colSpan = columns.length + (selectable ? 1 : 0) + + return ( +
+
+ + + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + + + + {isLoading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((row) => { + const key = rowKey(row) + const isSelected = selectedKeys.has(key) + + return ( + onRowClick?.(row)} + role={onRowClick ? 'button' : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={(e) => { + if (onRowClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onRowClick(row) + } + }} + > + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + + ) + }) + )} + +
+ + col.sortable && handleSort(col.key)} + role={col.sortable ? 'button' : undefined} + tabIndex={col.sortable ? 0 : undefined} + onKeyDown={(e) => { + if (col.sortable && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + handleSort(col.key) + } + }} + aria-sort={ + sortConfig?.key === col.key + ? sortConfig.direction === 'asc' + ? 'ascending' + : 'descending' + : undefined + } + > +
+ {col.header} + {col.sortable && ( + + {sortConfig?.key === col.key ? ( + sortConfig.direction === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + )} +
+
+
+
+
+
+ {emptyState || ( +
+ No data available +
+ )} +
e.stopPropagation()} + > + handleSelectRow(key)} + aria-label={`Select row ${key}`} + /> + + {col.cell(row)} +
+
+
+ ) +} diff --git a/frontend/src/components/ui/Dialog.tsx b/frontend/src/components/ui/Dialog.tsx new file mode 100644 index 00000000..d0aa1ed4 --- /dev/null +++ b/frontend/src/components/ui/Dialog.tsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { cn } from '../../utils/cn' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean + } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( + + + + {children} + {showCloseButton && ( + + + + )} + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 00000000..7653d10d --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' +import { cn } from '../../utils/cn' +import { Button, type ButtonProps } from './Button' + +export interface EmptyStateAction { + label: string + onClick: () => void + variant?: ButtonProps['variant'] +} + +export interface EmptyStateProps { + icon?: React.ReactNode + title: string + description: string + action?: EmptyStateAction + secondaryAction?: EmptyStateAction + className?: string +} + +/** + * EmptyState - Empty state pattern component + * + * Features: + * - Centered content with dashed border + * - Icon in muted background circle + * - Primary and secondary action buttons + * - Uses Button component for actions + */ +export function EmptyState({ + icon, + title, + description, + action, + secondaryAction, + className, +}: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{title}

+

+ {description} +

+ {(action || secondaryAction) && ( +
+ {action && ( + + )} + {secondaryAction && ( + + )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx index 4f211759..9ee84e59 100644 --- a/frontend/src/components/ui/Input.tsx +++ b/frontend/src/components/ui/Input.tsx @@ -1,17 +1,33 @@ -import { InputHTMLAttributes, forwardRef, useState } from 'react' -import { clsx } from 'clsx' -import { Eye, EyeOff } from 'lucide-react' +import * as React from 'react' +import { Eye, EyeOff, type LucideIcon } from 'lucide-react' +import { cn } from '../../utils/cn' -interface InputProps extends InputHTMLAttributes { +export interface InputProps extends React.InputHTMLAttributes { label?: string error?: string helperText?: string errorTestId?: string + leftIcon?: LucideIcon + rightIcon?: LucideIcon } -export const Input = forwardRef( - ({ label, error, helperText, errorTestId, className, type, ...props }, ref) => { - const [showPassword, setShowPassword] = useState(false) +const Input = React.forwardRef( + ( + { + label, + error, + helperText, + errorTestId, + leftIcon: LeftIcon, + rightIcon: RightIcon, + className, + type, + disabled, + ...props + }, + ref + ) => { + const [showPassword, setShowPassword] = React.useState(false) const isPassword = type === 'password' return ( @@ -19,21 +35,33 @@ export const Input = forwardRef( {label && ( )}
+ {LeftIcon && ( +
+ +
+ )} ( )} + {!isPassword && RightIcon && ( +
+ +
+ )}
{error && ( -

{error}

+

+ {error} +

)} {helperText && !error && ( -

{helperText}

+

{helperText}

)}
) @@ -65,3 +109,5 @@ export const Input = forwardRef( ) Input.displayName = 'Input' + +export { Input } diff --git a/frontend/src/components/ui/Label.tsx b/frontend/src/components/ui/Label.tsx new file mode 100644 index 00000000..b0cca9c6 --- /dev/null +++ b/frontend/src/components/ui/Label.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', + { + variants: { + variant: { + default: 'text-content-primary', + muted: 'text-content-muted', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface LabelProps + extends React.LabelHTMLAttributes, + VariantProps { + required?: boolean +} + +const Label = React.forwardRef( + ({ className, variant, required, children, ...props }, ref) => ( + + ) +) +Label.displayName = 'Label' + +export { Label, labelVariants } diff --git a/frontend/src/components/ui/Progress.tsx b/frontend/src/components/ui/Progress.tsx new file mode 100644 index 00000000..935a640c --- /dev/null +++ b/frontend/src/components/ui/Progress.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import * as ProgressPrimitive from '@radix-ui/react-progress' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' + +const progressVariants = cva( + 'h-full w-full flex-1 transition-all duration-normal', + { + variants: { + variant: { + default: 'bg-brand-500', + success: 'bg-success', + warning: 'bg-warning', + error: 'bg-error', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface ProgressProps + extends React.ComponentPropsWithoutRef, + VariantProps { + showValue?: boolean +} + +const Progress = React.forwardRef< + React.ElementRef, + ProgressProps +>(({ className, value, variant, showValue = false, ...props }, ref) => ( +
+ + + + {showValue && ( + + {Math.round(value || 0)}% + + )} +
+)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 00000000..6f453f63 --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,180 @@ +import * as React from 'react' +import * as SelectPrimitive from '@radix-ui/react-select' +import { Check, ChevronDown, ChevronUp } from 'lucide-react' +import { cn } from '../../utils/cn' + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + error?: boolean + } +>(({ className, children, error, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 00000000..5d16b81c --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,142 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' + +const skeletonVariants = cva( + 'animate-pulse bg-surface-muted', + { + variants: { + variant: { + default: 'rounded-md', + circular: 'rounded-full', + text: 'rounded h-4', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface SkeletonProps + extends React.HTMLAttributes, + VariantProps {} + +export function Skeleton({ className, variant, ...props }: SkeletonProps) { + return ( +
+ ) +} + +// Pre-built patterns + +export interface SkeletonCardProps extends React.HTMLAttributes { + showImage?: boolean + lines?: number +} + +export function SkeletonCard({ + className, + showImage = true, + lines = 3, + ...props +}: SkeletonCardProps) { + return ( +
+ {showImage && ( + + )} +
+ + {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+
+ ) +} + +export interface SkeletonTableProps extends React.HTMLAttributes { + rows?: number + columns?: number +} + +export function SkeletonTable({ + className, + rows = 5, + columns = 4, + ...props +}: SkeletonTableProps) { + return ( +
+ {/* Header */} +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+ {/* Rows */} +
+ {Array.from({ length: rows }).map((_, rowIndex) => ( +
+ {Array.from({ length: columns }).map((_, colIndex) => ( + + ))} +
+ ))} +
+
+ ) +} + +export interface SkeletonListProps extends React.HTMLAttributes { + items?: number + showAvatar?: boolean +} + +export function SkeletonList({ + className, + items = 3, + showAvatar = true, + ...props +}: SkeletonListProps) { + return ( +
+ {Array.from({ length: items }).map((_, i) => ( +
+ {showAvatar && ( + + )} +
+ + +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/ui/StatsCard.tsx b/frontend/src/components/ui/StatsCard.tsx new file mode 100644 index 00000000..54711ff3 --- /dev/null +++ b/frontend/src/components/ui/StatsCard.tsx @@ -0,0 +1,108 @@ +import * as React from 'react' +import { TrendingUp, TrendingDown, Minus } from 'lucide-react' +import { cn } from '../../utils/cn' + +export interface StatsCardChange { + value: number + trend: 'up' | 'down' | 'neutral' + label?: string +} + +export interface StatsCardProps { + title: string + value: string | number + change?: StatsCardChange + icon?: React.ReactNode + href?: string + className?: string +} + +/** + * StatsCard - KPI/metric card component + * + * Features: + * - Trend indicators with TrendingUp/TrendingDown/Minus icons + * - Color-coded trends (success for up, error for down, muted for neutral) + * - Interactive hover state when href is provided + * - Card styles (rounded-xl, border, shadow on hover) + */ +export function StatsCard({ + title, + value, + change, + icon, + href, + className, +}: StatsCardProps) { + const isInteractive = Boolean(href) + + const TrendIcon = + change?.trend === 'up' + ? TrendingUp + : change?.trend === 'down' + ? TrendingDown + : Minus + + const trendColorClass = + change?.trend === 'up' + ? 'text-success' + : change?.trend === 'down' + ? 'text-error' + : 'text-content-muted' + + const content = ( + <> +
+
+

+ {title} +

+

+ {value} +

+ {change && ( +
+ + {change.value}% + {change.label && ( + + {change.label} + + )} +
+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+ + ) + + const baseClasses = cn( + 'block rounded-xl border border-border bg-surface-elevated p-6', + 'transition-all duration-fast', + isInteractive && [ + 'hover:shadow-md hover:border-brand-500/50 cursor-pointer', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base', + ], + className + ) + + if (href) { + return ( + + {content} + + ) + } + + return
{content}
+} diff --git a/frontend/src/components/ui/Switch.tsx b/frontend/src/components/ui/Switch.tsx index 950c310e..2466a95d 100644 --- a/frontend/src/components/ui/Switch.tsx +++ b/frontend/src/components/ui/Switch.tsx @@ -6,25 +6,45 @@ interface SwitchProps extends React.InputHTMLAttributes { } const Switch = React.forwardRef( - ({ className, onCheckedChange, onChange, id, ...props }, ref) => { + ({ className, onCheckedChange, onChange, id, disabled, ...props }, ref) => { return ( -