mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user