feat: audit logging, secrets at rest, and legacy cleanup (#205)

* feat: audit logging, secrets at rest, and legacy cleanup

- Add Team Pro audit log: records all mutating API actions with user
  attribution, searchable timeline UI with filtering and pagination
- Add AES-256-GCM encryption at rest for node API tokens via CryptoService
- Drop 9 legacy SSH/TLS columns from nodes table (dead since v0.7)
- Remove orphaned MaintenanceModal.tsx (dead code, never imported)
- Add requireTeamPro backend guard for team-tier features
- Add audit log cleanup (90-day retention) to MonitorService
- Add docs page and navigation entry for audit log feature

* fix: remove unused AUTH_TAG_LENGTH constant from CryptoService
This commit is contained in:
Anso
2026-03-28 01:01:08 -04:00
committed by GitHub
parent 8bcd605ccb
commit 1799030060
11 changed files with 583 additions and 447 deletions
+10
View File
@@ -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
+112
View File
@@ -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<string, string> = {
'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<void> => {
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) => {
+66
View File
@@ -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);
}
}
+118 -18
View File
@@ -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<Node, 'id' | 'status' | 'created_at'>): 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<AuditLogEntry, 'id'>): 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);
}
}
+1
View File
@@ -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);
}
+1
View File
@@ -95,6 +95,7 @@
"features/rbac",
"features/atomic-deployments",
"features/fleet-backups",
"features/audit-log",
"features/licensing"
]
},
+58
View File
@@ -0,0 +1,58 @@
---
title: Audit Log
description: Track all mutating actions across your Sencho instance with a searchable audit trail for team accountability.
---
<Note>
The Audit Log requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature.
</Note>
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).
<Frame>
<img src="/images/audit-log.png" alt="Audit Log view showing a timeline of actions" />
</Frame>
### 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.
+4
View File
@@ -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)
+195
View File
@@ -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<AuditEntry[]>([]);
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 (
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ScrollText className="w-5 h-5" />
<CardTitle>Audit Log</CardTitle>
<Badge variant="outline" className="text-xs">Team Pro</Badge>
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<p className="text-sm text-muted-foreground mt-1">
Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`}
</p>
</CardHeader>
<CardContent>
{/* Filters */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by username..."
value={usernameFilter}
onChange={(e) => { setUsernameFilter(e.target.value); setPage(1); }}
className="pl-8"
/>
</div>
<Select value={methodFilter} onValueChange={(v) => { setMethodFilter(v); setPage(1); }}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Methods</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[170px]">Timestamp</TableHead>
<TableHead className="w-[110px]">User</TableHead>
<TableHead className="w-[80px]">Method</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-[70px]">Status</TableHead>
<TableHead className="w-[70px]">Node</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : entries.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No audit log entries found.
</TableCell>
</TableRow>
) : (
entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="text-xs text-muted-foreground font-mono">
{new Date(entry.timestamp).toLocaleString()}
</TableCell>
<TableCell className="font-medium text-sm">
{entry.username}
</TableCell>
<TableCell>
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
{entry.method}
</Badge>
</TableCell>
<TableCell className="text-sm">
{entry.summary}
</TableCell>
<TableCell className={`text-sm font-mono ${statusColor(entry.status_code)}`}>
{entry.status_code}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.node_id ?? '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+18 -2
View File
@@ -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<StackStatus>({});
@@ -1345,6 +1346,19 @@ export default function EditorLayout() {
<Activity className="w-4 h-4 mr-2" />
Logs
</Button>
{/* Audit Log Toggle (Team Pro + Admin only) */}
{isPro && license?.variant === 'team' && isAdmin && (
<Button
variant={activeView === 'audit-log' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'audit-log' ? (selectedFile ? 'editor' : 'dashboard') : 'audit-log')}
title="Audit Log"
>
<ScrollText className="w-4 h-4 mr-2" />
Audit
</Button>
)}
{/* Notifications Popover */}
<Popover>
@@ -1748,6 +1762,8 @@ export default function EditorLayout() {
setActiveView('dashboard');
}
}} />
) : activeView === 'audit-log' ? (
<AuditLogView />
) : (
<HomeDashboard />
)}
@@ -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<DockerUsage | null>(null);
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
// Ghost Hunter state
const [orphans, setOrphans] = useState<Record<string, ContainerInfo[]>>({});
const [isLoadingOrphans, setIsLoadingOrphans] = useState(false);
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-yellow-500" />
System Janitor
</DialogTitle>
<DialogDescription>
Clean up orphaned containers and perform generic system maintenance.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(val) => setActiveTab(val as 'ghosts' | 'system')}
className="flex-1 flex flex-col min-h-0 mt-4"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ghosts">Ghost Hunter</TabsTrigger>
<TabsTrigger value="system">System Cleanup</TabsTrigger>
</TabsList>
<TabsContent value="ghosts" className="flex-1 overflow-auto flex flex-col mt-4 border rounded-lg p-4 bg-muted/20">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
Detected Orphan Stacks <Badge variant="secondary">{totalOrphansCount}</Badge>
</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchOrphans}
disabled={isLoadingOrphans}
>
Refresh
</Button>
<Button
variant="destructive"
size="sm"
onClick={requestPurgeOrphans}
disabled={selectedOrphans.length === 0 || isPurging}
>
<Trash2 className="w-4 h-4 mr-2" />
{isPurging ? 'Purging...' : `Purge Selected(${selectedOrphans.length})`}
</Button>
</div>
</div>
{isLoadingOrphans ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Hunting for ghosts...
</div>
) : totalOrphansCount === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<MonitorX className="w-12 h-12 mb-2 opacity-50" />
<p>No orphaned containers detected.</p>
<p className="text-xs mt-1">Your system is clean!</p>
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="mb-2 flex items-center gap-2 px-2">
<input
type="checkbox"
onChange={selectAllOrphans}
checked={selectedOrphans.length === totalOrphansCount && totalOrphansCount > 0}
className="rounded border-gray-300 focus:ring-primary"
/>
<span className="text-sm font-medium">Select All</span>
</div>
{Object.entries(orphans).map(([project, containers]) => (
<div key={project} className="mb-6 last:mb-0 bg-card rounded-lg border shadow-sm overflow-hidden">
<div className="bg-muted px-4 py-2 border-b font-medium text-sm flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-red-500/80"></span>
Project: {project}
</div>
<div className="divide-y">
{containers.map(container => (
<div key={container.Id} className="flex items-center gap-4 p-3 hover:bg-muted/50 transition-colors">
<input
type="checkbox"
checked={selectedOrphans.includes(container.Id)}
onChange={() => toggleOrphanSelection(container.Id)}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold truncate">
{container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)}
</span>
<Badge variant={container.State === 'running' ? 'default' : 'secondary'} className="text-[10px] h-4">
{container.State}
</Badge>
</div>
<div className="text-xs text-muted-foreground truncate mt-1">
Image: {container.Image}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="system" className="flex-1 mt-4 p-4 border rounded-lg bg-card overflow-y-auto">
<div className="rounded-xl border bg-card text-card-foreground shadow-sm mb-6 p-6">
<div className="flex items-center gap-2 mb-4">
<HardDrive className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">Reclaimable Space Summary</h3>
</div>
{isLoadingUsage && !dockerUsage ? (
<div className="text-sm text-muted-foreground animate-pulse">Calculating reclaimable space...</div>
) : dockerUsage ? (
<div className="space-y-4">
<div className="flex justify-between items-center text-sm p-3 rounded-lg bg-muted/30 border">
<span className="flex items-center gap-2 font-medium">
<div className="w-3 h-3 rounded-full bg-orange-500 shadow-sm"></div> Stopped Containers
</span>
<span className="font-mono bg-background px-2 py-1 rounded shadow-sm border">{formatBytes(dockerUsage.reclaimableContainers)}</span>
</div>
<div className="flex justify-between items-center text-sm p-3 rounded-lg bg-muted/30 border">
<span className="flex items-center gap-2 font-medium">
<div className="w-3 h-3 rounded-full bg-blue-500 shadow-sm"></div> Unused & Dangling Images
</span>
<span className="font-mono bg-background px-2 py-1 rounded shadow-sm border">{formatBytes(dockerUsage.reclaimableImages)}</span>
</div>
<div className="flex justify-between items-center text-sm p-3 rounded-lg bg-muted/30 border">
<span className="flex items-center gap-2 font-medium">
<div className="w-3 h-3 rounded-full bg-purple-500 shadow-sm"></div> Unused Volumes
</span>
<span className="font-mono bg-background px-2 py-1 rounded shadow-sm border">{formatBytes(dockerUsage.reclaimableVolumes)}</span>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Reclaimable space data unavailable.</div>
)}
</div>
<h3 className="text-lg font-semibold mb-4">Global Docker Pruning</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<Button
variant="outline"
className="h-24 flex flex-col items-center justify-center gap-2"
onClick={() => requestPruneSystem('containers')}
disabled={isPruning}
>
<MonitorX className="w-6 h-6 text-orange-500" />
<div className="text-center">
<div className="font-bold">Prune Containers</div>
<div className="text-xs text-muted-foreground font-normal">Removes all stopped containers</div>
</div>
</Button>
<Button
variant="outline"
className="h-24 flex flex-col items-center justify-center gap-2"
onClick={() => requestPruneSystem('images')}
disabled={isPruning}
>
<PackageMinus className="w-6 h-6 text-blue-500" />
<div className="text-center">
<div className="font-bold">Prune Images</div>
<div className="text-xs text-muted-foreground font-normal">Removes unused images</div>
</div>
</Button>
<Button
variant="outline"
className="h-24 flex flex-col items-center justify-center gap-2"
onClick={() => requestPruneSystem('networks')}
disabled={isPruning}
>
<Network className="w-6 h-6 text-green-500" />
<div className="text-center">
<div className="font-bold">Prune Networks</div>
<div className="text-xs text-muted-foreground font-normal">Removes unused networks</div>
</div>
</Button>
<Button
variant="outline"
className="h-24 flex flex-col items-center justify-center gap-2"
onClick={() => requestPruneSystem('volumes')}
disabled={isPruning}
>
<HardDrive className="w-6 h-6 text-purple-500" />
<div className="text-center">
<div className="font-bold">Prune Volumes</div>
<div className="text-xs text-muted-foreground font-normal">Removes unused volumes</div>
</div>
</Button>
</div>
{pruneResult && (
<div className="bg-muted p-4 rounded-lg font-mono text-sm overflow-x-auto whitespace-pre-wrap">
<div className="font-bold mb-2">Result:</div>
<div className="text-green-600 dark:text-green-400">{pruneResult.message}</div>
{pruneResult.stdout && <div className="mt-2 text-foreground">{pruneResult.stdout}</div>}
{pruneResult.stderr && <div className="mt-2 text-red-500">{pruneResult.stderr}</div>}
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
{/* Purge Ghost Containers Confirmation */}
<AlertDialog open={purgeConfirmOpen} onOpenChange={setPurgeConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Forcefully Remove Containers</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to forcefully remove {selectedOrphans.length} ghost container(s)?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPurgeConfirmOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={confirmPurgeOrphans}>Remove</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Prune System Confirmation */}
<AlertDialog open={pruneConfirmOpen} onOpenChange={setPruneConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Prune {pruneTarget}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to prune all unused {pruneTarget}? This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPruneConfirmOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={confirmPruneSystem}>Prune</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}