diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d6170f..09c25817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **audit-log:** Team Pro audit logging — records all mutating API actions (deploy, stop, delete, settings changes, user CRUD) with user attribution, timestamp, HTTP method, status code, and node context. Searchable timeline UI with filtering by username and method. 90-day retention with automatic cleanup. +* **security:** encryption at rest for sensitive database values — node API tokens are now encrypted with AES-256-GCM using a per-instance key stored outside the database. Existing plaintext tokens are automatically migrated on startup. + +### Removed + +* **database:** dropped 9 legacy SSH/TLS columns from the nodes table (`host`, `port`, `ssh_port`, `ssh_user`, `ssh_password`, `ssh_key`, `tls_ca`, `tls_cert`, `tls_key`) — these were superseded by the Distributed API model in v0.7.0 and have been inert since +* **frontend:** removed orphaned `MaintenanceModal.tsx` component (dead code, never imported — prune functionality lives in `ResourcesView.tsx`) + ### Changed * **settings/license:** replaced static "View Pricing" button with dynamic upgrade cards — Community users see Personal Pro and Team Pro options with feature highlights; Personal Pro users see Team Pro upgrade only; each card links directly to Lemon Squeezy checkout diff --git a/backend/src/index.ts b/backend/src/index.ts index afbc3ac7..3267b362 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -440,6 +440,82 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); +// Audit logging middleware — records all mutating API actions for Team Pro accountability. +// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code. +const AUDIT_ROUTE_SUMMARIES: Record = { + 'POST /stacks': 'Created stack', + 'DELETE /stacks': 'Deleted stack', + 'POST /compose/up': 'Deployed stack', + 'POST /compose/down': 'Stopped stack', + 'POST /compose/start': 'Started stack', + 'POST /compose/stop': 'Stopped stack', + 'POST /compose/restart': 'Restarted stack', + 'POST /compose/pull': 'Pulled stack images', + 'POST /system/prune': 'Pruned system resources', + 'POST /nodes': 'Added node', + 'PUT /nodes': 'Updated node', + 'DELETE /nodes': 'Deleted node', + 'POST /users': 'Created user', + 'DELETE /users': 'Deleted user', + 'PUT /users': 'Updated user', + 'POST /license/activate': 'Activated license', + 'POST /license/deactivate': 'Deactivated license', + 'POST /agents': 'Updated notification agent', + 'POST /webhooks': 'Created webhook', + 'PUT /webhooks': 'Updated webhook', + 'DELETE /webhooks': 'Deleted webhook', + 'PUT /settings': 'Updated settings', + 'POST /fleet/snapshot': 'Created fleet backup', + 'DELETE /fleet/snapshot': 'Deleted fleet backup', + 'POST /fleet/snapshot/restore': 'Restored fleet backup', +}; + +function getAuditSummary(method: string, apiPath: string): string { + // Try exact prefix matches from most specific to least + const normalized = apiPath.replace(/^\//, ''); + for (const [pattern, summary] of Object.entries(AUDIT_ROUTE_SUMMARIES)) { + const [pMethod, pPath] = pattern.split(' '); + if (method === pMethod && normalized.startsWith(pPath.replace(/^\//, ''))) { + // Extract resource name from path if available (e.g., /stacks/myapp → "myapp") + const rest = normalized.slice(pPath.replace(/^\//, '').length).replace(/^\//, ''); + const resourceName = rest.split('/')[0]; + return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; + } + } + return `${method} /api/${normalized}`; +} + +app.use('/api', (req: Request, res: Response, next: NextFunction): void => { + if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { + next(); + return; + } + + const username = req.user?.username || 'unknown'; + const nodeId = req.nodeId ?? null; + const ip = req.ip || req.headers['x-forwarded-for'] as string || ''; + const apiPath = req.path; + + res.on('finish', () => { + try { + DatabaseService.getInstance().insertAuditLog({ + timestamp: Date.now(), + username, + method: req.method, + path: `/api${apiPath}`, + status_code: res.statusCode, + node_id: nodeId, + ip_address: ip, + summary: getAuditSummary(req.method, apiPath), + }); + } catch (err) { + console.error('[Audit] Failed to write audit log:', err); + } + }); + + next(); +}); + // --- License Routes (local-only, never proxied) --- // Pro feature guard: returns false and sends 403 if not Pro tier. @@ -451,6 +527,20 @@ const requirePro = (_req: Request, res: Response): boolean => { return true; }; +// Team Pro feature guard: requires Pro tier with team variant. +const requireTeamPro = (_req: Request, res: Response): boolean => { + const ls = LicenseService.getInstance(); + if (ls.getTier() !== 'pro') { + res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); + return false; + } + if (ls.getVariant() !== 'team') { + res.status(403).json({ error: 'This feature requires Sencho Team Pro.', code: 'TEAM_PRO_REQUIRED' }); + return false; + } + return true; +}; + const requireAdmin = (req: Request, res: Response): boolean => { if (req.user?.role !== 'admin') { res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' }); @@ -2715,6 +2805,28 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon } }); +// --- Audit Log Routes (Team Pro, local-only) --- + +app.get('/api/audit-log', async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const username = req.query.username as string | undefined; + const method = req.query.method as string | undefined; + const from = req.query.from ? parseInt(req.query.from as string) : undefined; + const to = req.query.to ? parseInt(req.query.to as string) : undefined; + + const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to }); + res.json(result); + } catch (error) { + console.error('[AuditLog] Failed to fetch audit log:', error); + res.status(500).json({ error: 'Failed to fetch audit log' }); + } +}); + // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => { diff --git a/backend/src/services/CryptoService.ts b/backend/src/services/CryptoService.ts new file mode 100644 index 00000000..7316b38e --- /dev/null +++ b/backend/src/services/CryptoService.ts @@ -0,0 +1,66 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 16; +const ENCRYPTED_PREFIX = 'enc:'; + +export class CryptoService { + private static instance: CryptoService; + private key: Buffer; + + private constructor() { + const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); + const keyPath = path.join(dataDir, 'encryption.key'); + + if (fs.existsSync(keyPath)) { + this.key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'hex'); + } else { + this.key = crypto.randomBytes(KEY_LENGTH); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + fs.writeFileSync(keyPath, this.key.toString('hex'), { mode: 0o600 }); + } + } + + public static getInstance(): CryptoService { + if (!CryptoService.instance) { + CryptoService.instance = new CryptoService(); + } + return CryptoService.instance; + } + + public encrypt(plaintext: string): string { + if (!plaintext) return plaintext; + + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return `${ENCRYPTED_PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; + } + + public decrypt(ciphertext: string): string { + if (!ciphertext || !this.isEncrypted(ciphertext)) return ciphertext; + + const payload = ciphertext.slice(ENCRYPTED_PREFIX.length); + const [ivHex, authTagHex, encryptedHex] = payload.split(':'); + + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const encrypted = Buffer.from(encryptedHex, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8'); + } + + public isEncrypted(value: string): boolean { + return value.startsWith(ENCRYPTED_PREFIX); + } +} diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 208d8183..017e6887 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; +import { CryptoService } from './CryptoService'; export interface Agent { id?: number; @@ -96,6 +97,18 @@ export interface FleetSnapshotFile { content: string; } +export interface AuditLogEntry { + id: number; + timestamp: number; + username: string; + method: string; + path: string; + status_code: number; + node_id: number | null; + ip_address: string; + summary: string; +} + export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; @@ -113,6 +126,7 @@ export class DatabaseService { this.initSchema(); this.migrateJsonConfig(dataDir); this.migrateAdminToUsersTable(); + this.migrateEncryptNodeTokens(); } public static getInstance(): DatabaseService { @@ -250,6 +264,21 @@ export class DatabaseService { ); CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + username TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL DEFAULT 0, + node_id INTEGER, + ip_address TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '' + ); + + CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp); + CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username); `); // Apply migrations safely (ignore if columns already exist) @@ -261,16 +290,11 @@ export class DatabaseService { maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''"); - // Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written) - maybeAddCol('nodes', 'host', "TEXT DEFAULT ''"); - maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375'); - maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22'); - maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''"); - maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''"); - maybeAddCol('nodes', 'ssh_key', "TEXT DEFAULT ''"); - maybeAddCol('nodes', 'tls_ca', "TEXT DEFAULT ''"); - maybeAddCol('nodes', 'tls_cert', "TEXT DEFAULT ''"); - maybeAddCol('nodes', 'tls_key', "TEXT DEFAULT ''"); + // Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written) + const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key']; + for (const col of legacyCols) { + try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch { /* already dropped or never existed */ } + } // Initialize default global settings if they don't exist const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)'); @@ -331,6 +355,17 @@ export class DatabaseService { } } + private migrateEncryptNodeTokens(): void { + const crypto = CryptoService.getInstance(); + const rows = this.db.prepare("SELECT id, api_token FROM nodes WHERE api_token != '' AND api_token IS NOT NULL").all() as Array<{ id: number; api_token: string }>; + for (const row of rows) { + if (!crypto.isEncrypted(row.api_token)) { + const encrypted = crypto.encrypt(row.api_token); + this.db.prepare('UPDATE nodes SET api_token = ? WHERE id = ?').run(encrypted, row.id); + } + } + } + // --- Agents --- public getAgents(): Agent[] { @@ -511,32 +546,39 @@ export class DatabaseService { // --- Nodes --- + private decryptNodeRow(row: any): Node { + const crypto = CryptoService.getInstance(); + return { + ...row, + is_default: row.is_default === 1, + api_token: row.api_token ? crypto.decrypt(row.api_token) : '', + }; + } + public getNodes(): Node[] { const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC'); - return stmt.all().map((row: any) => ({ - ...row, - is_default: row.is_default === 1 - })); + return stmt.all().map((row: any) => this.decryptNodeRow(row)); } public getNode(id: number): Node | undefined { const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?'); const row = stmt.get(id) as any; if (!row) return undefined; - return { ...row, is_default: row.is_default === 1 }; + return this.decryptNodeRow(row); } public getDefaultNode(): Node | undefined { const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1'); const row = stmt.get() as any; if (!row) return undefined; - return { ...row, is_default: row.is_default === 1 }; + return this.decryptNodeRow(row); } public addNode(node: Omit): number { if (node.is_default) { this.db.prepare('UPDATE nodes SET is_default = 0').run(); } + const crypto = CryptoService.getInstance(); const stmt = this.db.prepare( 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ); @@ -548,7 +590,7 @@ export class DatabaseService { 'unknown', Date.now(), node.api_url || '', - node.api_token || '' + node.api_token ? crypto.encrypt(node.api_token) : '' ); return result.lastInsertRowid as number; } @@ -570,7 +612,10 @@ export class DatabaseService { if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); } - if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); } + if (updates.api_token !== undefined) { + fields.push('api_token = ?'); + values.push(updates.api_token ? CryptoService.getInstance().encrypt(updates.api_token) : ''); + } if (fields.length === 0) return; @@ -777,4 +822,59 @@ export class DatabaseService { public getSnapshotCount(): number { return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0; } + + // --- Audit Log --- + + public insertAuditLog(entry: Omit): void { + this.db.prepare( + 'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(entry.timestamp, entry.username, entry.method, entry.path, entry.status_code, entry.node_id, entry.ip_address, entry.summary); + } + + public getAuditLogs(filters: { + page?: number; + limit?: number; + username?: string; + method?: string; + from?: number; + to?: number; + } = {}): { entries: AuditLogEntry[]; total: number } { + const page = filters.page ?? 1; + const limit = filters.limit ?? 50; + const offset = (page - 1) * limit; + + const conditions: string[] = []; + const params: (string | number)[] = []; + + if (filters.username) { + conditions.push('username = ?'); + params.push(filters.username); + } + if (filters.method) { + conditions.push('method = ?'); + params.push(filters.method); + } + if (filters.from) { + conditions.push('timestamp >= ?'); + params.push(filters.from); + } + if (filters.to) { + conditions.push('timestamp <= ?'); + params.push(filters.to); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const total = (this.db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as { count: number })?.count || 0; + const entries = this.db.prepare( + `SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?` + ).all(...params, limit, offset) as AuditLogEntry[]; + + return { entries, total }; + } + + public cleanupOldAuditLogs(daysToKeep = 90): void { + const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000); + this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff); + } } diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 81865318..41559f0a 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -304,6 +304,7 @@ export class MonitorService { db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours); const retentionDays = parseInt(settings['log_retention_days'] || '30', 10); db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays); + db.cleanupOldAuditLogs(90); } catch (e) { console.error('MonitorService: failed to cleanup old data', e); } diff --git a/docs/docs.json b/docs/docs.json index 75c0770f..b465a311 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -95,6 +95,7 @@ "features/rbac", "features/atomic-deployments", "features/fleet-backups", + "features/audit-log", "features/licensing" ] }, diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx new file mode 100644 index 00000000..8f5928e3 --- /dev/null +++ b/docs/features/audit-log.mdx @@ -0,0 +1,58 @@ +--- +title: Audit Log +description: Track all mutating actions across your Sencho instance with a searchable audit trail for team accountability. +--- + + + The Audit Log requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + + +Sencho Team Pro records every mutating action (deploy, stop, delete, settings changes, user management) with full attribution. The audit log answers the question every team eventually asks: **"Who changed what, and when?"** + +## What gets logged + +Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automatically recorded with: + +| Field | Description | +|-------|-------------| +| **Timestamp** | When the action occurred | +| **User** | The authenticated username that performed the action | +| **Method** | HTTP method (`POST`, `PUT`, `DELETE`, `PATCH`) | +| **Action** | Human-readable summary (e.g., "Deployed stack: nginx-proxy") | +| **Status** | HTTP response status code | +| **Node** | Which node the action targeted | + +### Example actions tracked + +- Stack lifecycle: deploy, stop, start, restart, pull, delete +- Stack creation and file edits +- Node management: add, update, delete +- User management: create, delete, role changes +- Settings changes +- System prune operations +- License activation/deactivation +- Webhook and notification agent configuration +- Fleet backup creation, restoration, and deletion + +## Viewing the audit log + +Navigate to the **Audit** tab in the sidebar (visible to Team Pro admins only). + + + Audit Log view showing a timeline of actions + + +### Filtering + +- **Username filter** — Search for actions by a specific user +- **Method filter** — Filter by HTTP method (POST, PUT, DELETE, PATCH) + +Pagination is built in for navigating large audit histories. + +## Data retention + +Audit log entries are automatically cleaned up after **90 days**. This runs as part of Sencho's periodic maintenance cycle alongside metrics and notification cleanup. + +## Security at rest + +As of this release, sensitive database values (such as remote node API tokens) are encrypted at rest using AES-256-GCM. The encryption key is stored as a separate file outside the SQLite database, ensuring that database file exposure alone does not compromise secrets. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index f11e626e..be3d47b5 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -67,6 +67,10 @@ Pro users get automatic backup and rollback on every deployment. Before applying Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy - even to remote nodes. [Learn more →](/features/fleet-backups) +## Audit log + +Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings — with timestamps, user attribution, and node context. Team Pro only. [Learn more →](/features/audit-log) + ## Licensing & billing Sencho is free for personal use with the Community tier. Pro unlocks RBAC, webhooks, fleet backups, atomic deployments, and advanced fleet features. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx new file mode 100644 index 00000000..ab86d02b --- /dev/null +++ b/frontend/src/components/AuditLogView.tsx @@ -0,0 +1,195 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; + +interface AuditEntry { + id: number; + timestamp: number; + username: string; + method: string; + path: string; + status_code: number; + node_id: number | null; + ip_address: string; + summary: string; +} + +export function AuditLogView() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [usernameFilter, setUsernameFilter] = useState(''); + const [methodFilter, setMethodFilter] = useState('all'); + const limit = 50; + + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (usernameFilter) params.set('username', usernameFilter); + if (methodFilter !== 'all') params.set('method', methodFilter); + + const res = await apiFetch(`/audit-log?${params}`, { localOnly: true }); + if (res.ok) { + const data = await res.json(); + setEntries(data.entries); + setTotal(data.total); + } + } catch { + // Silently fail — non-critical view + } finally { + setLoading(false); + } + }, [page, usernameFilter, methodFilter]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const methodBadgeVariant = (method: string): 'default' | 'secondary' | 'destructive' | 'outline' => { + switch (method) { + case 'POST': return 'default'; + case 'PUT': case 'PATCH': return 'secondary'; + case 'DELETE': return 'destructive'; + default: return 'outline'; + } + }; + + const statusColor = (code: number): string => { + if (code >= 200 && code < 300) return 'text-green-500'; + if (code >= 400 && code < 500) return 'text-yellow-500'; + if (code >= 500) return 'text-red-500'; + return 'text-muted-foreground'; + }; + + return ( +
+ + +
+
+ + Audit Log + Team Pro +
+ +
+

+ Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`} +

+
+ + {/* Filters */} +
+
+ + { setUsernameFilter(e.target.value); setPage(1); }} + className="pl-8" + /> +
+ +
+ + {/* Table */} +
+ + + + Timestamp + User + Method + Action + Status + Node + + + + {loading && entries.length === 0 ? ( + + + Loading... + + + ) : entries.length === 0 ? ( + + + No audit log entries found. + + + ) : ( + entries.map((entry) => ( + + + {new Date(entry.timestamp).toLocaleString()} + + + {entry.username} + + + + {entry.method} + + + + {entry.summary} + + + {entry.status_code} + + + {entry.node_id ?? '—'} + + + )) + )} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 2fb9307e..b9277992 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -36,6 +36,7 @@ import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import { GlobalObservabilityView } from './GlobalObservabilityView'; import { FleetView } from './FleetView'; +import { AuditLogView } from './AuditLogView'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; @@ -73,7 +74,7 @@ const formatBytes = (bytes: number) => { export default function EditorLayout() { const { isAdmin } = useAuth(); - const { isPro } = useLicense(); + const { isPro, license } = useLicense(); const { nodes, activeNode, setActiveNode } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). @@ -118,7 +119,7 @@ export default function EditorLayout() { window.matchMedia('(prefers-color-scheme: dark)').matches ); const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); - const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet'>('dashboard'); + const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log'>('dashboard'); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); @@ -1345,6 +1346,19 @@ export default function EditorLayout() { Logs + {/* Audit Log Toggle (Team Pro + Admin only) */} + {isPro && license?.variant === 'team' && isAdmin && ( + + )} {/* Notifications Popover */} @@ -1748,6 +1762,8 @@ export default function EditorLayout() { setActiveView('dashboard'); } }} /> + ) : activeView === 'audit-log' ? ( + ) : ( )} diff --git a/frontend/src/components/MaintenanceModal.tsx b/frontend/src/components/MaintenanceModal.tsx deleted file mode 100644 index 734b67da..00000000 --- a/frontend/src/components/MaintenanceModal.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from './ui/dialog'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "./ui/alert-dialog" -import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; -import { Button } from './ui/button'; -import { Badge } from './ui/badge'; -import { apiFetch } from '@/lib/api'; -import { toast } from 'sonner'; -import { Trash2, AlertTriangle, MonitorX, PackageMinus, Network, HardDrive } from 'lucide-react'; -import { formatBytes } from '@/lib/utils'; - -interface MaintenanceModalProps { - isOpen: boolean; - onClose: () => void; -} - -interface ContainerInfo { - Id: string; - Names: string[]; - State: string; - Status: string; - Image: string; -} - -interface DockerUsage { - reclaimableImages: number; - reclaimableContainers: number; - reclaimableVolumes: number; -} - -export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalProps) { - const [activeTab, setActiveTab] = useState<'ghosts' | 'system'>('ghosts'); - - // Docker Usage State - const [dockerUsage, setDockerUsage] = useState(null); - const [isLoadingUsage, setIsLoadingUsage] = useState(false); - - // Ghost Hunter state - const [orphans, setOrphans] = useState>({}); - const [isLoadingOrphans, setIsLoadingOrphans] = useState(false); - const [selectedOrphans, setSelectedOrphans] = useState([]); - const [isPurging, setIsPurging] = useState(false); - - // System Cleanup state - const [isPruning, setIsPruning] = useState(false); - const [pruneResult, setPruneResult] = useState<{ message: string; stdout: string; stderr: string } | null>(null); - - // Confirm Modals state - const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false); - const [pruneConfirmOpen, setPruneConfirmOpen] = useState(false); - const [pruneTarget, setPruneTarget] = useState<'containers' | 'images' | 'networks' | 'volumes' | null>(null); - - useEffect(() => { - if (isOpen && activeTab === 'ghosts') { - fetchOrphans(); - } else if (isOpen && activeTab === 'system') { - fetchDockerUsage(); - setPruneResult(null); // Reset when tab changes - } else { - setPruneResult(null); - } - }, [isOpen, activeTab]); - - const fetchDockerUsage = async () => { - setIsLoadingUsage(true); - try { - const res = await apiFetch('/system/docker-df'); - const data = await res.json(); - setDockerUsage(data); - } catch (error) { - console.error('Failed to fetch docker usage:', error); - } finally { - setIsLoadingUsage(false); - } - }; - - const fetchOrphans = async () => { - setIsLoadingOrphans(true); - try { - const res = await apiFetch('/system/orphans'); - const data = await res.json(); - setOrphans(data); - setSelectedOrphans([]); - } catch (error) { - console.error('Failed to fetch orphans:', error); - } finally { - setIsLoadingOrphans(false); - } - }; - - const toggleOrphanSelection = (containerId: string) => { - setSelectedOrphans(prev => - prev.includes(containerId) - ? prev.filter(id => id !== containerId) - : [...prev, containerId] - ); - }; - - const selectAllOrphans = () => { - const allIds = Object.values(orphans).flat().map(c => c.Id); - if (selectedOrphans.length === allIds.length) { - setSelectedOrphans([]); - } else { - setSelectedOrphans(allIds); - } - }; - - const requestPurgeOrphans = () => { - if (selectedOrphans.length === 0) return; - setPurgeConfirmOpen(true); - }; - - const confirmPurgeOrphans = async () => { - setPurgeConfirmOpen(false); - setIsPurging(true); - try { - const res = await apiFetch('/system/prune/orphans', { - method: 'POST', - body: JSON.stringify({ containerIds: selectedOrphans }) - }); - if (!res.ok) throw new Error('Purge failed'); - - await fetchOrphans(); // Refresh the list - toast.success(`Purged ${selectedOrphans.length} ghost container(s)`); - } catch (error) { - console.error('Failed to purge orphans:', error); - toast.error('Failed to purge selected containers.'); - } finally { - setIsPurging(false); - } - }; - - const requestPruneSystem = (target: 'containers' | 'images' | 'networks' | 'volumes') => { - setPruneTarget(target); - setPruneConfirmOpen(true); - }; - - const confirmPruneSystem = async () => { - if (!pruneTarget) return; - setPruneConfirmOpen(false); - setIsPruning(true); - setPruneResult(null); - try { - const res = await apiFetch('/system/prune/system', { - method: 'POST', - body: JSON.stringify({ target: pruneTarget }) - }); - const data = await res.json(); - setPruneResult(data); - - if (data.reclaimedBytes !== undefined) { - toast.success(`Prune complete! Reclaimed ${formatBytes(data.reclaimedBytes)}.`); - } else { - toast.success(`Successfully pruned ${pruneTarget}`); - } - - await fetchDockerUsage(); - } catch (error) { - console.error(`Failed to prune ${pruneTarget}: `, error); - toast.error(`Failed to prune ${pruneTarget}.`); - } finally { - setIsPruning(false); - setPruneTarget(null); - } - }; - - const totalOrphansCount = Object.values(orphans).flat().length; - - return ( - !open && onClose()}> - - - - - System Janitor - - - Clean up orphaned containers and perform generic system maintenance. - - - - setActiveTab(val as 'ghosts' | 'system')} - className="flex-1 flex flex-col min-h-0 mt-4" - > - - Ghost Hunter - System Cleanup - - - -
-

- Detected Orphan Stacks {totalOrphansCount} -

-
- - -
-
- - {isLoadingOrphans ? ( -
- Hunting for ghosts... -
- ) : totalOrphansCount === 0 ? ( -
- -

No orphaned containers detected.

-

Your system is clean!

-
- ) : ( -
-
- 0} - className="rounded border-gray-300 focus:ring-primary" - /> - Select All -
- - {Object.entries(orphans).map(([project, containers]) => ( -
-
- - Project: {project} -
-
- {containers.map(container => ( -
- toggleOrphanSelection(container.Id)} - className="rounded border-gray-300" - /> -
-
- - {container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} - - - {container.State} - -
-
- Image: {container.Image} -
-
-
- ))} -
-
- ))} -
- )} -
- - -
-
- -

Reclaimable Space Summary

-
- {isLoadingUsage && !dockerUsage ? ( -
Calculating reclaimable space...
- ) : dockerUsage ? ( -
-
- -
Stopped Containers -
- {formatBytes(dockerUsage.reclaimableContainers)} -
-
- -
Unused & Dangling Images -
- {formatBytes(dockerUsage.reclaimableImages)} -
-
- -
Unused Volumes -
- {formatBytes(dockerUsage.reclaimableVolumes)} -
-
- ) : ( -
Reclaimable space data unavailable.
- )} -
- -

Global Docker Pruning

- -
- - - - - - - -
- - {pruneResult && ( -
-
Result:
-
{pruneResult.message}
- {pruneResult.stdout &&
{pruneResult.stdout}
} - {pruneResult.stderr &&
{pruneResult.stderr}
} -
- )} -
-
-
- - {/* Purge Ghost Containers Confirmation */} - - - - Forcefully Remove Containers - - Are you sure you want to forcefully remove {selectedOrphans.length} ghost container(s)? - - - - setPurgeConfirmOpen(false)}>Cancel - Remove - - - - - {/* Prune System Confirmation */} - - - - Prune {pruneTarget} - - Are you sure you want to prune all unused {pruneTarget}? This cannot be undone. - - - - setPruneConfirmOpen(false)}>Cancel - Prune - - - -
- ); -}