diff --git a/frontend/src/app/entity-journey/page.tsx b/frontend/src/app/entity-journey/page.tsx index c302600..043e5e0 100644 --- a/frontend/src/app/entity-journey/page.tsx +++ b/frontend/src/app/entity-journey/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useToast } from "@/components/common/Toaster"; +import { BehavioralFlagBadges } from "@/components/entity/BehavioralFlagBadges"; import type { Camera } from "@/lib/types"; import { apiFetch, formatTimestamp } from "@/lib/utils"; @@ -10,8 +11,10 @@ interface JourneyEntity { entity_id: string; entity_type?: string; risk_score?: number; + escalation_level?: string; cameras_visited?: number; zones_entered?: string[]; + behavioral_flags?: string[]; last_seen_at?: string; } @@ -76,6 +79,11 @@ export default function EntityJourneyPage() { [appearances] ); + const selectedEntity = useMemo( + () => entities.find((e) => e.entity_id === selected) ?? null, + [entities, selected] + ); + return (
{/* Entity list */} @@ -112,6 +120,9 @@ export default function EntityJourneyPage() {
{e.cameras_visited ?? 0} cameras · {e.zones_entered?.length ?? 0} zones
+ {e.behavioral_flags && e.behavioral_flags.length > 0 && ( + + )} ))} @@ -134,6 +145,36 @@ export default function EntityJourneyPage() {
No appearances recorded.
) : ( <> + {selectedEntity && ( +
+
+ + {(selectedEntity.entity_type ?? "entity")} · {selectedEntity.entity_id.slice(0, 8)} + +
+ {selectedEntity.escalation_level && + selectedEntity.escalation_level !== "none" && ( + + {selectedEntity.escalation_level} + + )} + {typeof selectedEntity.risk_score === "number" && ( + + risk {selectedEntity.risk_score.toFixed(2)} + + )} +
+
+ {selectedEntity.behavioral_flags && + selectedEntity.behavioral_flags.length > 0 && ( + + )} +
+ )}
{appearances.length} appearances across {camerasInJourney} camera(s)
diff --git a/frontend/src/app/entity-tracking/page.tsx b/frontend/src/app/entity-tracking/page.tsx index 8c7ecb0..89cde9c 100644 --- a/frontend/src/app/entity-tracking/page.tsx +++ b/frontend/src/app/entity-tracking/page.tsx @@ -28,6 +28,7 @@ import { } from "lucide-react"; import { cn, apiFetch } from "@/lib/utils"; import { useToast } from "@/components/common/Toaster"; +import { BehavioralFlagBadges } from "@/components/entity/BehavioralFlagBadges"; /* ------------------------------------------------------------------ */ /* Types */ @@ -52,6 +53,7 @@ interface TrackedEntity { zones_entered: string[]; risk_score: number; escalation_level: "none" | "watch" | "alert" | "critical"; + behavioral_flags?: string[]; first_seen: string; last_seen: string; track_ids: string[]; @@ -590,6 +592,11 @@ export default function EntityTrackingPage() { Group: {groupSize} )} + + {/* Composite behavioral signature flags */} + {entity.behavioral_flags && entity.behavioral_flags.length > 0 && ( + + )}
{entity.appearance.colors.length > 0 && ( diff --git a/frontend/src/components/entity/BehavioralFlagBadges.tsx b/frontend/src/components/entity/BehavioralFlagBadges.tsx new file mode 100644 index 0000000..1cc8dbf --- /dev/null +++ b/frontend/src/components/entity/BehavioralFlagBadges.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface BehavioralFlagBadgesProps { + flags: string[]; + className?: string; + /** Cap the number rendered; the remainder collapses into a "+N" chip. */ + max?: number; +} + +type Severity = "critical" | "high" | "medium" | "low"; + +const SEVERITY_STYLE: Record = { + critical: "border-red-500/50 bg-red-500/10 text-red-400", + high: "border-orange-500/50 bg-orange-500/10 text-orange-400", + medium: "border-yellow-500/40 bg-yellow-500/10 text-yellow-400", + low: "border-gray-600 bg-gray-700/30 text-gray-400", +}; + +/** Keyword → severity. Composite-signature flags are free-form strings, so we + * classify by the most alarming substring they contain. */ +function flagSeverity(flag: string): Severity { + const f = flag.toLowerCase(); + if (/weapon|breach|intrusion|tailgat|forced|attack|assault|brandish/.test(f)) + return "critical"; + if (/escalat|reconnaissance|recon|evasion|perimeter|fleeing|abandon|aggress/.test(f)) + return "high"; + if (/loiter|dwell|repeated|crowd|unusual|anomal|wander/.test(f)) + return "medium"; + return "low"; +} + +function labelFor(flag: string): string { + return flag.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); +} + +/** + * Renders composite-signature behavioral flags as severity-colored chips. + * Shared by the entity-journey and entity-tracking views so flag styling + * never drifts. + */ +export function BehavioralFlagBadges({ + flags, + className, + max = 4, +}: BehavioralFlagBadgesProps) { + if (!flags || flags.length === 0) return null; + const shown = flags.slice(0, max); + const overflow = flags.length - shown.length; + + return ( +
+ {shown.map((flag, i) => { + const sev = flagSeverity(flag); + return ( + + + {labelFor(flag)} + + ); + })} + {overflow > 0 && ( + + +{overflow} + + )} +
+ ); +} + +export default BehavioralFlagBadges;