feat(audit-log): add configurable retention, export, Auditor role, and enhanced filtering (#258)

- Configurable retention: audit_retention_days setting (1-365 days, default 90)
  replaces hardcoded 90-day retention, exposed in Settings > Data Retention
- Export: one-click CSV/JSON export of filtered audit data via new
  GET /api/audit-log/export endpoint (capped at 10,000 entries)
- Auditor role: read-only role with system:audit permission for viewing
  and exporting audit logs without admin privileges (Admiral tier)
- Enhanced filtering: full-text search across summaries/paths/usernames,
  date range picker, and expandable row details showing request path,
  IP address, node ID, and entry ID
This commit is contained in:
Anso
2026-03-29 20:18:51 -04:00
committed by GitHub
parent f4428a394c
commit d586ce393a
11 changed files with 289 additions and 65 deletions
+7
View File
@@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* **audit-log:** configurable retention period (1365 days) via Settings → Developer → Data Retention, replacing the hardcoded 90-day limit
* **audit-log:** one-click CSV and JSON export of the currently filtered audit log dataset (capped at 10,000 entries)
* **rbac:** new Auditor role — read-only access to the audit log with no administrative privileges, ideal for compliance officers and security reviewers
* **audit-log:** full-text search across action summaries, API paths, and usernames
* **audit-log:** date range filter with From/To date pickers in the UI
* **audit-log:** expandable row details showing full request path, IP address, node ID, and entry ID
* **rbac:** introduce Deployer and Node Admin intermediate roles with scoped permissions (Team Pro)
- Deployer role: can deploy, restart, stop, and start stacks but cannot edit compose files, delete stacks, or access system settings
- Node Admin role: full stack and node management but no system settings, user management, or license access
+58 -8
View File
@@ -832,6 +832,9 @@ const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
viewer: [
'stack:read', 'node:read',
],
auditor: [
'stack:read', 'node:read', 'system:audit',
],
};
/**
@@ -1765,12 +1768,12 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
return;
}
if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return;
if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return;
const db = DatabaseService.getInstance();
const existing = db.getUserByUsername(username);
@@ -1832,12 +1835,12 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
}
if (role !== undefined) {
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
return;
}
if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return;
if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return;
// Prevent demoting yourself
if (user.username === req.user!.username && role !== user.role) {
res.status(400).json({ error: 'Cannot change your own role' });
@@ -3177,6 +3180,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'template_registry_url',
'metrics_retention_hours',
'log_retention_days',
'audit_retention_days',
]);
// Zod schema for bulk PATCH - all keys optional, present keys fully validated
@@ -3192,6 +3196,7 @@ const SettingsPatchSchema = z.object({
template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }),
metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String),
log_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
}).partial();
app.get('/api/settings', async (req: Request, res: Response) => {
@@ -3485,18 +3490,19 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response):
// --- Audit Log Routes (Admiral, local-only) ---
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) 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 search = req.query.search 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 });
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search });
res.json(result);
} catch (error) {
console.error('[AuditLog] Failed to fetch audit log:', error);
@@ -3504,6 +3510,50 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
}
});
app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const format = (req.query.format as string) === 'csv' ? 'csv' : 'json';
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search 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: 1, limit: 10000, username, method, from, to, search });
const timestamp = new Date().toISOString().slice(0, 10);
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.json"`);
res.json(result.entries);
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
const csvEscape = (val: string | number | null): string => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const rows = result.entries.map(e =>
headers.map(h => csvEscape(e[h as keyof typeof e])).join(',')
);
res.send([headers.join(','), ...rows].join('\n'));
}
} catch (error) {
console.error('[AuditLog] Export failed:', error);
res.status(500).json({ error: 'Failed to export audit log' });
}
});
// --- API Token Routes (Admiral, admin-only, local-only) ---
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
+7 -1
View File
@@ -62,7 +62,7 @@ export interface WebhookExecution {
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type ResourceType = 'stack' | 'node';
export interface User {
@@ -1109,6 +1109,7 @@ export class DatabaseService {
method?: string;
from?: number;
to?: number;
search?: string;
} = {}): { entries: AuditLogEntry[]; total: number } {
const page = filters.page ?? 1;
const limit = filters.limit ?? 50;
@@ -1133,6 +1134,11 @@ export class DatabaseService {
conditions.push('timestamp <= ?');
params.push(filters.to);
}
if (filters.search) {
conditions.push('(summary LIKE ? OR path LIKE ? OR username LIKE ?)');
const term = `%${filters.search}%`;
params.push(term, term, term);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
+2 -1
View File
@@ -304,7 +304,8 @@ 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);
const auditRetentionDays = parseInt(settings['audit_retention_days'] || '90', 10);
db.cleanupOldAuditLogs(isNaN(auditRetentionDays) ? 90 : auditRetentionDays);
} catch (e) {
console.error('MonitorService: failed to cleanup old data', e);
}
+45 -9
View File
@@ -1,6 +1,6 @@
---
title: Audit Log
description: Track all mutating actions across your Sencho instance with a searchable audit trail for team accountability.
description: Track all mutating actions across your Sencho instance with a searchable, exportable audit trail for team accountability.
---
<Note>
@@ -21,6 +21,8 @@ Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automati
| **Action** | Human-readable summary (e.g., "Deployed stack: nginx-proxy") |
| **Status** | HTTP response status code |
| **Node** | Which node the action targeted |
| **Request Path** | Full API path (visible in expanded detail view) |
| **IP Address** | Client IP address (visible in expanded detail view) |
### Example actions tracked
@@ -33,25 +35,59 @@ Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automati
- License activation/deactivation
- Webhook and notification agent configuration
- Fleet backup creation, restoration, and deletion
- Scheduled task management
- Registry credential management
## Viewing the audit log
Navigate to the **Audit** tab in the sidebar (visible to Admiral admins only).
Navigate to the **Audit** tab in the sidebar. This tab is visible to users with the **Admin** or **Auditor** role on an Admiral license.
<Frame>
<img src="/images/audit-log.png" alt="Audit Log view showing a timeline of actions" />
<img src="/images/audit-log/audit-log-overview.png" alt="Audit Log view showing a timeline of actions with search, filters, and export" />
</Frame>
### Filtering
Click any row to expand it and see full request details including the API path, IP address, node ID, and entry ID.
- **Username filter** - Search for actions by a specific user
- **Method filter** - Filter by HTTP method (POST, PUT, DELETE, PATCH)
## Filtering & search
Pagination is built in for navigating large audit histories.
The audit log provides several ways to find specific entries:
## Data retention
- **Full-text search** — Search across action summaries, API paths, and usernames
- **Method filter** — Filter by HTTP method (POST, PUT, DELETE, PATCH)
- **Date range** — Set a start and/or end date to narrow results to a specific time window
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.
All filters work together and are applied server-side with pagination.
## Export
Export the currently filtered audit log dataset as **CSV** or **JSON** using the **Export** dropdown in the header. The export respects all active filters — so you can narrow down to a date range or specific user before exporting.
Exports are capped at 10,000 entries per download.
## Auditor role
The **Auditor** role provides read-only access to the audit log without granting any administrative privileges. Auditors can:
- View the full audit log
- Search and filter entries
- Export audit data as CSV or JSON
- View stacks and nodes (read-only)
Auditors **cannot** modify settings, manage users, deploy stacks, or perform any other administrative actions. This role is ideal for compliance officers, security reviewers, or team leads who need visibility into system activity without operational access.
To create an Auditor user, go to **Settings → Users** and select the **Auditor** role when creating a new user.
## Configurable data retention
Audit log entries are automatically cleaned up based on your configured retention period. The default is **90 days**.
To change the retention period:
1. Go to **Settings → Developer → Data Retention**
2. Set the **Audit Log Retention** value (1365 days)
3. Click **Save Developer Settings**
Cleanup runs automatically as part of Sencho's periodic maintenance cycle.
## Security at rest
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+138 -38
View File
@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, Fragment } 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
interface AuditEntry {
id: number;
@@ -25,16 +27,32 @@ export function AuditLogView() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [usernameFilter, setUsernameFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [methodFilter, setMethodFilter] = useState('all');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const limit = 50;
const buildFilterParams = useCallback(() => {
const params = new URLSearchParams();
if (searchFilter) params.set('search', searchFilter);
if (methodFilter !== 'all') params.set('method', methodFilter);
if (fromDate) params.set('from', String(new Date(fromDate).getTime()));
if (toDate) {
const end = new Date(toDate);
end.setHours(23, 59, 59, 999);
params.set('to', String(end.getTime()));
}
return params;
}, [searchFilter, methodFilter, fromDate, toDate]);
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 params = buildFilterParams();
params.set('page', String(page));
params.set('limit', String(limit));
const res = await apiFetch(`/audit-log?${params}`, { localOnly: true });
if (res.ok) {
@@ -47,7 +65,7 @@ export function AuditLogView() {
} finally {
setLoading(false);
}
}, [page, usernameFilter, methodFilter]);
}, [page, buildFilterParams]);
useEffect(() => {
fetchLogs();
@@ -71,6 +89,30 @@ export function AuditLogView() {
return 'text-muted-foreground';
};
const handleExport = async (format: 'csv' | 'json') => {
try {
const params = buildFilterParams();
params.set('format', format);
const res = await apiFetch(`/audit-log/export?${params}`, { localOnly: true });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Export failed.');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.${format === 'csv' ? 'csv' : 'json'}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
toast.error('Export failed.');
}
};
return (
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
<Card>
@@ -80,10 +122,25 @@ export function AuditLogView() {
<ScrollText className="w-5 h-5" />
<CardTitle>Audit Log</CardTitle>
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Export
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('csv')}>Export as CSV</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground mt-1">
Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`}
@@ -91,13 +148,13 @@ export function AuditLogView() {
</CardHeader>
<CardContent>
{/* Filters */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-xs">
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="relative flex-1 min-w-[200px] 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); }}
placeholder="Search actions, paths, users..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(1); }}
className="pl-8"
/>
</div>
@@ -113,6 +170,20 @@ export function AuditLogView() {
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
value={fromDate}
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
className="w-[150px]"
placeholder="From"
/>
<Input
type="date"
value={toDate}
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
className="w-[150px]"
placeholder="To"
/>
</div>
{/* Table */}
@@ -143,28 +214,57 @@ export function AuditLogView() {
</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>
<Fragment key={entry.id}>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedId(expandedId === entry.id ? null : 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>
{expandedId === entry.id && (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30 px-6 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground text-xs block">Request Path</span>
<span className="font-mono text-xs">{entry.path}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">IP Address</span>
<span className="font-mono text-xs">{entry.ip_address || '-'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Node ID</span>
<span className="font-mono text-xs">{entry.node_id ?? 'Local'}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block">Entry ID</span>
<span className="font-mono text-xs">#{entry.id}</span>
</div>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
))
)}
</TableBody>
+4 -6
View File
@@ -167,14 +167,12 @@ export default function EditorLayout() {
{ value: 'templates', label: 'App Store', icon: CloudDownload },
{ value: 'global-observability', label: 'Logs', icon: Activity },
);
if (isPro && license?.variant === 'team' && isAdmin) {
items.push(
{ value: 'audit-log', label: 'Audit', icon: ScrollText },
{ value: 'scheduled-ops', label: 'Schedules', icon: Clock },
);
if (isPro && license?.variant === 'team') {
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
return items;
}, [isAdmin, isPro, license?.variant]);
}, [isAdmin, isPro, license?.variant, can]);
// Only highlight a tab if activeView matches a nav item
const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined;
+27 -1
View File
@@ -49,6 +49,7 @@ interface PatchableSettings {
template_registry_url?: string;
metrics_retention_hours?: string;
log_retention_days?: string;
audit_retention_days?: string;
}
type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'registries' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
@@ -91,6 +92,7 @@ const DEFAULT_SETTINGS: PatchableSettings = {
template_registry_url: '',
metrics_retention_hours: '24',
log_retention_days: '30',
audit_retention_days: '90',
};
function WebhooksSection({ isPro }: { isPro: boolean }) {
@@ -630,6 +632,7 @@ function UsersSection() {
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</>
)}
</SelectContent>
@@ -873,7 +876,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
useEffect(() => {
if (isOpen) {
@@ -925,6 +929,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -980,6 +985,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
global_logs_refresh: settings.global_logs_refresh,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
}, setIsSavingDeveloper, true);
if (ok) toast.success('Developer settings saved.');
};
@@ -1751,6 +1757,26 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
{isPro && license?.variant === 'team' && (
<div className="flex items-center justify-between gap-4 pt-4 border-t border-border">
<div className="space-y-0.5">
<Label className="text-base">Audit Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep audit trail entries.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => handleSettingChange('audit_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
)}
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, type React
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type PermissionAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'