feat(22-04): add TimelineCard chronological event renderer
- Merges reports/classifications/audit-events into one ascending list (relies on server ordering, no client-side sort) - Per-kind icon/label/tint: FileText for reports, Sparkles tinted by verdict for classifications, event_type table for audit rows (remediation_approved/completed, campaign_marked_false_positive, campaign_classified, humanized fallback for anything else) - 8px rail dot + border-l connector per UI-SPEC Timeline Spec
This commit is contained in:
parent
14adddfdf0
commit
e990a320b2
1 changed files with 153 additions and 0 deletions
153
components/phishing/timeline-card.tsx
Normal file
153
components/phishing/timeline-card.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
'use client';
|
||||
|
||||
import { FileText, Sparkles, CheckCircle2, ShieldCheck, XCircle, Circle } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TimelineEntry =
|
||||
| { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }
|
||||
| { kind: 'classification'; at: string; verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; confidence: string | null }
|
||||
| { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown };
|
||||
|
||||
interface TimelineCardProps {
|
||||
timeline: TimelineEntry[];
|
||||
}
|
||||
|
||||
const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT', string> = {
|
||||
SPAM: 'bg-slate-500 text-slate-600',
|
||||
UNWANTED: 'bg-amber-500 text-amber-600',
|
||||
THREAT: 'bg-destructive text-destructive',
|
||||
};
|
||||
|
||||
function humanizeEventType(eventType: string): string {
|
||||
return eventType
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function renderEntry(entry: TimelineEntry): {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
dotClass: string;
|
||||
textClass: string;
|
||||
} {
|
||||
if (entry.kind === 'report') {
|
||||
const ticketLabel = entry.ticketNumber ? `Ticket #${entry.ticketNumber}` : 'Ticket';
|
||||
const company = entry.companyName ? ` (${entry.companyName})` : '';
|
||||
return {
|
||||
label: `Report linked — ${ticketLabel}${company}`,
|
||||
icon: FileText,
|
||||
dotClass: 'bg-muted-foreground',
|
||||
textClass: 'text-muted-foreground',
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.kind === 'classification') {
|
||||
const confidence = entry.confidence != null ? ` (${entry.confidence}%)` : '';
|
||||
return {
|
||||
label: `Classified as ${entry.verdict}${confidence}`,
|
||||
icon: Sparkles,
|
||||
dotClass: VERDICT_TINT[entry.verdict].split(' ')[0],
|
||||
textClass: VERDICT_TINT[entry.verdict].split(' ')[1],
|
||||
};
|
||||
}
|
||||
|
||||
// entry.kind === 'audit'
|
||||
const payload = (entry.payload ?? {}) as Record<string, unknown>;
|
||||
switch (entry.eventType) {
|
||||
case 'remediation_approved': {
|
||||
const count =
|
||||
(Array.isArray(payload.actionIds) && payload.actionIds.length) ||
|
||||
(Array.isArray(payload.actions) && payload.actions.length) ||
|
||||
0;
|
||||
return {
|
||||
label: `${count} action(s) approved by ${entry.actor ?? 'unknown'}`,
|
||||
icon: CheckCircle2,
|
||||
dotClass: 'bg-blue-500',
|
||||
textClass: 'text-blue-600',
|
||||
};
|
||||
}
|
||||
case 'remediation_completed':
|
||||
return {
|
||||
label: 'Remediation completed',
|
||||
icon: ShieldCheck,
|
||||
dotClass: 'bg-green-500',
|
||||
textClass: 'text-green-600',
|
||||
};
|
||||
case 'campaign_marked_false_positive':
|
||||
return {
|
||||
label: 'Marked as false positive',
|
||||
icon: XCircle,
|
||||
dotClass: 'bg-slate-500',
|
||||
textClass: 'text-slate-600',
|
||||
};
|
||||
case 'campaign_classified': {
|
||||
const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | undefined;
|
||||
const tint = verdict ? VERDICT_TINT[verdict] : 'bg-muted-foreground text-muted-foreground';
|
||||
return {
|
||||
label: verdict ? `Classified as ${verdict}` : 'Campaign classified',
|
||||
icon: Sparkles,
|
||||
dotClass: tint.split(' ')[0],
|
||||
textClass: tint.split(' ')[1],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: humanizeEventType(entry.eventType),
|
||||
icon: Circle,
|
||||
dotClass: 'bg-muted-foreground',
|
||||
textClass: 'text-muted-foreground',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function TimelineCard({ timeline }: TimelineCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-bold">Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No timeline events yet.</p>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{timeline.map((entry, idx) => {
|
||||
const { label, icon: Icon, dotClass, textClass } = renderEntry(entry);
|
||||
const isLast = idx === timeline.length - 1;
|
||||
const actor = entry.kind === 'audit' ? entry.actor : null;
|
||||
const absolute = new Date(entry.at).toLocaleString();
|
||||
const relative = formatDistanceToNow(new Date(entry.at), { addSuffix: true });
|
||||
return (
|
||||
<div key={idx} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn('h-2 w-2 rounded-full shrink-0 mt-1.5', dotClass)}
|
||||
/>
|
||||
{!isLast && <span className="border-l flex-1 w-0 min-h-4" />}
|
||||
</div>
|
||||
<div className="pb-4 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', textClass)} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground" title={absolute}>
|
||||
{relative}
|
||||
</span>
|
||||
{actor && (
|
||||
<span className="text-xs text-muted-foreground">{actor}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue