Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions frontend/src/app/entity-journey/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
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";

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;
}

Expand Down Expand Up @@ -76,6 +79,11 @@ export default function EntityJourneyPage() {
[appearances]
);

const selectedEntity = useMemo(
() => entities.find((e) => e.entity_id === selected) ?? null,
[entities, selected]
);

return (
<div className="p-6 grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6">
{/* Entity list */}
Expand Down Expand Up @@ -112,6 +120,9 @@ export default function EntityJourneyPage() {
<div className="text-xs text-gray-500">
{e.cameras_visited ?? 0} cameras · {e.zones_entered?.length ?? 0} zones
</div>
{e.behavioral_flags && e.behavioral_flags.length > 0 && (
<BehavioralFlagBadges flags={e.behavioral_flags} max={3} className="mt-1.5" />
)}
</button>
</li>
))}
Expand All @@ -134,6 +145,36 @@ export default function EntityJourneyPage() {
<div className="text-sm text-gray-500 py-12 text-center">No appearances recorded.</div>
) : (
<>
{selectedEntity && (
<div className="mb-4 rounded-lg border border-gray-800 bg-zinc-900/40 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-100">
{(selectedEntity.entity_type ?? "entity")} · {selectedEntity.entity_id.slice(0, 8)}
</span>
<div className="flex items-center gap-2 text-[11px]">
{selectedEntity.escalation_level &&
selectedEntity.escalation_level !== "none" && (
<span className="rounded border border-orange-500/40 px-1.5 py-0.5 uppercase text-orange-400">
{selectedEntity.escalation_level}
</span>
)}
{typeof selectedEntity.risk_score === "number" && (
<span className="text-gray-400">
risk {selectedEntity.risk_score.toFixed(2)}
</span>
)}
</div>
</div>
{selectedEntity.behavioral_flags &&
selectedEntity.behavioral_flags.length > 0 && (
<BehavioralFlagBadges
flags={selectedEntity.behavioral_flags}
max={8}
className="mt-2"
/>
)}
</div>
)}
<div className="text-sm text-gray-400 mb-4">
{appearances.length} appearances across {camerasInJourney} camera(s)
</div>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/app/entity-tracking/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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[];
Expand Down Expand Up @@ -590,6 +592,11 @@ export default function EntityTrackingPage() {
Group: {groupSize}
</span>
)}

{/* Composite behavioral signature flags */}
{entity.behavioral_flags && entity.behavioral_flags.length > 0 && (
<BehavioralFlagBadges flags={entity.behavioral_flags} max={3} />
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
{entity.appearance.colors.length > 0 && (
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/entity/BehavioralFlagBadges.tsx
Original file line number Diff line number Diff line change
@@ -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<Severity, string> = {
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 (
<div className={cn("flex flex-wrap items-center gap-1", className)}>
{shown.map((flag, i) => {
const sev = flagSeverity(flag);
return (
<span
key={`${flag}-${i}`}
title={`Behavioral signature: ${labelFor(flag)} (${sev})`}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[9px] font-bold uppercase",
SEVERITY_STYLE[sev]
)}
>
<AlertTriangle className="h-2.5 w-2.5" />
{labelFor(flag)}
</span>
);
})}
{overflow > 0 && (
<span className="rounded-md border border-gray-700 bg-gray-800/40 px-1.5 py-0.5 text-[9px] font-bold text-gray-400">
+{overflow}
</span>
)}
</div>
);
}

export default BehavioralFlagBadges;
Loading