add Endpoint table in the details

This commit is contained in:
lda
2026-05-27 02:17:40 +07:00 Verified
parent ba95257309
commit 69b5313005
6 changed files with 119 additions and 9 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ _Avoid_: Endpoint state, device presence
- API arrays should be domain-sorted by default; UI may sort top-level fleet rows for presentation, but route ranking and recommended route selection are backend-owned.
- The main fleet API embeds **Endpoints** in each **Device** response; a separate endpoint API is unnecessary until payload size or access patterns require it.
- The control plane stores current per-agent endpoint snapshots, not endpoint event history.
- Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement; default retention is 30 days unless configured otherwise.
- Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement; default retention is 7 days unless configured otherwise.
- Live-source **Endpoints** use snapshot time as their observed time; hook-derived **Endpoints** use the hook row's recorded last-seen time.
- **DeviceId**, **Endpoint Key**, and **Agent Endpoint Key** are typed concepts; string keys are boundary serialization only.
- **DeviceId** is derived from a device's **Endpoints**: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent.
@@ -138,7 +138,7 @@ Known-device identifiers are source-independent ownership claims over MAC or per
The control plane stores current per-agent endpoint snapshots as structured columns, not opaque `Device` JSON and not endpoint event history. Summary MAC/IP collections are derived from endpoints; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement. The default retention is 30 days unless configured otherwise. Live-source endpoints use snapshot time as their observed time; hook-derived endpoints use the hook row's recorded last-seen time.
Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement. The default retention is 7 days unless configured otherwise. Live-source endpoints use snapshot time as their observed time; hook-derived endpoints use the hook row's recorded last-seen time.
## Consequences
+17
View File
@@ -103,6 +103,20 @@ export type FleetDeviceAgent = {
last_seen_unix: number;
};
export type FleetDeviceEndpoint = {
agent_id: string;
nickname?: string | null;
connected: boolean;
source: string;
mac: string | null;
ip: string | null;
hostname: string | null;
interface: string | null;
presence: string;
first_seen_unix: number | null;
last_seen_unix: number | null;
};
export type FleetWakeRoute = {
route_id: string;
agent_id: string;
@@ -111,7 +125,9 @@ export type FleetWakeRoute = {
mac: string | null;
ip: string | null;
hostname: string | null;
interface: string | null;
source: string;
presence: string;
last_seen_unix: number;
wakeable: boolean;
};
@@ -126,6 +142,7 @@ export type FleetDevice = {
hostnames: string[];
agents: FleetDeviceAgent[];
sources: string[];
endpoints: FleetDeviceEndpoint[];
first_seen_unix: number | null;
last_seen_unix: number | null;
presence: string;
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Minus, Plus, Zap } from "lucide-react";
import { Copy, Minus, Plus, Zap } from "lucide-react";
import { toast } from "sonner";
import {
@@ -8,6 +8,7 @@ import {
detachDeviceIdentifier,
mergeKnownDevice,
type FleetDevice,
type FleetDeviceEndpoint,
type KnownDevice,
} from "@/api";
import { Badge } from "@/components/ui/badge";
@@ -29,7 +30,12 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
import {
agentLabel,
formatSeen,
identifiersFor,
routeLabel,
} from "@/pages/fleet/utils";
type Props = {
device: FleetDevice | null;
@@ -225,6 +231,30 @@ export function FleetDeviceDetailsDialog({
<Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Endpoints</h3>
<Badge variant="outline">{device.endpoints.length}</Badge>
</div>
{device.endpoints.length ? (
<div className="divide-y overflow-hidden rounded-md border">
{device.endpoints.map((endpoint, index) => (
<EndpointRow
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
endpoint={endpoint}
onCopy={onCopy}
/>
))}
</div>
) : (
<p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
No endpoint evidence reported.
</p>
)}
</div>
<Separator />
{/* ── Wake route ── */}
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
@@ -513,3 +543,52 @@ export function FleetDeviceDetailsDialog({
</Dialog>
);
}
function EndpointRow({
endpoint,
onCopy,
}: {
endpoint: FleetDeviceEndpoint;
onCopy: (label: string, value: string) => void;
}) {
const target = [endpoint.mac, endpoint.ip].filter(Boolean).join(" / ");
const location = [endpoint.interface, endpoint.hostname]
.filter(Boolean)
.join(" / ");
return (
<div className="grid gap-2 p-3 text-sm sm:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<PresenceBadge presence={endpoint.presence} />
<Badge variant="secondary">{endpoint.source.replace("_", " ")}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{agentLabel(endpoint.agent_id, endpoint.nickname)}
{endpoint.connected ? " (connected)" : " (offline)"}
</p>
</div>
<div className="min-w-0">
<p className="truncate font-mono text-xs">{target || "-"}</p>
<p className="truncate text-xs text-muted-foreground">
{location || "No interface or hostname"}
</p>
</div>
<div className="flex items-center justify-between gap-2 sm:justify-end">
<span className="text-xs text-muted-foreground">
{formatSeen(endpoint.last_seen_unix)}
</span>
{target && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onCopy("Endpoint", target)}
aria-label={`Copy endpoint ${target}`}
>
<Copy className="size-3.5" />
</Button>
)}
</div>
</div>
);
}
+4 -4
View File
@@ -14,8 +14,8 @@ export const presenceFilters = [
"all",
"online",
"likely_online",
"unknown",
"offline",
"unknown",
] as const;
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
export function presenceRank(presence: string): number {
if (presence === "online") return 4;
if (presence === "likely_online") return 3;
if (presence === "unknown") return 2;
if (presence === "offline") return 1;
if (presence === "offline") return 2;
if (presence === "unknown") return 1;
return 0;
}
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
}
export function routeLabel(route: FleetWakeRoute): string {
const status = route.connected ? "connected" : "offline";
const status = route.connected ? "connected" : "agent offline";
const target = route.mac
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
: (route.ip ?? "-");
+15 -1
View File
@@ -11,7 +11,7 @@ const WAKEY_OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases";
const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 30;
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig {
@@ -214,4 +214,18 @@ mod tests {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn config_without_retention_uses_current_default() {
let config: AgentConfig = toml::from_str(
r#"
server_url = "https://example.com"
agent_id = "agent-1"
agent_token = "secret"
"#,
)
.expect("config should parse");
assert_eq!(config.observation_retention_days, 7);
}
}