fix(audit): harden audit log with summary fixes, design compliance, and test coverage (#561)

* fix(audit): harden audit log with summary fixes, design compliance, and test coverage

Replace stale /compose/* route summaries with current /stacks/* patterns,
add ~30 missing route summaries for container ops, labels, fleet updates,
SSO, templates, and auto-update. Fix X-Forwarded-For extracting full
proxy chain instead of first client IP. Pre-sort pattern table at module
load for O(1) lookup instead of sorting per request.

Add Calendar and DatePicker UI components (react-day-picker v9 + date-fns)
replacing browser-native date inputs. Align AuditLogView with design system:
Combobox instead of Select, design token colors, strokeWidth 1.5, card bevel,
tabular-nums on all numeric cells.

Add diagnostic logging gated behind developer mode for audit middleware,
query, and export endpoints. Extract getAuditSummary to testable utility.
Add 32 tests covering summary resolution, DB round-trips, API permissions,
export formats, and middleware integration.

* fix(audit): use correct Chevron prop types for react-day-picker compatibility
This commit is contained in:
Anso
2026-04-13 15:38:33 -04:00
committed by GitHub
parent 397b684eab
commit 6daa7a135d
11 changed files with 853 additions and 138 deletions
+53 -46
View File
@@ -2,7 +2,8 @@ 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 { Combobox } from '@/components/ui/combobox';
import { DatePicker } from '@/components/ui/date-picker';
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';
@@ -22,6 +23,14 @@ interface AuditEntry {
summary: string;
}
const methodOptions = [
{ value: 'all', label: 'All Methods' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
];
export function AuditLogView() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
@@ -29,8 +38,8 @@ export function AuditLogView() {
const [loading, setLoading] = useState(true);
const [searchFilter, setSearchFilter] = useState('');
const [methodFilter, setMethodFilter] = useState('all');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [fromDate, setFromDate] = useState<Date | undefined>();
const [toDate, setToDate] = useState<Date | undefined>();
const [expandedId, setExpandedId] = useState<number | null>(null);
const limit = 50;
@@ -38,7 +47,11 @@ export function AuditLogView() {
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 (fromDate) {
const start = new Date(fromDate);
start.setHours(0, 0, 0, 0);
params.set('from', String(start.getTime()));
}
if (toDate) {
const end = new Date(toDate);
end.setHours(23, 59, 59, 999);
@@ -60,8 +73,8 @@ export function AuditLogView() {
setEntries(data.entries);
setTotal(data.total);
}
} catch {
// Silently fail - non-critical view
} catch (err) {
console.error('[AuditLog] Failed to fetch:', err);
} finally {
setLoading(false);
}
@@ -84,8 +97,8 @@ export function AuditLogView() {
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';
if (code >= 400 && code < 500) return 'text-warning';
if (code >= 500) return 'text-destructive';
return 'text-muted-foreground';
};
@@ -108,27 +121,28 @@ export function AuditLogView() {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
} catch (err) {
console.error('[AuditLog] Export failed:', err);
toast.error('Export failed.');
}
};
return (
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
<Card>
<Card className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ScrollText className="w-5 h-5" />
<ScrollText className="w-5 h-5" strokeWidth={1.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" />
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
Export
<ChevronDown className="w-3 h-3 ml-1" />
<ChevronDown className="w-3 h-3 ml-1" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -137,7 +151,7 @@ export function AuditLogView() {
</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' : ''}`} />
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh
</Button>
</div>
@@ -150,7 +164,7 @@ export function AuditLogView() {
{/* 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" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search actions, paths, users..."
value={searchFilter}
@@ -158,31 +172,24 @@ export function AuditLogView() {
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"
<Combobox
options={methodOptions}
value={methodFilter}
onValueChange={(v) => { setMethodFilter(v || 'all'); setPage(1); }}
placeholder="Method"
className="w-[140px]"
/>
<Input
type="date"
<DatePicker
value={fromDate}
onChange={(d) => { setFromDate(d); setPage(1); }}
placeholder="From"
className="w-[160px]"
/>
<DatePicker
value={toDate}
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
className="w-[150px]"
onChange={(d) => { setToDate(d); setPage(1); }}
placeholder="To"
className="w-[160px]"
/>
</div>
@@ -219,7 +226,7 @@ export function AuditLogView() {
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
>
<TableCell className="text-xs text-muted-foreground font-mono">
<TableCell className="text-xs text-muted-foreground font-mono tabular-nums">
{new Date(entry.timestamp).toLocaleString()}
</TableCell>
<TableCell className="font-medium text-sm">
@@ -233,10 +240,10 @@ export function AuditLogView() {
<TableCell className="text-sm">
{entry.summary}
</TableCell>
<TableCell className={`text-sm font-mono ${statusColor(entry.status_code)}`}>
<TableCell className={`text-sm font-mono tabular-nums ${statusColor(entry.status_code)}`}>
{entry.status_code}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<TableCell className="text-sm text-muted-foreground font-mono tabular-nums">
{entry.node_id ?? '-'}
</TableCell>
</TableRow>
@@ -250,15 +257,15 @@ export function AuditLogView() {
</div>
<div>
<span className="text-muted-foreground text-xs block">IP Address</span>
<span className="font-mono text-xs">{entry.ip_address || '-'}</span>
<span className="font-mono text-xs tabular-nums">{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>
<span className="font-mono text-xs tabular-nums">{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>
<span className="font-mono text-xs tabular-nums">#{entry.id}</span>
</div>
</div>
</TableCell>
@@ -274,15 +281,15 @@ export function AuditLogView() {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground font-mono tabular-nums">
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" />
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
<ChevronRight className="w-4 h-4" />
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
+65
View File
@@ -0,0 +1,65 @@
import { DayPicker, getDefaultClassNames } from "react-day-picker";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import "react-day-picker/style.css";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function CalendarChevron({ orientation }: { orientation?: "left" | "right" | "up" | "down" }) {
return orientation === "left" ? (
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
) : (
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
);
}
export function Calendar({ className, classNames, ...props }: CalendarProps) {
const defaults = getDefaultClassNames();
return (
<DayPicker
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
month_caption: "flex items-center justify-center h-7 relative",
caption_label: "text-sm font-medium font-sans",
nav: "flex items-center gap-1",
button_previous: cn(
"absolute left-1 inline-flex items-center justify-center",
"h-7 w-7 rounded-md border-0 bg-transparent p-0",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"cursor-pointer transition-colors"
),
button_next: cn(
"absolute right-1 inline-flex items-center justify-center",
"h-7 w-7 rounded-md border-0 bg-transparent p-0",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"cursor-pointer transition-colors"
),
weekdays: "flex",
weekday: "text-muted-foreground w-8 font-sans text-xs font-medium",
week: "flex mt-1",
day: "relative p-0 text-center font-mono text-sm",
day_button: cn(
"inline-flex items-center justify-center h-8 w-8 rounded-md",
"font-mono text-sm tabular-nums p-0 border-0 bg-transparent",
"cursor-pointer transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand"
),
today: "border border-brand/40 rounded-md",
selected: "bg-brand text-brand-foreground rounded-md hover:bg-brand hover:text-brand-foreground",
outside: "text-muted-foreground/50",
disabled: "text-muted-foreground/30 cursor-not-allowed",
chevron: `${defaults.chevron} fill-muted-foreground`,
...classNames,
}}
components={{
Chevron: CalendarChevron,
}}
{...props}
/>
);
}
@@ -0,0 +1,62 @@
import * as React from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
interface DatePickerProps {
value: Date | undefined;
onChange: (date: Date | undefined) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export function DatePicker({
value,
onChange,
placeholder = "Pick a date",
disabled = false,
className,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className={cn(
"justify-start text-left font-normal border-border",
!value && "text-muted-foreground",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{value ? (
<span className="font-mono text-xs tabular-nums">
{format(value, "MMM d, yyyy")}
</span>
) : (
<span className="text-xs">{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={(date) => {
onChange(date);
setOpen(false);
}}
autoFocus
/>
</PopoverContent>
</Popover>
);
}