diff --git a/.vscode/tasks.json b/.vscode/tasks.json index dc632115..bbe1a2b1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -258,6 +258,17 @@ "command": "scripts/bump_beta.sh", "group": "none", "problemMatcher": [] + }, + { + "label": "Utility: Database Recovery", + "type": "shell", + "command": "scripts/db-recovery.sh", + "group": "none", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } } ] } diff --git a/backend/internal/api/handlers/uptime_handler.go b/backend/internal/api/handlers/uptime_handler.go index 6e34893c..346ddb66 100644 --- a/backend/internal/api/handlers/uptime_handler.go +++ b/backend/internal/api/handlers/uptime_handler.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" ) @@ -19,6 +20,7 @@ func NewUptimeHandler(service *services.UptimeService) *UptimeHandler { func (h *UptimeHandler) List(c *gin.Context) { monitors, err := h.service.ListMonitors() if err != nil { + logger.Log().WithError(err).Error("Failed to list uptime monitors") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list monitors"}) return } @@ -31,6 +33,7 @@ func (h *UptimeHandler) GetHistory(c *gin.Context) { history, err := h.service.GetMonitorHistory(id, limit) if err != nil { + logger.Log().WithError(err).WithField("monitor_id", id).Error("Failed to get monitor history") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"}) return } @@ -41,12 +44,14 @@ func (h *UptimeHandler) Update(c *gin.Context) { id := c.Param("id") var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { + logger.Log().WithError(err).WithField("monitor_id", id).Warn("Invalid JSON payload for monitor update") c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } monitor, err := h.service.UpdateMonitor(id, updates) if err != nil { + logger.Log().WithError(err).WithField("monitor_id", id).Error("Failed to update monitor") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -56,6 +61,7 @@ func (h *UptimeHandler) Update(c *gin.Context) { func (h *UptimeHandler) Sync(c *gin.Context) { if err := h.service.SyncMonitors(); err != nil { + logger.Log().WithError(err).Error("Failed to sync uptime monitors") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync monitors"}) return } @@ -66,6 +72,7 @@ func (h *UptimeHandler) Sync(c *gin.Context) { func (h *UptimeHandler) Delete(c *gin.Context) { id := c.Param("id") if err := h.service.DeleteMonitor(id); err != nil { + logger.Log().WithError(err).WithField("monitor_id", id).Error("Failed to delete monitor") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete monitor"}) return } @@ -77,6 +84,7 @@ func (h *UptimeHandler) CheckMonitor(c *gin.Context) { id := c.Param("id") monitor, err := h.service.GetMonitorByID(id) if err != nil { + logger.Log().WithError(err).WithField("monitor_id", id).Warn("Monitor not found for check") c.JSON(http.StatusNotFound, gin.H{"error": "Monitor not found"}) return } diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index a02d2fe9..8ce12df6 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/Wikid82/charon/backend/internal/logger" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -43,6 +44,14 @@ func Connect(dbPath string) (*gorm.DB, error) { } configurePool(sqlDB) + // Verify WAL mode is enabled and log confirmation + var journalMode string + if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil { + logger.Log().WithError(err).Warn("Failed to verify SQLite journal mode") + } else { + logger.Log().WithField("journal_mode", journalMode).Info("SQLite database connected with WAL mode enabled") + } + return db, nil } diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go index 0b152f6f..adb4ae9e 100644 --- a/backend/internal/database/database_test.go +++ b/backend/internal/database/database_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestConnect(t *testing.T) { @@ -27,3 +28,30 @@ func TestConnect_Error(t *testing.T) { _, err := Connect(tempDir) assert.Error(t, err) } + +func TestConnect_WALMode(t *testing.T) { + // Create a file-based database to test WAL mode + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "wal_test.db") + + db, err := Connect(dbPath) + require.NoError(t, err) + require.NotNil(t, db) + + // Verify WAL mode is enabled + var journalMode string + err = db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error + require.NoError(t, err) + assert.Equal(t, "wal", journalMode, "SQLite should be in WAL mode") + + // Verify other PRAGMA settings + var busyTimeout int + err = db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error + require.NoError(t, err) + assert.Equal(t, 5000, busyTimeout, "busy_timeout should be 5000ms") + + var synchronous int + err = db.Raw("PRAGMA synchronous").Scan(&synchronous).Error + require.NoError(t, err) + assert.Equal(t, 1, synchronous, "synchronous should be NORMAL (1)") +} diff --git a/docs/database-maintenance.md b/docs/database-maintenance.md new file mode 100644 index 00000000..14cdff90 --- /dev/null +++ b/docs/database-maintenance.md @@ -0,0 +1,322 @@ +# Database Maintenance + +Charon uses SQLite as its embedded database. This guide explains how the database +is configured, how to maintain it, and what to do if something goes wrong. + +--- + +## Overview + +### Why SQLite? + +SQLite is perfect for Charon because: + +- **Zero setup** — No external database server needed +- **Portable** — One file contains everything +- **Reliable** — Used by billions of devices worldwide +- **Fast** — Local file access beats network calls + +### Where Is My Data? + +| Environment | Database Location | +|-------------|-------------------| +| Docker | `/app/data/charon.db` | +| Local dev | `backend/data/charon.db` | + +You may also see these files next to the database: + +- `charon.db-wal` — Write-Ahead Log (temporary transactions) +- `charon.db-shm` — Shared memory file (temporary) + +**Don't delete the WAL or SHM files while Charon is running!** +They contain pending transactions. + +--- + +## Database Configuration + +Charon automatically configures SQLite with optimized settings: + +| Setting | Value | What It Does | +|---------|-------|--------------| +| `journal_mode` | WAL | Enables concurrent reads while writing | +| `busy_timeout` | 5000ms | Waits 5 seconds before failing on lock | +| `synchronous` | NORMAL | Balanced safety and speed | +| `cache_size` | 64MB | Memory cache for faster queries | + +### What Is WAL Mode? + +**WAL (Write-Ahead Logging)** is a more modern journaling mode for SQLite that: + +- ✅ Allows readers while writing (no blocking) +- ✅ Faster for most workloads +- ✅ Reduces disk I/O +- ✅ Safer crash recovery + +Charon enables WAL mode automatically — you don't need to do anything. + +--- + +## Backups + +### Automatic Backups + +Charon creates automatic backups before destructive operations (like deleting hosts). +These are stored in: + +| Environment | Backup Location | +|-------------|-----------------| +| Docker | `/app/data/backups/` | +| Local dev | `backend/data/backups/` | + +### Manual Backups + +To create a manual backup: + +```bash +# Docker +docker exec charon cp /app/data/charon.db /app/data/backups/manual_backup.db + +# Local development +cp backend/data/charon.db backend/data/backups/manual_backup.db +``` + +**Important:** If WAL mode is active, also copy the `-wal` and `-shm` files: + +```bash +cp backend/data/charon.db-wal backend/data/backups/manual_backup.db-wal +cp backend/data/charon.db-shm backend/data/backups/manual_backup.db-shm +``` + +Or use the recovery script which handles this automatically (see below). + +--- + +## Database Recovery + +If your database becomes corrupted (rare, but possible after power loss or +disk failure), Charon includes a recovery script. + +### When to Use Recovery + +Use the recovery script if you see errors like: + +- "database disk image is malformed" +- "database is locked" (persists after restart) +- "SQLITE_CORRUPT" +- Application won't start due to database errors + +### Running the Recovery Script + +**In Docker:** + +```bash +# First, stop Charon to release database locks +docker stop charon + +# Run recovery (from host) +docker run --rm -v charon_data:/app/data charon:latest /app/scripts/db-recovery.sh + +# Restart Charon +docker start charon +``` + +**Local Development:** + +```bash +# Make sure Charon is not running, then: +./scripts/db-recovery.sh +``` + +**Force mode (skip confirmations):** + +```bash +./scripts/db-recovery.sh --force +``` + +### What the Recovery Script Does + +1. **Creates a backup** — Saves your current database before any changes +2. **Runs integrity check** — Uses SQLite's `PRAGMA integrity_check` +3. **If healthy** — Confirms database is OK, enables WAL mode +4. **If corrupted** — Attempts automatic recovery: + - Exports data using SQLite `.dump` command + - Creates a new database from the dump + - Verifies the new database integrity + - Replaces the old database with the recovered one +5. **Cleans up** — Removes old backups (keeps last 10) + +### Recovery Output Example + +**Healthy database:** + +``` +============================================== + Charon Database Recovery Tool +============================================== + +[INFO] sqlite3 found: 3.40.1 +[INFO] Running in Docker environment +[INFO] Database path: /app/data/charon.db +[INFO] Creating backup: /app/data/backups/charon_backup_20250101_120000.db +[SUCCESS] Backup created successfully + +============================================== + Integrity Check Results +============================================== +ok +[SUCCESS] Database integrity check passed! +[INFO] WAL mode already enabled + +============================================== + Summary +============================================== +[SUCCESS] Database is healthy +[INFO] Backup stored at: /app/data/backups/charon_backup_20250101_120000.db +``` + +**Corrupted database (with successful recovery):** + +``` +============================================== + Integrity Check Results +============================================== +*** in database main *** +Page 42: btree page count invalid +[ERROR] Database integrity check FAILED + +WARNING: Database corruption detected! +This script will attempt to recover the database. +A backup has already been created. + +Continue with recovery? (y/N): y + +============================================== + Recovery Process +============================================== +[INFO] Attempting database recovery... +[INFO] Exporting database via .dump command... +[SUCCESS] Database dump created +[INFO] Creating new database from dump... +[SUCCESS] Recovered database created +[SUCCESS] Recovered database passed integrity check +[INFO] Replacing original database with recovered version... +[SUCCESS] Database replaced successfully + +============================================== + Summary +============================================== +[SUCCESS] Database recovery completed successfully! +[INFO] Please restart the Charon application +``` + +--- + +## Preventive Measures + +### Do + +- ✅ **Keep regular backups** — Use the backup page in Charon or manual copies +- ✅ **Use proper shutdown** — Stop Charon gracefully (`docker stop charon`) +- ✅ **Monitor disk space** — SQLite needs space for temporary files +- ✅ **Use reliable storage** — SSDs are more reliable than HDDs + +### Don't + +- ❌ **Don't kill Charon** — Avoid `docker kill` or `kill -9` (use `stop` instead) +- ❌ **Don't edit the database manually** — Unless you know SQLite well +- ❌ **Don't delete WAL files** — While Charon is running +- ❌ **Don't run out of disk space** — Can cause corruption + +--- + +## Troubleshooting + +### "Database is locked" + +**Cause:** Another process has the database open. + +**Fix:** + +1. Stop all Charon instances +2. Check for zombie processes: `ps aux | grep charon` +3. Kill any remaining processes +4. Restart Charon + +### "Database disk image is malformed" + +**Cause:** Database corruption (power loss, disk failure, etc.) + +**Fix:** + +1. Stop Charon +2. Run the recovery script: `./scripts/db-recovery.sh` +3. Restart Charon + +### "SQLITE_BUSY" + +**Cause:** Long-running transaction blocking others. + +**Fix:** Usually resolves itself (5-second timeout). If persistent: + +1. Restart Charon +2. If still occurring, check for stuck processes + +### WAL File Is Very Large + +**Cause:** Many writes without checkpointing. + +**Fix:** This is usually handled automatically. To force a checkpoint: + +```bash +sqlite3 /path/to/charon.db "PRAGMA wal_checkpoint(TRUNCATE);" +``` + +### Lost Data After Recovery + +**What happened:** The `.dump` command recovers readable data, but severely +corrupted records may be lost. + +**What to do:** + +1. Check your automatic backups in `data/backups/` +2. Restore from the most recent pre-corruption backup +3. Re-create any missing configuration manually + +--- + +## Advanced: Manual Recovery + +If the automatic script fails, you can try manual recovery: + +```bash +# 1. Create a SQL dump of whatever is readable +sqlite3 charon.db ".dump" > backup.sql + +# 2. Check what was exported +head -100 backup.sql + +# 3. Create a new database +sqlite3 charon_new.db < backup.sql + +# 4. Verify the new database +sqlite3 charon_new.db "PRAGMA integrity_check;" + +# 5. If OK, replace the old database +mv charon.db charon_corrupted.db +mv charon_new.db charon.db + +# 6. Enable WAL mode on the new database +sqlite3 charon.db "PRAGMA journal_mode=WAL;" +``` + +--- + +## Need Help? + +If recovery fails or you're unsure what to do: + +1. **Don't panic** — Your backup was created before recovery attempts +2. **Check backups** — Look in `data/backups/` for recent copies +3. **Ask for help** — Open an issue on [GitHub](https://github.com/Wikid82/charon/issues) + with your error messages diff --git a/docs/features.md b/docs/features.md index c5f557ec..349bbcc4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -464,7 +464,52 @@ Your uptime history will be preserved. **What you do:** Click "Logs" in the sidebar. --- +## 🗄️ Database Maintenance +**What it does:** Keeps your configuration database healthy and recoverable. + +**Why you care:** Your proxy hosts, SSL certificates, and security settings are stored in a database. Keeping it healthy prevents data loss. + +### Optimized SQLite Configuration + +Charon uses SQLite with performance-optimized settings enabled automatically: + +- **WAL Mode** — Allows reading while writing, faster performance +- **Busy Timeout** — Waits 5 seconds instead of failing immediately on lock +- **Smart Caching** — 64MB memory cache for faster queries + +**What you do:** Nothing—these settings are applied automatically. + +### Database Recovery + +**What it does:** Detects and repairs database corruption. + +**Why you care:** Power outages or disk failures can (rarely) corrupt your database. The recovery script can often fix it. + +**When to use it:** If you see errors like "database disk image is malformed" or Charon won't start. + +**How to run it:** + +```bash +# Docker (stop Charon first) +docker stop charon +docker run --rm -v charon_data:/app/data charon:latest /app/scripts/db-recovery.sh +docker start charon + +# Local development +./scripts/db-recovery.sh +``` + +The script will: + +1. Create a backup of your current database +2. Check database integrity +3. Attempt automatic recovery if corruption is found +4. Keep the last 10 backups automatically + +**Learn more:** See the [Database Maintenance Guide](database-maintenance.md) for detailed documentation. + +--- ## 🔴 Live Security Logs & Notifications **What it does:** Stream security events in real-time and get notified about critical threats. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 1ed8ad8d..dfe97f26 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,1469 +1,335 @@ -# Charon UI/UX Improvement Plan +# Uptime Feature Trace Analysis - Bug Investigation -**Issue:** GitHub #409 - UI Enhancement & Design System -**Date:** December 16, 2025 -**Status:** ✅ Completed -**Completion Date:** December 16, 2025 -**Stack:** React 19 + Vite + TypeScript + TanStack Query + Tailwind CSS v4 +**Issue:** 6 out of 14 proxy hosts show "No History Available" in uptime heartbeat graphs +**Date:** December 17, 2025 +**Status:** 🔴 ROOT CAUSE IDENTIFIED - SQLite Database Corruption --- ## Executive Summary -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: - -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 +**This is NOT a logic bug.** The root cause is **SQLite database corruption** affecting specific records in the `uptime_heartbeats` table. The error `database disk image is malformed` is consistently returned when querying heartbeat history for exactly 6 specific monitor IDs. --- -## 1. Current State Analysis +## 1. Evidence from Container Logs -### 1.1 Tailwind Configuration (tailwind.config.js) +### Error Pattern Observed -**Current:** -```javascript -colors: { - 'light-bg': '#f0f4f8', - 'dark-bg': '#0f172a', - 'dark-sidebar': '#020617', - 'dark-card': '#1e293b', - 'blue-active': '#1d4ed8', - 'blue-hover': '#2563eb', -} +```log +2025/12/17 07:44:04 /app/backend/internal/services/uptime_service.go:877 database disk image is malformed +[8.185ms] [rows:0] SELECT * FROM `uptime_heartbeats` WHERE monitor_id = "2b8cea58-b8f9-43fc-abe0-f6a0baba2351" ORDER BY created_at desc LIMIT 60 ``` -**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 +### Affected Monitor IDs (6 total) -### 1.2 CSS Variables (index.css) +| Monitor UUID | Status Code | Error | +|--------------|-------------|-------| +| `2b8cea58-b8f9-43fc-abe0-f6a0baba2351` | 500 | database disk image is malformed | +| `5523d6b3-e2bf-4727-a071-6546f58e8839` | 500 | database disk image is malformed | +| `264fb47b-9814-479a-bb40-0397f21026fe` | 500 | database disk image is malformed | +| `97ecc308-ca86-41f9-ba59-5444409dee8e` | 500 | database disk image is malformed | +| `cad93a3d-6ad4-4cba-a95c-5bb9b46168cd` | 500 | database disk image is malformed | +| `cdc4d769-8703-4881-8202-4b2493bccf58` | 500 | database disk image is malformed | -**Current:** -```css -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - color: rgba(255, 255, 255, 0.87); - background-color: #0f172a; -} -``` +### Working Monitor IDs (8 total - return HTTP 200) -**Problems:** -- ❌ Hardcoded colors, not CSS variables -- ❌ No dark/light mode toggle system -- ❌ No type scale -- ❌ Custom animations exist but no transition standards - -### 1.3 Existing Component Library (frontend/src/components/ui/) - -| 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 | - -**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 - -### 1.4 Page-Level UI Patterns - -| 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 | - -### 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 +- `fdbc17bd-a00a-4bde-b2f9-e6db69a55c0a` +- `869aee1a-37f0-437c-b151-72074629af3e` +- `dc254e9c-28b5-4b59-ae9a-3c0378420a5a` +- `33371a73-09a2-4c50-b327-69fab5324728` +- `412f9c0b-8498-4045-97c9-021d6fc2ed7e` +- `bef3866b-dbde-4159-9c40-1fb002ed0396` +- `84329e2b-7f7e-4c8b-a1a6-ca52d3b7e565` +- `edd36d10-0e5b-496c-acea-4e4cf7103369` +- `0b426c10-82b8-4cc4-af0e-2dd5f1082fb2` --- -## 2. Design Token System +## 2. Complete File Map - Uptime Feature -### 2.1 CSS Variables (index.css) +### Frontend Layer (`frontend/src/`) -```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 */ +| File | Purpose | +|------|---------| +| [pages/Uptime.tsx](frontend/src/pages/Uptime.tsx) | Main Uptime page component, displays MonitorCard grid | +| [api/uptime.ts](frontend/src/api/uptime.ts) | API client functions: `getMonitors()`, `getMonitorHistory()`, `updateMonitor()`, `deleteMonitor()`, `checkMonitor()` | +| [components/UptimeWidget.tsx](frontend/src/components/UptimeWidget.tsx) | Dashboard widget showing uptime summary | +| No dedicated hook | Uses inline `useQuery` in components | - /* ======================================== - * 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 */ +### Backend Layer (`backend/internal/`) - /* 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 */ +| File | Purpose | +|------|---------| +| [api/routes/routes.go](backend/internal/api/routes/routes.go#L230-L240) | Route registration for `/uptime/*` endpoints | +| [api/handlers/uptime_handler.go](backend/internal/api/handlers/uptime_handler.go) | HTTP handlers: `List()`, `GetHistory()`, `Update()`, `Delete()`, `Sync()`, `CheckMonitor()` | +| [services/uptime_service.go](backend/internal/services/uptime_service.go) | Business logic: monitor checking, notification batching, history retrieval | +| [models/uptime.go](backend/internal/models/uptime.go) | GORM models: `UptimeMonitor`, `UptimeHeartbeat` | +| [models/uptime_host.go](backend/internal/models/uptime_host.go) | GORM models: `UptimeHost`, `UptimeNotificationEvent` | - /* 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 */ +--- - /* 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 */ +## 3. Data Flow Analysis - /* ======================================== - * TYPOGRAPHY - * ======================================== */ - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +### Request Flow: UI → API → DB → Response - /* 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 */ +```text +┌─────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +├─────────────────────────────────────────────────────────────────────────┤ +│ 1. Uptime.tsx loads → useQuery(['monitors'], getMonitors) │ +│ 2. For each monitor, MonitorCard renders │ +│ 3. MonitorCard calls useQuery(['uptimeHistory', monitor.id], │ +│ () => getMonitorHistory(monitor.id, 60)) │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ API CLIENT (frontend/src/api/uptime.ts) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ getMonitorHistory(id: string, limit: number = 50): │ +│ client.get │ +│ (`/uptime/monitors/${id}/history?limit=${limit}`) │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ BACKEND ROUTES (backend/internal/api/routes/routes.go) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory) │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ HANDLER (backend/internal/api/handlers/uptime_handler.go) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ func (h *UptimeHandler) GetHistory(c *gin.Context) { │ +│ id := c.Param("id") │ +│ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) │ +│ history, err := h.service.GetMonitorHistory(id, limit) │ +│ if err != nil { │ +│ c.JSON(500, gin.H{"error": "Failed to get history"}) ◄─ ERROR │ +│ return │ +│ } │ +│ c.JSON(200, history) │ +│ } │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SERVICE (backend/internal/services/uptime_service.go:875-879) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ func (s *UptimeService) GetMonitorHistory(id string, limit int) │ +│ ([]models.UptimeHeartbeat, error) { │ +│ var heartbeats []models.UptimeHeartbeat │ +│ result := s.DB.Where("monitor_id = ?", id) │ +│ .Order("created_at desc") │ +│ .Limit(limit) │ +│ .Find(&heartbeats) ◄─ GORM QUERY │ +│ return heartbeats, result.Error ◄─ ERROR RETURNED HERE │ +│ } │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ DATABASE (SQLite via GORM) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ SELECT * FROM uptime_heartbeats │ +│ WHERE monitor_id = "..." │ +│ ORDER BY created_at desc │ +│ LIMIT 60 │ +│ │ +│ ERROR: "database disk image is malformed" │ +└─────────────────────────────────────────────────────────────────────────┘ +``` - /* Line Heights */ - --leading-tight: 1.25; - --leading-normal: 1.5; - --leading-relaxed: 1.75; +--- - /* Font Weights */ - --font-normal: 400; - --font-medium: 500; - --font-semibold: 600; - --font-bold: 700; +## 4. Database Schema - /* ======================================== - * 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 */ +### UptimeMonitor Table - /* Container */ - --container-sm: 640px; - --container-md: 768px; - --container-lg: 1024px; - --container-xl: 1280px; - --container-2xl: 1536px; - - /* Page Gutters */ - --page-gutter: var(--space-6); - --page-gutter-lg: var(--space-8); - - /* ======================================== - * 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; - - /* 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); - - /* 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); - - /* Focus Ring */ - --ring-width: 2px; - --ring-offset: 2px; - --ring-color: var(--color-brand-500); - } - - /* ======================================== - * 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 */ - - /* 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 */ - - /* 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 */ - - /* 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 */ - } +```go +type UptimeMonitor struct { + ID string `gorm:"primaryKey" json:"id"` // UUID + ProxyHostID *uint `json:"proxy_host_id"` // Optional FK + RemoteServerID *uint `json:"remote_server_id"` // Optional FK + UptimeHostID *string `json:"uptime_host_id"` // FK to UptimeHost + Name string `json:"name"` + Type string `json:"type"` // http, tcp, ping + URL string `json:"url"` + UpstreamHost string `json:"upstream_host"` + Interval int `json:"interval"` // seconds + Enabled bool `json:"enabled"` + Status string `json:"status"` // up, down, pending + LastCheck time.Time `json:"last_check"` + Latency int64 `json:"latency"` // ms + FailureCount int `json:"failure_count"` + MaxRetries int `json:"max_retries"` + // ... timestamps } ``` -### 2.2 Tailwind Configuration (tailwind.config.js) +### UptimeHeartbeat Table (where corruption exists) -```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: [], +```go +type UptimeHeartbeat struct { + ID uint `gorm:"primaryKey" json:"id"` // Auto-increment + MonitorID string `json:"monitor_id" gorm:"index"` // UUID FK + Status string `json:"status"` // up, down + Latency int64 `json:"latency"` + Message string `json:"message"` + CreatedAt time.Time `json:"created_at" gorm:"index"` } ``` --- -## 3. Component Library Specifications +## 5. Root Cause Identification -### 3.1 Directory Structure +### Primary Issue: SQLite Database Corruption -``` -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 -``` +The error `database disk image is malformed` is a SQLite-specific error indicating: -### 3.2 Component Specifications +- Corruption in the database file's B-tree structure +- Possible causes: + 1. **Disk I/O errors** during write operations + 2. **Unexpected container shutdown** mid-transaction + 3. **File system issues** in Docker volume + 4. **Database file written by multiple processes** (concurrent access without WAL) + 5. **Full disk** causing incomplete writes -#### Badge Component +### Why Only Some Monitors Are Affected -```tsx -// frontend/src/components/ui/Badge.tsx -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '../../utils/cn' +The corruption appears to be **localized to specific B-tree pages** that contain +the heartbeat records for those 6 monitors. SQLite's error occurs when: -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', - }, - } -) +- The query touches corrupted pages +- The index on `monitor_id` or `created_at` has corruption +- The data pages for those specific rows are damaged -interface BadgeProps - extends React.HTMLAttributes, - VariantProps { - icon?: React.ReactNode +### Evidence Supporting This Conclusion + +1. **Consistent 500 errors** for the same 6 monitor IDs +2. **Other queries succeed** (listing monitors returns 200) +3. **Error occurs at the GORM layer** (service.go:877) +4. **Query itself is correct** (same pattern works for 8 other monitors) +5. **No ID mismatch** - UUIDs are correctly passed from frontend to backend + +--- + +## 6. Recommended Actions + +### Immediate Actions + +1. **Stop the container gracefully** to prevent further corruption: + + ```bash + docker stop charon + ``` + +2. **Backup the current database** before any repair: + + ```bash + docker cp charon:/app/data/charon.db ./charon.db.backup.$(date +%Y%m%d) + ``` + +3. **Check database integrity** from within container: + + ```bash + docker exec -it charon sqlite3 /app/data/charon.db "PRAGMA integrity_check;" + ``` + +4. **Attempt database recovery**: + + ```bash + # Export all data that can be read + sqlite3 /app/data/charon.db ".dump" > dump.sql + # Create new database + sqlite3 /app/data/charon_new.db < dump.sql + # Replace original + mv /app/data/charon_new.db /app/data/charon.db + ``` + +### If Recovery Fails + +5. **Delete corrupted heartbeat records** (lossy but restores functionality): + + ```sql + DELETE FROM uptime_heartbeats WHERE monitor_id IN ( + '2b8cea58-b8f9-43fc-abe0-f6a0baba2351', + '5523d6b3-e2bf-4727-a071-6546f58e8839', + '264fb47b-9814-479a-bb40-0397f21026fe', + '97ecc308-ca86-41f9-ba59-5444409dee8e', + 'cad93a3d-6ad4-4cba-a95c-5bb9b46168cd', + 'cdc4d769-8703-4881-8202-4b2493bccf58' + ); + VACUUM; + ``` + +### Long-Term Prevention + +6. **Enable WAL mode** for better crash resilience (in DB initialization): + + ```go + db.Exec("PRAGMA journal_mode=WAL;") + ``` + +7. **Add periodic VACUUM** to compact database and rebuild indexes + +8. **Consider heartbeat table rotation** - archive old heartbeats to prevent + unbounded growth + +--- + +## 7. Code Quality Notes + +### No Logic Bugs Found + +After tracing the complete data flow: + +- ✅ Frontend correctly passes monitor UUID +- ✅ API route correctly extracts `:id` param +- ✅ Handler correctly calls service with UUID +- ✅ Service correctly queries by `monitor_id` +- ✅ GORM model has correct field types and indexes + +### Potential Improvement: Error Handling + +The handler currently returns generic "Failed to get history" for all errors: + +```go +// Current (hides root cause) +if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"}) + return } -export function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) { - return ( - - {icon && {icon}} - {children} - - ) -} -``` - -#### Alert Component - -```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' - -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" - } +// Better (exposes root cause in logs, generic to user) +if err != nil { + logger.Log().WithError(err).WithField("monitor_id", id).Error("GetHistory failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"}) + return } ``` --- -## 4. Layout Improvements +## 8. Summary -### 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)} -
-
-
- ) -} -``` +| Question | Answer | +|----------|--------| +| Is this a frontend bug? | ❌ No | +| Is this a backend logic bug? | ❌ No | +| Is this an ID mismatch? | ❌ No (UUIDs are consistent) | +| Is this a timing issue? | ❌ No | +| **Is this database corruption?** | ✅ **YES** | +| Affected component | SQLite `uptime_heartbeats` table | +| Root cause | Disk image malformed (B-tree corruption) | +| Immediate fix | Database recovery/rebuild | +| Permanent fix | Enable WAL mode, graceful shutdowns | --- -## 5. Implementation Phases - -### Phase 1: Design Tokens Foundation (Week 1) - -**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 - -**Files to Create:** -- None (modify existing) - -**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 - -**Testing:** -- Visual regression test for Dashboard, Security, ProxyHosts -- Dark/light mode toggle verification -- Build succeeds without errors - ---- - -### Phase 2: Core Component Library (Weeks 2-3) - -**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 - -**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 - -**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 -``` - -**Testing:** -- Unit tests for each new component -- Storybook-style visual verification (manual) -- Accessibility audit (keyboard nav, screen reader) - ---- - -### Phase 3: Layout Components (Week 4) - -**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) - -**Files to Modify:** -- [frontend/src/components/Layout.tsx](frontend/src/components/Layout.tsx) - Apply token system - -**Testing:** -- Responsive layout tests -- Mobile sidebar behavior -- Table scrolling with sticky headers - ---- - -### 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 -``` - ---- - -*Plan created: December 16, 2025* -*Estimated completion: 7 weeks* -*Issue reference: GitHub #409* +*Investigation completed: December 17, 2025* +*Investigator: GitHub Copilot* diff --git a/docs/plans/prev_spec_uiux_dec16.md b/docs/plans/prev_spec_uiux_dec16.md new file mode 100644 index 00000000..1ed8ad8d --- /dev/null +++ b/docs/plans/prev_spec_uiux_dec16.md @@ -0,0 +1,1469 @@ +# Charon UI/UX Improvement Plan + +**Issue:** GitHub #409 - UI Enhancement & Design System +**Date:** December 16, 2025 +**Status:** ✅ Completed +**Completion Date:** December 16, 2025 +**Stack:** React 19 + Vite + TypeScript + TanStack Query + Tailwind CSS v4 + +--- + +## Executive Summary + +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: + +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 + +--- + +## 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', +} +``` + +**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 + +### 1.2 CSS Variables (index.css) + +**Current:** +```css +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + color: rgba(255, 255, 255, 0.87); + background-color: #0f172a; +} +``` + +**Problems:** +- ❌ Hardcoded colors, not CSS variables +- ❌ No dark/light mode toggle system +- ❌ No type scale +- ❌ Custom animations exist but no transition standards + +### 1.3 Existing Component Library (frontend/src/components/ui/) + +| 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 | + +**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 + +### 1.4 Page-Level UI Patterns + +| 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 | + +### 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 + +--- + +## 2. Design Token System + +### 2.1 CSS Variables (index.css) + +```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 */ + + /* ======================================== + * 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 */ + + /* 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 */ + + /* 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 */ + + /* ======================================== + * TYPOGRAPHY + * ======================================== */ + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* 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 */ + + /* Line Heights */ + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.75; + + /* Font Weights */ + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; + + /* ======================================== + * 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 */ + + /* Container */ + --container-sm: 640px; + --container-md: 768px; + --container-lg: 1024px; + --container-xl: 1280px; + --container-2xl: 1536px; + + /* Page Gutters */ + --page-gutter: var(--space-6); + --page-gutter-lg: var(--space-8); + + /* ======================================== + * 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; + + /* 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); + + /* 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); + + /* Focus Ring */ + --ring-width: 2px; + --ring-offset: 2px; + --ring-color: var(--color-brand-500); + } + + /* ======================================== + * 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 */ + + /* 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 */ + + /* 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 */ + + /* 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 */ + } +} +``` + +### 2.2 Tailwind Configuration (tailwind.config.js) + +```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: [], +} +``` + +--- + +## 3. Component Library Specifications + +### 3.1 Directory Structure + +``` +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 +``` + +### 3.2 Component Specifications + +#### Badge Component + +```tsx +// frontend/src/components/ui/Badge.tsx +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../utils/cn' + +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} + + ) +} +``` + +#### Alert Component + +```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' + +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. Implementation Phases + +### Phase 1: Design Tokens Foundation (Week 1) + +**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 + +**Files to Create:** +- None (modify existing) + +**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 + +**Testing:** +- Visual regression test for Dashboard, Security, ProxyHosts +- Dark/light mode toggle verification +- Build succeeds without errors + +--- + +### Phase 2: Core Component Library (Weeks 2-3) + +**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 + +**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 + +**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 +``` + +**Testing:** +- Unit tests for each new component +- Storybook-style visual verification (manual) +- Accessibility audit (keyboard nav, screen reader) + +--- + +### Phase 3: Layout Components (Week 4) + +**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) + +**Files to Modify:** +- [frontend/src/components/Layout.tsx](frontend/src/components/Layout.tsx) - Apply token system + +**Testing:** +- Responsive layout tests +- Mobile sidebar behavior +- Table scrolling with sticky headers + +--- + +### 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 +``` + +--- + +*Plan created: December 16, 2025* +*Estimated completion: 7 weeks* +*Issue reference: GitHub #409* diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index cce1efc6..17ad0c22 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,141 +1,256 @@ -# QA Security Audit Report - Final Verification +# QA Audit Report -**Date:** 2025-12-16 (Updated) -**Auditor:** QA_Security Agent -**Scope:** Comprehensive Final QA Verification +## Audit Information -## Executive Summary +- **Date:** December 17, 2025 +- **Time:** 13:03 - 13:22 UTC +- **Auditor:** Automated QA Pipeline +- **Scope:** Full codebase audit after recent changes -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. +## Changes Under Review -## Final Check Results +1. New script: `scripts/db-recovery.sh` +2. Modified: `backend/internal/models/database.go` (WAL mode verification) +3. Modified: `backend/internal/models/database_test.go` (new test) +4. Modified: `backend/internal/api/handlers/uptime_handler.go` (improved logging) +5. Modified: `.vscode/tasks.json` (new task) -| Check | Status | Details | -|-------|--------|---------| -| 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) | +--- + +## Check Results Summary + +| # | Check | Status | Notes | +|---|-------|--------|-------| +| 1 | Pre-commit (All Files) | ⚠️ WARNING | Version mismatch (non-blocking) | +| 2 | Backend Build | ✅ PASS | No errors | +| 3 | Backend Tests | ✅ PASS | All tests passed | +| 4 | Go Vet | ✅ PASS | No issues | +| 5 | Frontend Build | ✅ PASS | Built successfully | +| 6 | Frontend Tests | ✅ PASS | 1032 passed, 2 skipped | +| 7 | Frontend Lint | ✅ PASS | 14 warnings (0 errors) | +| 8 | TypeScript Check | ✅ PASS | No type errors | +| 9 | Markdownlint | ✅ PASS | No issues | +| 10 | Hadolint | ℹ️ INFO | 1 informational suggestion | +| 11 | Go Vulnerability Check | ✅ PASS | No vulnerabilities found | + +--- ## Detailed Results -### 1. Frontend Tests (✅ PASS) +### 1. Pre-commit (All Files) -**Final Test Results:** -- **947 tests passed** (100%) -- **0 tests failed** -- **2 tests skipped** (intentional - WebSocket connection tests) -- **91 test files** -- **Duration:** ~69.40s +**Status:** ⚠️ WARNING (Non-blocking) -**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` +**Output:** -### 2. Frontend Build (✅ PASS) +```text +Check .version matches latest Git tag....................................Failed +- hook id: check-version-match +- exit code: 1 -Production build completed successfully: -- 2327 modules transformed -- Build time: 6.21s -- All chunks properly bundled and optimized - -### 3. Frontend Linting (✅ PASS) - -**Results:** 0 errors, 14 warnings - -**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 | - -### 4. Backend Build (✅ PASS) - -Go build completed without errors for all packages. - -### 5. Backend Tests (✅ PASS) - -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` ✅ - -### 6. Pre-commit (⚠️ PARTIAL) - -**Passed Checks:** -- ✅ Go Tests -- ✅ Go Vet -- ✅ LFS Large Files Check -- ✅ CodeQL DB Artifacts Check -- ✅ Data Backups Check -- ✅ Frontend TypeScript Check -- ✅ Frontend Lint (Fix) - -**Expected Warning:** -- ⚠️ Version tag mismatch (.version vs git tag) - This is expected behavior, not a code issue - -## Test Coverage - -| Component | Coverage | Requirement | Status | -|-----------|----------|-------------|--------| -| Backend | 85.4% | 85% minimum | ✅ PASS | -| Frontend | Full suite | All tests pass | ✅ PASS | - -## Code Quality Summary - -### Dashboard.tsx Fixes Applied: -```diff -- import { ..., Certificate } from 'lucide-react' -+ import { ..., FileKey } from 'lucide-react' // Certificate icon doesn't exist - -+ const validCertificates = certificates.filter(c => c.status === 'valid').length - -- icon={} -+ icon={} - -- change={enabledCertificates > 0 ? {...} // undefined variable -+ change={validCertificates > 0 ? {...} // fixed - -- import CertificateStatusCard from '../components/CertificateStatusCard' - // Removed unused import +ERROR: .version (0.7.13) does not match latest Git tag (v0.9.3) +To sync, either update .version or tag with 'v0.7.13' ``` +**Other Pre-commit Hooks:** + +- Go Vet: ✅ Passed +- Prevent large files: ✅ Passed +- Prevent CodeQL DB artifacts: ✅ Passed +- Prevent data/backups commits: ✅ Passed +- Frontend TypeScript Check: ✅ Passed +- Frontend Lint (Fix): ✅ Passed + +**Assessment:** The version mismatch is a CI/CD configuration matter and does not affect code quality or functionality of the audited changes. This is expected during development between releases. + +--- + +### 2. Backend Build + +**Status:** ✅ PASS + +```bash +cd backend && go build ./... +``` + +No compilation errors. All packages build successfully. + +--- + +### 3. Backend Tests + +**Status:** ✅ PASS + +All backend tests passed with 85.5% code coverage (minimum required: 85%). + +**Package Results:** + +- `internal/api/handlers`: PASS +- `internal/api/middleware`: PASS (cached) +- `internal/api/routes`: PASS +- `internal/api/tests`: PASS +- `internal/caddy`: PASS +- `internal/cerberus`: PASS (cached) +- `internal/config`: PASS (cached) +- `internal/crowdsec`: PASS +- `internal/database`: PASS +- `internal/logger`: PASS (cached) +- `internal/metrics`: PASS (cached) +- `internal/models`: PASS (cached) +- `internal/server`: PASS (cached) +- `internal/services`: PASS (cached) +- `internal/util`: PASS (cached) +- `internal/version`: PASS (cached) + +--- + +### 4. Go Vet + +**Status:** ✅ PASS + +```bash +cd backend && go vet ./... +``` + +No static analysis issues found. + +--- + +### 5. Frontend Build + +**Status:** ✅ PASS + +```text +vite v7.3.0 building client environment for production... +✓ 2326 modules transformed. +✓ built in 7.59s +``` + +All assets compiled successfully with optimized bundles. + +--- + +### 6. Frontend Tests + +**Status:** ✅ PASS + +```text +Test Files 96 passed (96) + Tests 1032 passed | 2 skipped (1034) + Duration 75.24s +``` + +All test suites passed. 2 tests skipped (intentional, integration-related). + +--- + +### 7. Frontend Lint + +**Status:** ✅ PASS (with warnings) + +**Summary:** 0 errors, 14 warnings + +**Warning Categories:** + +| Type | Count | Files Affected | +|------|-------|----------------| +| `@typescript-eslint/no-explicit-any` | 8 | Test files | +| `@typescript-eslint/no-unused-vars` | 1 | E2E test | +| `react-hooks/exhaustive-deps` | 1 | CrowdSecConfig.tsx | +| `react-refresh/only-export-components` | 2 | UI components | + +**Assessment:** All warnings are in test files or non-critical areas. No errors that would affect production code. + +--- + +### 8. TypeScript Check + +**Status:** ✅ PASS + +```bash +cd frontend && npm run type-check +tsc --noEmit +``` + +No TypeScript type errors found. + +--- + +### 9. Markdownlint + +**Status:** ✅ PASS + +All Markdown files pass linting rules. + +--- + +### 10. Hadolint (Dockerfile) + +**Status:** ℹ️ INFO + +```text +-:183 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation. +``` + +**Assessment:** This is an informational suggestion, not an error. The current Dockerfile structure is intentional for build caching optimization during development. + +--- + +### 11. Go Vulnerability Check + +**Status:** ✅ PASS + +```text +No vulnerabilities found. +``` + +All Go dependencies are secure with no known CVEs. + +--- + +## Issues Found + +### Critical Issues + +None. + +### Non-Critical Issues + +1. **Version Mismatch** (Pre-commit) + - `.version` file (0.7.13) doesn't match latest git tag (v0.9.3) + - **Impact:** None for functionality; affects CI/CD tagging + - **Recommendation:** Update `.version` file before next release + +2. **ESLint Warnings** (14 total) + - Mostly `no-explicit-any` in test files + - **Impact:** None for production code + - **Recommendation:** Address in future cleanup sprint + +3. **Dockerfile Suggestion** + - Multiple consecutive RUN instructions at line 183 + - **Impact:** Slightly larger image size + - **Recommendation:** Consider consolidation if image size becomes a concern + +--- + ## Conclusion -**✅ ALL QA CHECKS PASSED** +**Overall Status: ✅ QA 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 +All critical checks pass successfully. The audited changes to: -**Status:** ✅ **READY FOR PRODUCTION** +- `scripts/db-recovery.sh` +- `backend/internal/models/database.go` +- `backend/internal/models/database_test.go` +- `backend/internal/api/handlers/uptime_handler.go` +- `.vscode/tasks.json` ---- -*Generated by QA_Security Agent - December 16, 2025* +...do not introduce any regressions, security vulnerabilities, or breaking changes. The codebase maintains: + +- **85.5% backend test coverage** (above 85% minimum) +- **100% frontend test pass rate** (1032/1032 tests) +- **Zero Go vulnerabilities** +- **Zero TypeScript errors** +- **Zero ESLint errors** + +The codebase is ready for merge/deployment. diff --git a/scripts/db-recovery.sh b/scripts/db-recovery.sh new file mode 100755 index 00000000..46840dd2 --- /dev/null +++ b/scripts/db-recovery.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# ============================================================================== +# Charon Database Recovery Script +# ============================================================================== +# This script performs database integrity checks and recovery operations for +# the Charon SQLite database. It can detect corruption, create backups, and +# attempt to recover data using SQLite's .dump command. +# +# Usage: ./scripts/db-recovery.sh [--force] +# --force: Skip confirmation prompts +# +# Exit codes: +# 0 - Success (database healthy or recovered) +# 1 - Failure (recovery failed or prerequisites missing) +# ============================================================================== + +set -euo pipefail + +# Configuration +DOCKER_DB_PATH="/app/data/charon.db" +LOCAL_DB_PATH="backend/data/charon.db" +BACKUP_DIR="" +DB_PATH="" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +FORCE_MODE=false + +# Colors for output (disabled if not a terminal) +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +fi + +# ============================================================================== +# Helper Functions +# ============================================================================== + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if sqlite3 is available +check_prerequisites() { + if ! command -v sqlite3 &> /dev/null; then + log_error "sqlite3 is not installed or not in PATH" + log_info "Install with: apt-get install sqlite3 (Debian/Ubuntu)" + log_info " or: apk add sqlite (Alpine)" + log_info " or: brew install sqlite (macOS)" + exit 1 + fi + log_info "sqlite3 found: $(sqlite3 --version)" +} + +# Detect environment (Docker vs Local) +detect_environment() { + if [ -f "$DOCKER_DB_PATH" ]; then + DB_PATH="$DOCKER_DB_PATH" + BACKUP_DIR="/app/data/backups" + log_info "Running in Docker environment" + elif [ -f "$LOCAL_DB_PATH" ]; then + DB_PATH="$LOCAL_DB_PATH" + BACKUP_DIR="backend/data/backups" + log_info "Running in local development environment" + else + log_error "Database not found at expected locations:" + log_error " - Docker: $DOCKER_DB_PATH" + log_error " - Local: $LOCAL_DB_PATH" + exit 1 + fi + log_info "Database path: $DB_PATH" +} + +# Create backup directory if it doesn't exist +ensure_backup_dir() { + if [ ! -d "$BACKUP_DIR" ]; then + mkdir -p "$BACKUP_DIR" + log_info "Created backup directory: $BACKUP_DIR" + fi +} + +# Create a timestamped backup of the current database +create_backup() { + local backup_file="${BACKUP_DIR}/charon_backup_${TIMESTAMP}.db" + + log_info "Creating backup: $backup_file" + cp "$DB_PATH" "$backup_file" + + # Also backup WAL and SHM files if they exist + if [ -f "${DB_PATH}-wal" ]; then + cp "${DB_PATH}-wal" "${backup_file}-wal" + log_info "Backed up WAL file" + fi + if [ -f "${DB_PATH}-shm" ]; then + cp "${DB_PATH}-shm" "${backup_file}-shm" + log_info "Backed up SHM file" + fi + + log_success "Backup created successfully" + echo "$backup_file" +} + +# Run SQLite integrity check +run_integrity_check() { + log_info "Running SQLite integrity check..." + + local result + result=$(sqlite3 "$DB_PATH" "PRAGMA integrity_check;" 2>&1) || true + + echo "$result" + + if [ "$result" = "ok" ]; then + return 0 + else + return 1 + fi +} + +# Attempt to recover database using .dump +recover_database() { + local dump_file="${BACKUP_DIR}/charon_dump_${TIMESTAMP}.sql" + local recovered_db="${BACKUP_DIR}/charon_recovered_${TIMESTAMP}.db" + + log_info "Attempting database recovery..." + + # Export database using .dump (works even with some corruption) + log_info "Exporting database via .dump command..." + if ! sqlite3 "$DB_PATH" ".dump" > "$dump_file" 2>&1; then + log_error "Failed to export database dump" + return 1 + fi + log_success "Database dump created: $dump_file" + + # Check if dump file has content + if [ ! -s "$dump_file" ]; then + log_error "Dump file is empty - no data to recover" + return 1 + fi + + # Create new database from dump + log_info "Creating new database from dump..." + if ! sqlite3 "$recovered_db" < "$dump_file" 2>&1; then + log_error "Failed to create database from dump" + return 1 + fi + log_success "Recovered database created: $recovered_db" + + # Verify recovered database integrity + log_info "Verifying recovered database integrity..." + local verify_result + verify_result=$(sqlite3 "$recovered_db" "PRAGMA integrity_check;" 2>&1) || true + if [ "$verify_result" != "ok" ]; then + log_error "Recovered database failed integrity check" + log_error "Result: $verify_result" + return 1 + fi + log_success "Recovered database passed integrity check" + + # Replace original with recovered database + log_info "Replacing original database with recovered version..." + + # Remove old WAL/SHM files first + rm -f "${DB_PATH}-wal" "${DB_PATH}-shm" + + # Move recovered database to original location + mv "$recovered_db" "$DB_PATH" + log_success "Database replaced successfully" + + return 0 +} + +# Enable WAL mode on database +enable_wal_mode() { + log_info "Enabling WAL (Write-Ahead Logging) mode..." + + local current_mode + current_mode=$(sqlite3 "$DB_PATH" "PRAGMA journal_mode;" 2>&1) || true + + if [ "$current_mode" = "wal" ]; then + log_info "WAL mode already enabled" + return 0 + fi + + if sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" > /dev/null 2>&1; then + log_success "WAL mode enabled" + return 0 + else + log_warn "Failed to enable WAL mode (database may be locked)" + return 1 + fi +} + +# Cleanup old backups (keep last 10) +cleanup_old_backups() { + log_info "Cleaning up old backups (keeping last 10)..." + + local backup_count + backup_count=$(find "$BACKUP_DIR" -name "charon_backup_*.db" -type f 2>/dev/null | wc -l) + + if [ "$backup_count" -gt 10 ]; then + find "$BACKUP_DIR" -name "charon_backup_*.db" -type f -printf '%T@ %p\n' 2>/dev/null | \ + sort -n | head -n -10 | cut -d' ' -f2- | \ + while read -r file; do + rm -f "$file" "${file}-wal" "${file}-shm" + log_info "Removed old backup: $file" + done + fi +} + +# Parse command line arguments +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --force|-f) + FORCE_MODE=true + shift + ;; + --help|-h) + echo "Usage: $0 [--force]" + echo "" + echo "Options:" + echo " --force, -f Skip confirmation prompts" + echo " --help, -h Show this help message" + exit 0 + ;; + *) + log_error "Unknown option: $1" + exit 1 + ;; + esac + done +} + +# ============================================================================== +# Main Script +# ============================================================================== + +main() { + echo "==============================================" + echo " Charon Database Recovery Tool" + echo "==============================================" + echo "" + + parse_args "$@" + + # Step 1: Check prerequisites + check_prerequisites + + # Step 2: Detect environment + detect_environment + + # Step 3: Ensure backup directory exists + ensure_backup_dir + + # Step 4: Create backup before any operations + local backup_file + backup_file=$(create_backup) + echo "" + + # Step 5: Run integrity check + echo "==============================================" + echo " Integrity Check Results" + echo "==============================================" + local integrity_result + if integrity_result=$(run_integrity_check); then + echo "$integrity_result" + log_success "Database integrity check passed!" + echo "" + + # Even if healthy, ensure WAL mode is enabled + enable_wal_mode + + # Cleanup old backups + cleanup_old_backups + + echo "" + echo "==============================================" + echo " Summary" + echo "==============================================" + log_success "Database is healthy" + log_info "Backup stored at: $backup_file" + exit 0 + fi + + # Database has issues + echo "$integrity_result" + log_error "Database integrity check FAILED" + echo "" + + # Step 6: Confirm recovery (unless force mode) + if [ "$FORCE_MODE" != "true" ]; then + echo -e "${YELLOW}WARNING: Database corruption detected!${NC}" + echo "This script will attempt to recover the database." + echo "A backup has already been created at: $backup_file" + echo "" + read -p "Continue with recovery? (y/N): " -r confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + log_info "Recovery cancelled by user" + exit 1 + fi + fi + + # Step 7: Attempt recovery + echo "" + echo "==============================================" + echo " Recovery Process" + echo "==============================================" + if recover_database; then + # Step 8: Enable WAL mode on recovered database + enable_wal_mode + + # Cleanup old backups + cleanup_old_backups + + echo "" + echo "==============================================" + echo " Summary" + echo "==============================================" + log_success "Database recovery completed successfully!" + log_info "Original backup: $backup_file" + log_info "Please restart the Charon application" + exit 0 + else + echo "" + echo "==============================================" + echo " Summary" + echo "==============================================" + log_error "Database recovery FAILED" + log_info "Your original database backup is at: $backup_file" + log_info "SQL dump (if created) is in: $BACKUP_DIR" + log_info "Manual intervention may be required" + exit 1 + fi +} + +# Run main function with all arguments +main "$@"