mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
f9ebd1d77c
* feat: UI polish sprint — tag filters, toast tokens, logs toolbar, billing portal, editor UX - Fleet: replace inline tag pills with multi-select combobox dropdown - Toast: remap all notification colors to oklch design tokens - Logs: convert floating hover toolbar to permanent pinned toolbar, replace all hardcoded colors with design tokens for theme support - Audit: fix dropdown scroll-lock (modal=false), light theme button contrast - Resources: remove redundant inner border/bg on tab wrapper - Editor: add ⌘K/Ctrl+K shortcut hint and handler, standardize button heights - Billing: signed Lemon Squeezy portal URLs via sencho.io proxy for all tiers - New MultiSelectCombobox UI component * feat(editor): replace button row with split-button dropdown Replace three separate editor buttons (Discard, Save Only, Save & Deploy) with a compact split-button dropdown. Primary action is "Save & Deploy"; chevron opens dropdown with "Save Only" and "Discard Changes" options.
295 lines
16 KiB
TypeScript
295 lines
16 KiB
TypeScript
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 { 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 '@/components/ui/toast-store';
|
|
|
|
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 [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 = buildFilterParams();
|
|
params.set('page', String(page));
|
|
params.set('limit', String(limit));
|
|
|
|
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, buildFilterParams]);
|
|
|
|
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-success';
|
|
if (code >= 400 && code < 500) return 'text-yellow-500';
|
|
if (code >= 500) return 'text-red-500';
|
|
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>
|
|
<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>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="border-border">
|
|
<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" className="border-border" 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.`}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* Filters */}
|
|
<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="Search actions, paths, users..."
|
|
value={searchFilter}
|
|
onChange={(e) => { setSearchFilter(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>
|
|
<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 */}
|
|
<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) => (
|
|
<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>
|
|
</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>
|
|
);
|
|
}
|