ui: removes observations (goodbye) add manual identifier actions

This commit is contained in:
lda
2026-05-03 04:09:06 +07:00 Verified
parent 64cf5f9e69
commit 038d8ee03c
11 changed files with 345 additions and 792 deletions
+1 -2
View File
@@ -24,7 +24,6 @@ import { DashboardPage } from "@/pages/DashboardPage";
import { DevicesPage } from "@/pages/DevicesPage";
import { TokensPage } from "@/pages/TokensPage";
import { WakeToolsPage } from "@/pages/WakeToolsPage";
import { ObservationsPage } from "@/pages/ObservationsPage";
type LoadState = "idle" | "loading" | "ready" | "error";
@@ -215,7 +214,7 @@ export function App() {
}
/>
<Route path="tokens" element={<TokensPage />} />
<Route path="observations" element={<ObservationsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
+10 -71
View File
@@ -96,32 +96,6 @@ export type KnownDeviceSummary = {
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 = {
agent_id: string;
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: {
display_name: string;
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(
deviceId: 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(
targetDeviceId: string,
sourceDeviceId: string,
-2
View File
@@ -11,7 +11,6 @@ import {
Bot,
FileText,
Key,
Eye,
Terminal,
PanelLeftClose,
PanelLeft,
@@ -49,7 +48,6 @@ const navSections: NavSection[] = [
items: [
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
{ to: "/alerts", label: "Alerts", icon: Bell },
{ to: "/observations", label: "Observations", icon: Eye },
],
},
{
+3 -3
View File
@@ -97,8 +97,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
const result = await refreshFleetDevices();
const failed = result.agents.filter((agent) => agent.status !== "ok");
const msg = failed.length
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
toast.success(msg);
await loadFleet();
} catch (err) {
@@ -190,7 +190,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
<div>
<CardTitle>Devices</CardTitle>
<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
</p>
</div>
-1
View File
@@ -1 +0,0 @@
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
+331 -95
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from "react";
import { Zap } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Minus, Plus, Zap } from "lucide-react";
import { toast } from "sonner";
import {
attachDeviceIdentifier,
createKnownDevice,
detachDeviceIdentifier,
mergeKnownDevice,
type FleetDevice,
type KnownDevice,
} from "@/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -52,67 +55,120 @@ export function FleetDeviceDetailsDialog({
const [displayName, setDisplayName] = useState("");
const [targetDeviceId, setTargetDeviceId] = useState("");
const [routeId, setRouteId] = useState("");
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
const [addValue, setAddValue] = useState("");
const [error, setError] = useState("");
const [actionBusy, setActionBusy] = useState(false);
useEffect(() => {
setDisplayName(device?.display_name ?? "");
setTargetDeviceId("");
setRouteId(device?.recommended_route?.route_id ?? "");
setAddKind("mac");
setAddValue("");
setError("");
setActionBusy(false);
}, [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;
const currentDevice = device;
const identifiers = identifiersFor(currentDevice);
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("");
setActionBusy(true);
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,
pinned: true,
identifiers,
identifiers: rowIdentifiers,
});
onChanged();
} catch (err) {
setError(String(err));
}
toast.success(`Remembered as "${result.display_name}"`);
});
}
async function attachToExisting() {
async function handleAttachToExisting() {
if (!targetDeviceId) return;
setError("");
try {
for (const identifier of identifiers) {
await attachDeviceIdentifier(targetDeviceId, identifier);
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
await wrap(async () => {
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
for (const id of ids) {
await attachDeviceIdentifier(targetDeviceId, id);
}
onChanged();
} catch (err) {
setError(String(err));
}
toast.success(
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
);
});
}
async function mergeIntoExisting() {
async function handleMerge() {
if (!targetDeviceId || !currentDevice.known_device) return;
setError("");
try {
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
await wrap(async () => {
await mergeKnownDevice(
targetDeviceId,
currentDevice.known_device.device_id,
currentDevice.known_device!.device_id,
);
onChanged();
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
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 =
device.route_candidates.find((route) => route.route_id === routeId) ??
device.route_candidates.find((r) => r.route_id === routeId) ??
device.recommended_route;
return (
@@ -127,7 +183,7 @@ export function FleetDeviceDetailsDialog({
<DialogDescription>
{device.known_device
? "Remembered fleet device"
: "Observed fleet device"}
: "Unidentified fleet device"}
</DialogDescription>
</div>
<PresenceBadge presence={device.presence} />
@@ -150,8 +206,8 @@ export function FleetDeviceDetailsDialog({
/>
<DetailBlock
label="Agents"
values={device.agents.map((agent) =>
agentLabel(agent.agent_id, agent.nickname),
values={device.agents.map((a) =>
agentLabel(a.agent_id, a.nickname),
)}
onCopy={onCopy}
/>
@@ -159,6 +215,7 @@ export function FleetDeviceDetailsDialog({
<Separator />
{/* ── Wake route ── */}
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Wake route</h3>
@@ -171,10 +228,7 @@ export function FleetDeviceDetailsDialog({
{busy ? "Waking" : "Wake"}
</Button>
</div>
<Select
value={routeId}
onValueChange={(value) => setRouteId(value ?? "")}
>
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? "")}>
<SelectTrigger>
<span>
{selectedRoute
@@ -194,68 +248,250 @@ export function FleetDeviceDetailsDialog({
<Separator />
{/* ── Identity section ── */}
<div className="grid gap-3">
<h3 className="text-sm font-medium">Remember / merge</h3>
{!device.known_device && (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
<Input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Device name"
/>
<Button
onClick={() => void createRemembered()}
disabled={!identifiers.length}
>
Remember device
</Button>
<h3 className="text-sm font-medium">Identity</h3>
{/* ── Known device: show metadata + identifiers ── */}
{fullKnown && (
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant="secondary">known</Badge>
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
<span className="font-medium">{fullKnown.display_name}</span>
{fullKnown.notes && (
<span className="text-muted-foreground">
{fullKnown.notes}
</span>
)}
</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 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>
<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>
);
}
-32
View File
@@ -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;
}