ui: removes observations (goodbye) add manual identifier actions
This commit is contained in:
+1
-2
@@ -24,7 +24,6 @@ import { DashboardPage } from "@/pages/DashboardPage";
|
|||||||
import { DevicesPage } from "@/pages/DevicesPage";
|
import { DevicesPage } from "@/pages/DevicesPage";
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -215,7 +214,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="tokens" element={<TokensPage />} />
|
<Route path="tokens" element={<TokensPage />} />
|
||||||
<Route path="observations" element={<ObservationsPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+10
-71
@@ -96,32 +96,6 @@ export type KnownDeviceSummary = {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentDeviceObservation = {
|
|
||||||
observation_key: string;
|
|
||||||
agent_id: string;
|
|
||||||
kind: string;
|
|
||||||
mac: string | null;
|
|
||||||
ip: string | null;
|
|
||||||
hostname: string | null;
|
|
||||||
first_seen_unix: number;
|
|
||||||
last_seen_unix: number;
|
|
||||||
last_action: string;
|
|
||||||
known_device: KnownDeviceSummary | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentDeviceObservationEvent = {
|
|
||||||
event_id: string;
|
|
||||||
observation_key: string;
|
|
||||||
agent_id: string;
|
|
||||||
kind: string;
|
|
||||||
action: string;
|
|
||||||
mac: string | null;
|
|
||||||
ip: string | null;
|
|
||||||
hostname: string | null;
|
|
||||||
ts_unix: number;
|
|
||||||
known_device: KnownDeviceSummary | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FleetDeviceAgent = {
|
export type FleetDeviceAgent = {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
@@ -318,38 +292,6 @@ export function wakeFleetDevice(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchObservations(opts?: {
|
|
||||||
agentId?: string;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<AgentDeviceObservation[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
|
||||||
params.set("limit", String(opts?.limit ?? 500));
|
|
||||||
return request<AgentDeviceObservation[]>(
|
|
||||||
`/api/v1/control/observations?${params.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchObservationHistory(opts?: {
|
|
||||||
agentId?: string;
|
|
||||||
kind?: string;
|
|
||||||
mac?: string;
|
|
||||||
ip?: string;
|
|
||||||
observationKey?: string;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<AgentDeviceObservationEvent[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
|
||||||
if (opts?.kind) params.set("kind", opts.kind);
|
|
||||||
if (opts?.mac) params.set("mac", opts.mac);
|
|
||||||
if (opts?.ip) params.set("ip", opts.ip);
|
|
||||||
if (opts?.observationKey) params.set("observation_key", opts.observationKey);
|
|
||||||
params.set("limit", String(opts?.limit ?? 500));
|
|
||||||
return request<AgentDeviceObservationEvent[]>(
|
|
||||||
`/api/v1/control/observations/history?${params.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createKnownDevice(input: {
|
export function createKnownDevice(input: {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
@@ -367,19 +309,6 @@ export function createKnownDevice(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachObservationIdentifier(
|
|
||||||
deviceId: string,
|
|
||||||
observationKey: string,
|
|
||||||
): Promise<KnownDevice> {
|
|
||||||
return request<KnownDevice>(
|
|
||||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/from-observation`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ observation_key: observationKey }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachDeviceIdentifier(
|
export function attachDeviceIdentifier(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
identifier: { kind: string; value: string },
|
identifier: { kind: string; value: string },
|
||||||
@@ -393,6 +322,16 @@ export function attachDeviceIdentifier(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detachDeviceIdentifier(
|
||||||
|
deviceId: string,
|
||||||
|
identifierKey: string,
|
||||||
|
): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>(
|
||||||
|
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/${encodeURIComponent(identifierKey)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeKnownDevice(
|
export function mergeKnownDevice(
|
||||||
targetDeviceId: string,
|
targetDeviceId: string,
|
||||||
sourceDeviceId: string,
|
sourceDeviceId: string,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
FileText,
|
FileText,
|
||||||
Key,
|
Key,
|
||||||
Eye,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
@@ -49,7 +48,6 @@ const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||||
{ to: "/alerts", label: "Alerts", icon: Bell },
|
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||||
{ to: "/observations", label: "Observations", icon: Eye },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
const result = await refreshFleetDevices();
|
const result = await refreshFleetDevices();
|
||||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||||
const msg = failed.length
|
const msg = failed.length
|
||||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
|
||||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
await loadFleet();
|
await loadFleet();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -190,7 +190,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>Devices</CardTitle>
|
<CardTitle>Devices</CardTitle>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{devices.length} fleet rows from stored observations and known
|
{devices.length} fleet rows from agent snapshots and known
|
||||||
devices
|
devices
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Zap } from "lucide-react";
|
import { Minus, Plus, Zap } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
attachDeviceIdentifier,
|
attachDeviceIdentifier,
|
||||||
createKnownDevice,
|
createKnownDevice,
|
||||||
|
detachDeviceIdentifier,
|
||||||
mergeKnownDevice,
|
mergeKnownDevice,
|
||||||
type FleetDevice,
|
type FleetDevice,
|
||||||
type KnownDevice,
|
type KnownDevice,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -52,67 +55,120 @@ export function FleetDeviceDetailsDialog({
|
|||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState("");
|
||||||
|
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||||
|
const [addValue, setAddValue] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(device?.display_name ?? "");
|
setDisplayName(device?.display_name ?? "");
|
||||||
setTargetDeviceId("");
|
setTargetDeviceId("");
|
||||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||||
|
setAddKind("mac");
|
||||||
|
setAddValue("");
|
||||||
setError("");
|
setError("");
|
||||||
|
setActionBusy(false);
|
||||||
}, [device]);
|
}, [device]);
|
||||||
|
|
||||||
|
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||||
|
|
||||||
|
// Resolve the full KnownDevice object if this fleet row is known
|
||||||
|
const fullKnown = knownDevices.find(
|
||||||
|
(kd) => kd.device_id === device?.known_device?.device_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Identifiers from the row that are NOT already on the known device
|
||||||
|
const unattachedIdentifiers = useMemo(() => {
|
||||||
|
if (!fullKnown) return rowIdentifiers;
|
||||||
|
const existing = new Set(
|
||||||
|
fullKnown.identifiers.map((id) => `${id.kind}:${id.value}`),
|
||||||
|
);
|
||||||
|
return rowIdentifiers.filter(
|
||||||
|
(id) => !existing.has(`${id.kind}:${id.value}`),
|
||||||
|
);
|
||||||
|
}, [fullKnown, rowIdentifiers]);
|
||||||
|
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
|
|
||||||
const currentDevice = device;
|
const currentDevice = device;
|
||||||
const identifiers = identifiersFor(currentDevice);
|
|
||||||
const otherKnownDevices = knownDevices.filter(
|
const otherKnownDevices = knownDevices.filter(
|
||||||
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function createRemembered() {
|
async function wrap(fn: () => Promise<void>) {
|
||||||
setError("");
|
setError("");
|
||||||
|
setActionBusy(true);
|
||||||
try {
|
try {
|
||||||
await createKnownDevice({
|
await fn();
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRemembered() {
|
||||||
|
await wrap(async () => {
|
||||||
|
const result = await createKnownDevice({
|
||||||
display_name: displayName.trim() || currentDevice.display_name,
|
display_name: displayName.trim() || currentDevice.display_name,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
identifiers,
|
identifiers: rowIdentifiers,
|
||||||
});
|
});
|
||||||
onChanged();
|
toast.success(`Remembered as "${result.display_name}"`);
|
||||||
} catch (err) {
|
});
|
||||||
setError(String(err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachToExisting() {
|
async function handleAttachToExisting() {
|
||||||
if (!targetDeviceId) return;
|
if (!targetDeviceId) return;
|
||||||
setError("");
|
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||||
try {
|
await wrap(async () => {
|
||||||
for (const identifier of identifiers) {
|
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
|
||||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
for (const id of ids) {
|
||||||
|
await attachDeviceIdentifier(targetDeviceId, id);
|
||||||
}
|
}
|
||||||
onChanged();
|
toast.success(
|
||||||
} catch (err) {
|
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
|
||||||
setError(String(err));
|
);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeIntoExisting() {
|
async function handleMerge() {
|
||||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||||
setError("");
|
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||||
try {
|
await wrap(async () => {
|
||||||
await mergeKnownDevice(
|
await mergeKnownDevice(
|
||||||
targetDeviceId,
|
targetDeviceId,
|
||||||
currentDevice.known_device.device_id,
|
currentDevice.known_device!.device_id,
|
||||||
);
|
);
|
||||||
onChanged();
|
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
});
|
||||||
setError(String(err));
|
}
|
||||||
}
|
|
||||||
|
async function handleAddIdentifier() {
|
||||||
|
if (!fullKnown || !addValue.trim()) return;
|
||||||
|
await wrap(async () => {
|
||||||
|
await attachDeviceIdentifier(fullKnown!.device_id, {
|
||||||
|
kind: addKind,
|
||||||
|
value: addValue.trim(),
|
||||||
|
});
|
||||||
|
setAddValue("");
|
||||||
|
toast.success(`Added ${addKind} identifier`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDetachIdentifier(identifierKey: string) {
|
||||||
|
if (!fullKnown) return;
|
||||||
|
await wrap(async () => {
|
||||||
|
await detachDeviceIdentifier(fullKnown!.device_id, identifierKey);
|
||||||
|
toast.success("Identifier removed");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRoute =
|
const selectedRoute =
|
||||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
device.route_candidates.find((r) => r.route_id === routeId) ??
|
||||||
device.recommended_route;
|
device.recommended_route;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,7 +183,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{device.known_device
|
{device.known_device
|
||||||
? "Remembered fleet device"
|
? "Remembered fleet device"
|
||||||
: "Observed fleet device"}
|
: "Unidentified fleet device"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<PresenceBadge presence={device.presence} />
|
<PresenceBadge presence={device.presence} />
|
||||||
@@ -150,8 +206,8 @@ export function FleetDeviceDetailsDialog({
|
|||||||
/>
|
/>
|
||||||
<DetailBlock
|
<DetailBlock
|
||||||
label="Agents"
|
label="Agents"
|
||||||
values={device.agents.map((agent) =>
|
values={device.agents.map((a) =>
|
||||||
agentLabel(agent.agent_id, agent.nickname),
|
agentLabel(a.agent_id, a.nickname),
|
||||||
)}
|
)}
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
/>
|
/>
|
||||||
@@ -159,6 +215,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Wake route ── */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-medium">Wake route</h3>
|
<h3 className="text-sm font-medium">Wake route</h3>
|
||||||
@@ -171,10 +228,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
{busy ? "Waking" : "Wake"}
|
{busy ? "Waking" : "Wake"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? "")}>
|
||||||
value={routeId}
|
|
||||||
onValueChange={(value) => setRouteId(value ?? "")}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>
|
<span>
|
||||||
{selectedRoute
|
{selectedRoute
|
||||||
@@ -194,68 +248,250 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Identity section ── */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<h3 className="text-sm font-medium">Remember / merge</h3>
|
<h3 className="text-sm font-medium">Identity</h3>
|
||||||
{!device.known_device && (
|
|
||||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
{/* ── Known device: show metadata + identifiers ── */}
|
||||||
<Input
|
{fullKnown && (
|
||||||
value={displayName}
|
<div className="grid gap-3">
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
placeholder="Device name"
|
<Badge variant="secondary">known</Badge>
|
||||||
/>
|
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
|
||||||
<Button
|
<span className="font-medium">{fullKnown.display_name}</span>
|
||||||
onClick={() => void createRemembered()}
|
{fullKnown.notes && (
|
||||||
disabled={!identifiers.length}
|
<span className="text-muted-foreground">
|
||||||
>
|
— {fullKnown.notes}
|
||||||
Remember device
|
</span>
|
||||||
</Button>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current identifiers with remove buttons */}
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
Identifiers
|
||||||
|
</div>
|
||||||
|
{fullKnown.identifiers.length ? (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{fullKnown.identifiers.map((id) => (
|
||||||
|
<div
|
||||||
|
key={id.identifier_key}
|
||||||
|
className="flex items-center justify-between gap-2 rounded px-1 py-0.5 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="mr-1.5 text-[0.65rem]"
|
||||||
|
>
|
||||||
|
{id.kind}
|
||||||
|
</Badge>
|
||||||
|
{id.value}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDetachIdentifier(id.identifier_key)
|
||||||
|
}
|
||||||
|
disabled={actionBusy}
|
||||||
|
aria-label={`Remove ${id.kind} ${id.value}`}
|
||||||
|
>
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No identifiers attached
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick-add from row values */}
|
||||||
|
{unattachedIdentifiers.length > 0 && (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add from this device row:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{unattachedIdentifiers.map((id) => (
|
||||||
|
<Button
|
||||||
|
key={`${id.kind}:${id.value}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() =>
|
||||||
|
void wrap(async () => {
|
||||||
|
await attachDeviceIdentifier(
|
||||||
|
fullKnown!.device_id,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
toast.success(`Added ${id.kind}: ${id.value}`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
{id.kind}: {id.value}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual add identifier */}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add identifier manually:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-[6rem_minmax(0,1fr)_auto] gap-2">
|
||||||
|
<Select
|
||||||
|
value={addKind}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setAddKind((v as "mac" | "ip") ?? "mac")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<span>{addKind}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
|
<SelectItem value="mac">mac</SelectItem>
|
||||||
|
<SelectItem value="ip">ip</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={addValue}
|
||||||
|
onChange={(e) => setAddValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
addKind === "mac" ? "aa:bb:cc:dd:ee:ff" : "192.168.1.100"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleAddIdentifier()}
|
||||||
|
disabled={actionBusy || !addValue.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merge into another known device */}
|
||||||
|
<Separator />
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Merge this known device into another:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
|
<Select
|
||||||
|
value={targetDeviceId}
|
||||||
|
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<span>
|
||||||
|
{targetDeviceId
|
||||||
|
? knownDevices.find(
|
||||||
|
(kd) => kd.device_id === targetDeviceId,
|
||||||
|
)?.display_name
|
||||||
|
: "Select target device"}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,30rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{otherKnownDevices.map((kd) => (
|
||||||
|
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||||
|
{kd.display_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleMerge()}
|
||||||
|
disabled={actionBusy || !targetDeviceId}
|
||||||
|
>
|
||||||
|
Merge
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Unknown device: remember or attach ── */}
|
||||||
|
{!device.known_device && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{/* Remember as new known device */}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Remember as a new known device:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
|
<Input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Device name"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleCreateRemembered()}
|
||||||
|
disabled={actionBusy || !rowIdentifiers.length}
|
||||||
|
>
|
||||||
|
Remember device
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Identifiers:{" "}
|
||||||
|
{rowIdentifiers
|
||||||
|
.map((id) => `${id.kind}:${id.value}`)
|
||||||
|
.join(", ") || "none"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attach to existing known device */}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Or attach to an existing known device:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
|
<Select
|
||||||
|
value={targetDeviceId}
|
||||||
|
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<span>
|
||||||
|
{targetDeviceId
|
||||||
|
? knownDevices.find(
|
||||||
|
(kd) => kd.device_id === targetDeviceId,
|
||||||
|
)?.display_name
|
||||||
|
: "Select known device"}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,30rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{knownDevices.map((kd) => (
|
||||||
|
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||||
|
{kd.display_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleAttachToExisting()}
|
||||||
|
disabled={
|
||||||
|
actionBusy || !targetDeviceId || !rowIdentifiers.length
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Attach IDs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
|
||||||
<Select
|
|
||||||
value={targetDeviceId}
|
|
||||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<span>
|
|
||||||
{targetDeviceId
|
|
||||||
? knownDevices.find(
|
|
||||||
(known) => known.device_id === targetDeviceId,
|
|
||||||
)?.display_name
|
|
||||||
: "Select known device"}
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
className="max-w-[min(92vw,30rem)]"
|
|
||||||
alignItemWithTrigger={false}
|
|
||||||
>
|
|
||||||
{otherKnownDevices.map((known) => (
|
|
||||||
<SelectItem key={known.device_id} value={known.device_id}>
|
|
||||||
{known.display_name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void attachToExisting()}
|
|
||||||
disabled={!targetDeviceId || !identifiers.length}
|
|
||||||
>
|
|
||||||
Attach IDs
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void mergeIntoExisting()}
|
|
||||||
disabled={!targetDeviceId || !device.known_device}
|
|
||||||
>
|
|
||||||
Merge
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Proposed identifiers:{" "}
|
|
||||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
|
||||||
"-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchObservationHistory,
|
|
||||||
type AgentDeviceObservation,
|
|
||||||
type AgentDeviceObservationEvent,
|
|
||||||
} from "@/api";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
formatSeen,
|
|
||||||
observationLabel,
|
|
||||||
observationStateLabel,
|
|
||||||
} from "@/pages/observations/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
observation: AgentDeviceObservation | null;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ObservationHistoryDialog({
|
|
||||||
observation,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: Props) {
|
|
||||||
const [events, setEvents] = useState<AgentDeviceObservationEvent[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !observation) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
fetchObservationHistory({
|
|
||||||
observationKey: observation.observation_key,
|
|
||||||
limit: 100,
|
|
||||||
})
|
|
||||||
.then(setEvents)
|
|
||||||
.catch((err) => {
|
|
||||||
setEvents([]);
|
|
||||||
setError(String(err));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [open, observation]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Observation History</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{observation
|
|
||||||
? observationLabel(observation)
|
|
||||||
: "No observation selected"}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
|
||||||
<div className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
|
|
||||||
<span>Time</span>
|
|
||||||
<span>State</span>
|
|
||||||
<span>Source</span>
|
|
||||||
<span>Network</span>
|
|
||||||
<span>Known Device</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.event_id}
|
|
||||||
className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
|
||||||
>
|
|
||||||
<span className="truncate text-muted-foreground">
|
|
||||||
{formatSeen(event.ts_unix)}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<Badge
|
|
||||||
variant={event.action === "remove" ? "outline" : "secondary"}
|
|
||||||
>
|
|
||||||
{observationStateLabel(event.action)}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
<span className="flex min-w-0 gap-1">
|
|
||||||
<Badge variant="outline">{event.kind}</Badge>
|
|
||||||
<Badge variant="outline">{event.action}</Badge>
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 text-muted-foreground">
|
|
||||||
<span className="block truncate">{event.ip ?? "-"}</span>
|
|
||||||
<span className="block truncate">{event.mac ?? "-"}</span>
|
|
||||||
{event.hostname && (
|
|
||||||
<span className="block truncate">{event.hostname}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{event.known_device?.display_name ?? "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!events.length && (
|
|
||||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
|
||||||
{loading ? "Loading history..." : "No history found"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
identifierSummary,
|
|
||||||
observationLabel,
|
|
||||||
} from "@/pages/observations/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
observation: AgentDeviceObservation;
|
|
||||||
devices: KnownDevice[];
|
|
||||||
busy: boolean;
|
|
||||||
selectedDeviceId: string;
|
|
||||||
newName: string;
|
|
||||||
onSelectDevice: (deviceId: string) => void;
|
|
||||||
onNewNameChange: (name: string) => void;
|
|
||||||
onAttachExisting: () => void;
|
|
||||||
onCreateAndAttach: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ObservationIdentityActions({
|
|
||||||
observation,
|
|
||||||
devices,
|
|
||||||
busy,
|
|
||||||
selectedDeviceId,
|
|
||||||
newName,
|
|
||||||
onSelectDevice,
|
|
||||||
onNewNameChange,
|
|
||||||
onAttachExisting,
|
|
||||||
onCreateAndAttach,
|
|
||||||
}: Props) {
|
|
||||||
const attachable = Boolean(observation.mac || observation.ip);
|
|
||||||
const selectedDevice = devices.find(
|
|
||||||
(device) => device.device_id === selectedDeviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-w-0 gap-2">
|
|
||||||
<div className="flex min-w-0 gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedDeviceId}
|
|
||||||
onValueChange={(value: string | null) => onSelectDevice(value ?? "")}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="min-w-0 flex-1">
|
|
||||||
<span className="truncate">
|
|
||||||
{selectedDevice?.display_name ?? "Attach to existing"}
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
className="max-w-[min(92vw,28rem)]"
|
|
||||||
alignItemWithTrigger={false}
|
|
||||||
>
|
|
||||||
{devices.map((device) => (
|
|
||||||
<SelectItem key={device.device_id} value={device.device_id}>
|
|
||||||
<span className="grid min-w-0 gap-0.5">
|
|
||||||
<span className="truncate">{device.display_name}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{identifierSummary(device)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || !attachable || !selectedDeviceId}
|
|
||||||
onClick={onAttachExisting}
|
|
||||||
>
|
|
||||||
Attach
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 gap-2">
|
|
||||||
<Input
|
|
||||||
value={newName}
|
|
||||||
onChange={(event) => onNewNameChange(event.target.value)}
|
|
||||||
placeholder={`Create as ${observationLabel(observation)}`}
|
|
||||||
disabled={busy || !attachable}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || !attachable}
|
|
||||||
onClick={onCreateAndAttach}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ObservationIdentityActions } from "@/pages/observations/ObservationIdentityActions";
|
|
||||||
import {
|
|
||||||
formatSeen,
|
|
||||||
observationLabel,
|
|
||||||
observationStateLabel,
|
|
||||||
} from "@/pages/observations/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
observations: AgentDeviceObservation[];
|
|
||||||
devices: KnownDevice[];
|
|
||||||
selectedDevices: Record<string, string>;
|
|
||||||
newNames: Record<string, string>;
|
|
||||||
busyKey: string;
|
|
||||||
onSelectDevice: (observationKey: string, deviceId: string) => void;
|
|
||||||
onNewNameChange: (observationKey: string, name: string) => void;
|
|
||||||
onAttachExisting: (observation: AgentDeviceObservation) => void;
|
|
||||||
onCreateAndAttach: (observation: AgentDeviceObservation) => void;
|
|
||||||
onOpenHistory: (observation: AgentDeviceObservation) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ObservationTable({
|
|
||||||
observations,
|
|
||||||
devices,
|
|
||||||
selectedDevices,
|
|
||||||
newNames,
|
|
||||||
busyKey,
|
|
||||||
onSelectDevice,
|
|
||||||
onNewNameChange,
|
|
||||||
onAttachExisting,
|
|
||||||
onCreateAndAttach,
|
|
||||||
onOpenHistory,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2 overflow-x-auto">
|
|
||||||
<div className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
|
||||||
<span>Device</span>
|
|
||||||
<span>Network</span>
|
|
||||||
<span>Agent</span>
|
|
||||||
<span>Seen</span>
|
|
||||||
<span>Known Device</span>
|
|
||||||
<span>History</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{observations.map((observation) => {
|
|
||||||
const busy = busyKey === observation.observation_key;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={observation.observation_key}
|
|
||||||
className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">
|
|
||||||
{observationLabel(observation)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
|
||||||
<Badge variant="outline">{observation.kind}</Badge>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
observation.last_action === "remove"
|
|
||||||
? "outline"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{observationStateLabel(observation.last_action)}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">{observation.last_action}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-muted-foreground">
|
|
||||||
<div className="truncate" title={observation.ip ?? "-"}>
|
|
||||||
{observation.ip ?? "-"}
|
|
||||||
</div>
|
|
||||||
<div className="truncate" title={observation.mac ?? "-"}>
|
|
||||||
{observation.mac ?? "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 truncate text-muted-foreground">
|
|
||||||
{observation.agent_id}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-muted-foreground">
|
|
||||||
<div className="truncate">
|
|
||||||
{formatSeen(observation.last_seen_unix)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
{observation.known_device ? (
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<Badge variant="secondary">known</Badge>
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{observation.known_device.display_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{observation.known_device.pinned ? "pinned" : "not pinned"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ObservationIdentityActions
|
|
||||||
observation={observation}
|
|
||||||
devices={devices}
|
|
||||||
busy={busy}
|
|
||||||
selectedDeviceId={
|
|
||||||
selectedDevices[observation.observation_key] ?? ""
|
|
||||||
}
|
|
||||||
newName={newNames[observation.observation_key] ?? ""}
|
|
||||||
onSelectDevice={(deviceId) =>
|
|
||||||
onSelectDevice(observation.observation_key, deviceId)
|
|
||||||
}
|
|
||||||
onNewNameChange={(name) =>
|
|
||||||
onNewNameChange(observation.observation_key, name)
|
|
||||||
}
|
|
||||||
onAttachExisting={() => onAttachExisting(observation)}
|
|
||||||
onCreateAndAttach={() => onCreateAndAttach(observation)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onOpenHistory(observation)}
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!observations.length && (
|
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
|
||||||
No observations found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
attachObservationIdentifier,
|
|
||||||
createKnownDevice,
|
|
||||||
fetchKnownDevices,
|
|
||||||
fetchObservations,
|
|
||||||
type AgentDeviceObservation,
|
|
||||||
type KnownDevice,
|
|
||||||
} from "@/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ObservationHistoryDialog } from "@/pages/observations/ObservationHistoryDialog";
|
|
||||||
import { ObservationTable } from "@/pages/observations/ObservationTable";
|
|
||||||
import {
|
|
||||||
observationLabel,
|
|
||||||
type ObservationFilter,
|
|
||||||
} from "@/pages/observations/utils";
|
|
||||||
|
|
||||||
export function ObservationsPage() {
|
|
||||||
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
|
||||||
const [filter, setFilter] = useState<ObservationFilter>("all");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [selectedDevices, setSelectedDevices] = useState<
|
|
||||||
Record<string, string>
|
|
||||||
>({});
|
|
||||||
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
|
||||||
const [busyKey, setBusyKey] = useState("");
|
|
||||||
const [historyObservation, setHistoryObservation] =
|
|
||||||
useState<AgentDeviceObservation | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const [nextObservations, nextDevices] = await Promise.all([
|
|
||||||
fetchObservations({ limit: 500 }),
|
|
||||||
fetchKnownDevices(),
|
|
||||||
]);
|
|
||||||
setObservations(nextObservations);
|
|
||||||
setDevices(nextDevices);
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return observations.filter((observation) => {
|
|
||||||
if (filter === "known" && !observation.known_device) return false;
|
|
||||||
if (filter === "unknown" && observation.known_device) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
observation.hostname,
|
|
||||||
observation.mac,
|
|
||||||
observation.ip,
|
|
||||||
observation.agent_id,
|
|
||||||
observation.kind,
|
|
||||||
observation.last_action,
|
|
||||||
observation.known_device?.display_name,
|
|
||||||
]
|
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase();
|
|
||||||
return haystack.includes(q);
|
|
||||||
});
|
|
||||||
}, [filter, observations, query]);
|
|
||||||
|
|
||||||
async function attachExisting(observation: AgentDeviceObservation) {
|
|
||||||
const deviceId = selectedDevices[observation.observation_key];
|
|
||||||
if (!deviceId) return;
|
|
||||||
setBusyKey(observation.observation_key);
|
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const device = await attachObservationIdentifier(
|
|
||||||
deviceId,
|
|
||||||
observation.observation_key,
|
|
||||||
);
|
|
||||||
setStatus(
|
|
||||||
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
|
||||||
);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setBusyKey("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAndAttach(observation: AgentDeviceObservation) {
|
|
||||||
const displayName =
|
|
||||||
newNames[observation.observation_key]?.trim() ||
|
|
||||||
observationLabel(observation);
|
|
||||||
if (!displayName.trim()) return;
|
|
||||||
setBusyKey(observation.observation_key);
|
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const device = await createKnownDevice({
|
|
||||||
display_name: displayName,
|
|
||||||
pinned: true,
|
|
||||||
identifiers: [],
|
|
||||||
});
|
|
||||||
const updated = await attachObservationIdentifier(
|
|
||||||
device.device_id,
|
|
||||||
observation.observation_key,
|
|
||||||
);
|
|
||||||
setStatus(
|
|
||||||
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
|
||||||
);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setBusyKey("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
|
||||||
<CardTitle>Observed Devices</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void load()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Refreshing..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
|
||||||
<span>Status</span>
|
|
||||||
<Select
|
|
||||||
value={filter}
|
|
||||||
onValueChange={(value: ObservationFilter | null) => {
|
|
||||||
if (value) setFilter(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<span>{filter}</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent alignItemWithTrigger={false}>
|
|
||||||
<SelectItem value="unknown">unknown</SelectItem>
|
|
||||||
<SelectItem value="known">known</SelectItem>
|
|
||||||
<SelectItem value="all">all</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
|
||||||
<span>Search</span>
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="hostname, mac, ip, agent, known device"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {filtered.length} of {observations.length}
|
|
||||||
</p>
|
|
||||||
{status && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
|
||||||
{status}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ObservationTable
|
|
||||||
observations={filtered}
|
|
||||||
devices={devices}
|
|
||||||
selectedDevices={selectedDevices}
|
|
||||||
newNames={newNames}
|
|
||||||
busyKey={busyKey}
|
|
||||||
onSelectDevice={(observationKey, deviceId) =>
|
|
||||||
setSelectedDevices((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[observationKey]: deviceId,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onNewNameChange={(observationKey, name) =>
|
|
||||||
setNewNames((prev) => ({ ...prev, [observationKey]: name }))
|
|
||||||
}
|
|
||||||
onAttachExisting={(observation) => void attachExisting(observation)}
|
|
||||||
onCreateAndAttach={(observation) => void createAndAttach(observation)}
|
|
||||||
onOpenHistory={setHistoryObservation}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ObservationHistoryDialog
|
|
||||||
observation={historyObservation}
|
|
||||||
open={Boolean(historyObservation)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setHistoryObservation(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
|
||||||
|
|
||||||
export type ObservationFilter = "all" | "unknown" | "known";
|
|
||||||
|
|
||||||
export function observationLabel(observation: AgentDeviceObservation): string {
|
|
||||||
return (
|
|
||||||
observation.hostname?.trim() ||
|
|
||||||
observation.mac?.trim() ||
|
|
||||||
observation.ip?.trim() ||
|
|
||||||
observation.observation_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSeen(tsUnix: number): string {
|
|
||||||
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
|
||||||
return new Date(tsUnix * 1000).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function identifierSummary(device: KnownDevice): string {
|
|
||||||
if (!device.identifiers.length) return "no identifiers";
|
|
||||||
return device.identifiers
|
|
||||||
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function observationStateLabel(action: string): string {
|
|
||||||
if (action === "remove") return "stale";
|
|
||||||
if (action === "add" || action === "update" || action === "old") {
|
|
||||||
return "present";
|
|
||||||
}
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user