add Endpoint table in the details
This commit is contained in:
+1
-1
@@ -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.
|
- 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 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.
|
- 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.
|
- 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**, **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.
|
- **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.
|
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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ export type FleetDeviceAgent = {
|
|||||||
last_seen_unix: number;
|
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 = {
|
export type FleetWakeRoute = {
|
||||||
route_id: string;
|
route_id: string;
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
@@ -111,7 +125,9 @@ export type FleetWakeRoute = {
|
|||||||
mac: string | null;
|
mac: string | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
|
interface: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
presence: string;
|
||||||
last_seen_unix: number;
|
last_seen_unix: number;
|
||||||
wakeable: boolean;
|
wakeable: boolean;
|
||||||
};
|
};
|
||||||
@@ -126,6 +142,7 @@ export type FleetDevice = {
|
|||||||
hostnames: string[];
|
hostnames: string[];
|
||||||
agents: FleetDeviceAgent[];
|
agents: FleetDeviceAgent[];
|
||||||
sources: string[];
|
sources: string[];
|
||||||
|
endpoints: FleetDeviceEndpoint[];
|
||||||
first_seen_unix: number | null;
|
first_seen_unix: number | null;
|
||||||
last_seen_unix: number | null;
|
last_seen_unix: number | null;
|
||||||
presence: string;
|
presence: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
detachDeviceIdentifier,
|
detachDeviceIdentifier,
|
||||||
mergeKnownDevice,
|
mergeKnownDevice,
|
||||||
type FleetDevice,
|
type FleetDevice,
|
||||||
|
type FleetDeviceEndpoint,
|
||||||
type KnownDevice,
|
type KnownDevice,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -29,7 +30,12 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
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 = {
|
type Props = {
|
||||||
device: FleetDevice | null;
|
device: FleetDevice | null;
|
||||||
@@ -225,6 +231,30 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
<Separator />
|
<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 ── */}
|
{/* ── 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">
|
||||||
@@ -513,3 +543,52 @@ export function FleetDeviceDetailsDialog({
|
|||||||
</Dialog>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export const presenceFilters = [
|
|||||||
"all",
|
"all",
|
||||||
"online",
|
"online",
|
||||||
"likely_online",
|
"likely_online",
|
||||||
"unknown",
|
|
||||||
"offline",
|
"offline",
|
||||||
|
"unknown",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||||
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
|
|||||||
export function presenceRank(presence: string): number {
|
export function presenceRank(presence: string): number {
|
||||||
if (presence === "online") return 4;
|
if (presence === "online") return 4;
|
||||||
if (presence === "likely_online") return 3;
|
if (presence === "likely_online") return 3;
|
||||||
if (presence === "unknown") return 2;
|
if (presence === "offline") return 2;
|
||||||
if (presence === "offline") return 1;
|
if (presence === "unknown") return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function routeLabel(route: FleetWakeRoute): string {
|
export function routeLabel(route: FleetWakeRoute): string {
|
||||||
const status = route.connected ? "connected" : "offline";
|
const status = route.connected ? "connected" : "agent offline";
|
||||||
const target = route.mac
|
const target = route.mac
|
||||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||||
: (route.ip ?? "-");
|
: (route.ip ?? "-");
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const WAKEY_OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
|||||||
const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases";
|
const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases";
|
||||||
const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
|
const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
|
||||||
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.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)]
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
@@ -214,4 +214,18 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user