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;