fmt + add and wire observation retention

what ever new thing could add stuff in CONTEXT.md and docs/adr/ hmmmmmmmmmmmmmmmmmmmmmm
This commit is contained in:
lda
2026-05-15 16:15:09 +07:00 Verified
parent 6fc3dee12d
commit 134d3d0ef9
20 changed files with 544 additions and 97 deletions
+128
View File
@@ -0,0 +1,128 @@
# Wakey
Wakey models devices discovered on a local network and the control-plane view used to wake and manage them across agents.
## Language
**Device**:
An operator-facing aggregate that represents one discovered network identity candidate.
_Avoid_: Host, machine, route
**Device Summary**:
The derived names, MACs, IPs, and presence shown on a device aggregate.
_Avoid_: Source of truth, route data
**Endpoint**:
One observed network contact point for a device, including the agent/source, optional MAC, optional IP, presence, and observation time.
_Avoid_: Raw observation, route, IP row
**Endpoint Source**:
The typed origin of an endpoint, such as neighbor data, DHCP lease data, or a hotplug hook.
_Avoid_: String kind, source label
**Observation Fact**:
Debug evidence copied from local observation inputs before it is interpreted into endpoints.
_Avoid_: Endpoint, identifier, source of truth
**Presence**:
The current reachability interpretation for a device or endpoint.
_Avoid_: Endpoint state, status
**Wake Route**:
An endpoint that has enough information and agent availability to attempt waking a device.
_Avoid_: Device, endpoint when not wakeable
**Known Device**:
A device identity manually curated by the operator.
_Avoid_: Auto-pinned device, saved observation
**Identifier**:
A manually approved MAC or IP value that links an observed device or endpoint to a known device.
_Avoid_: Observation key, route id
**Endpoint Key**:
The typed identity of endpoint evidence, based on source and optional MAC/IP.
_Avoid_: Stringly typed route id
**Agent Endpoint Key**:
An endpoint key scoped to the agent that observed it.
_Avoid_: Concatenated agent/source/MAC/IP string
**Agent**:
A fleet member that observes local network state and sends device snapshots to the control plane.
_Avoid_: Router when referring to the software actor
**Agent Host**:
The machine running an agent, including host identity and runtime metadata.
_Avoid_: Device when referring to the agent machine
**Agent Capability**:
Something an agent host can observe or do on the network.
_Avoid_: Router flag
**Interface Telemetry**:
Current operational measurements for a network interface visible to an agent.
_Avoid_: Endpoint state, device presence
## Relationships
- A **Device** has zero or more **Endpoints**.
- A **Device Summary** is derived from a **Device** and its **Endpoints**.
- Local source rows are grouped into **Devices** by endpoint-derived **DeviceId**, not by exact **Endpoint Key**.
- Within one complete local snapshot, MAC+IP endpoints may absorb IP-only endpoints with the same IP; this merge is not remembered across snapshots unless the operator creates an **Identifier**.
- An **Endpoint** belongs to exactly one **Agent**.
- An **Endpoint** has one **Endpoint Key**.
- An **Agent Endpoint Key** combines an **Agent** with an **Endpoint Key**.
- An **Endpoint** may have both MAC and IP, MAC only, or IP only.
- An **Endpoint** has exactly one **Endpoint Source**.
- An **Endpoint** has one **Presence**.
- An **Endpoint** may name the network interface where it was observed.
- An **Observation Fact** may explain why an **Endpoint** exists, but does not define identity or wakeability.
- A **Wake Route** is derived from exactly one **Endpoint**.
- A **Wake Route** may carry the interface from its source **Endpoint**.
- A **Known Device** has zero or more **Identifiers**.
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
- An **Agent** runs on one **Agent Host**.
- An **Agent Capability** describes what an **Agent Host** can observe or do.
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
## Example dialogue
> **Dev:** "This **Device** has three IPs. Which one should the UI wake?"
> **Domain expert:** "Do not infer that from the **Device** summary. Use the **Wake Routes** derived from its **Endpoints**."
## Flagged ambiguities
- "route" was used for both observed network contact points and wakeable paths — resolved: use **Endpoint** for observed contact points and **Wake Route** only when the endpoint can be used for wake.
- "device" was used for both an identity aggregate and each MAC/IP pair — resolved: use **Device** for the aggregate and **Endpoint** for each observed MAC/IP/source/state tuple.
- `Device.ips` and `Device.macs` are true summary values, but they are not authoritative for wake, freshness, or per-IP state decisions — resolved: use **Endpoint** for those decisions.
- MAC-only and IP-only observations are still **Endpoints** — resolved: **Wake Routes** require a MAC and a connected **Agent**, while IP is optional route detail.
- Endpoint-specific states such as "removed" or "stale" are not separate concepts yet — resolved: use **Presence** until a distinction appears that presence cannot express cleanly.
- Endpoint origin is not a free string — resolved: use **Endpoint Source** for domain logic and reserve raw strings for debug facts only.
- Local observation file rows map naturally to **Observation Facts** — resolved: keep them for debugging, but do not use them as the operational model.
- Offline **Endpoints** remain part of the API model — resolved: UI may collapse them from the primary view, but the API should still expose them for details, copy, and debugging.
- **Device Summary** IPs come only from concrete live-ish sources such as neighbor table rows and current DHCP lease rows — resolved: hotplug hook memory creates **Endpoints** and **Observation Facts**, but does not directly inflate summary IPs.
- Router-ness is not a boolean domain concept — resolved: model **Agent Capabilities** and **Agent Host** metadata instead.
- **Presence** ordering should treat **Unknown** as non-voting/no evidence, weaker than **Offline** — resolved: concrete offline evidence should not be overridden by unknown hook or DHCP ambiguity.
- **Device** presence is derived from all **Endpoints**, not only summary-eligible endpoints.
- Current DHCP leases are summary-eligible **Endpoints** with **Unknown** presence; they may support wake when a MAC is present, but they do not prove reachability.
- DHCP hook events, including removal, indicate **Unknown** because they describe lease churn rather than reachability.
- Neighbor table and neighbor hook events may indicate **Offline** when they report failed or removed reachability.
- **Presence** and wakeability are separate: an **Offline** endpoint may still produce a **Wake Route** if it has a MAC and a connected **Agent**.
- **Endpoint** does not store wakeability; wakeability is derived from endpoint data plus **Agent** runtime and capability context.
- Preferred **Wake Routes** are ordered by connected agent, wakeability, having an IP, presence quality, recency, then source quality.
- **Endpoint** evidence preserves source-specific rows; equivalent **Wake Routes** may ignore source when they target the same agent, MAC, and IP.
- The control plane stores **Endpoints** as structured data so fleet search, identity matching, and wake route selection do not depend on parsing opaque device JSON.
- 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.
- 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.
- 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.
- Hostnames are endpoint evidence/display data, not part of **Endpoint Key** identity.
- Interface names are endpoint metadata, not part of **Endpoint Key** identity; multi-segment routers may require scoped endpoint identity later if the same source/MAC/IP has different meaning on different interfaces.
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
+57
View File
@@ -0,0 +1,57 @@
# lda rambling
currently the collection of fleetwakeroutes doesnt make much sense.
it goes deep. Read code, then this again.
## big fleet shows everything.
all the IPs, offline or not. I at least want to hide the offline/unknown IPs into a drawer somewhere. its too cluttered.
i do need offline IPs, because, a failed / dhcp-rotated IP may be of use. but what use? copying only. You cant even send shit to it, its FAILED.
so not all the IPs should be immediately invisible.
## big fleet DOESNT show everything
yea showing all the offline IP but only ONE online ip per mac for wake routes.
BRING This BACK. i need ALL the routes. Color coded (already has that with disabled wake button)
## big collect collects everything
at wakey, collection makes no sense; a device saves all IPs without saying which of them is not real.
oh you do have to search again for the observations because they are unknown ahh hints,
## logics are misleading
also IPs without macs are unknown, (should be OFFLINE? ips without macs? what do you think it is)
while macs without ips are offline (pulled straight from the Big Mac Name cache)
what is this ahh logic
## struct names are misleading
Big Device over here, collected (fresh AND old) From the router ITSELF. Is BY MAC.
but the Device struct was designed to take multiple MACs, Why. This is very misleading.
The migration was from Observations, which is a string fest, to Device, which
1. give up the very cool observations based design, which fundamentally is just tracking identifing pairs of (ip, mac, maybe hostname)
2. doesnt solve the confusing ahh mac-keyed Devices. Why is the deviceid optional? What!
## big code needs a rewrite
i think we need to have a very clear model. And such model is ALREADY PRESENT! fleet wake route MIGHT be the coolest thing ever, solves everything.
Should type instead of free string construction. For example. alot of the keys are currently freehanded. You need a struct with Display or ToString or a dedicated method.
hopefully those keys can have a helper contructor. HOPEFULLY NOONE PULLS INFO OUT OF THE STRING BY SLICING IT.
If you want to keep just device.macs, device.ips, you can, we need to collect the Wake routes! which goes deep,
but is reasonable, because there are two sources that give us identifiers, neighbors and observations. We flatten this to device, at the router its keyed by mac or failed ip.
At the fleet idfk, i dont control that part of the code; its all AI.
So, if we can collect wake targets directly from device. Saves a whole plane of problems. suddenly wakey-cc is like 6 million times slimmer, because this is a wakey or wakey-core problem.
@@ -0,0 +1,149 @@
# Core Endpoints and Wake Routes
Wakey will model observed network contact points as **Endpoints** in `wakey-core`, and derive **Wake Routes** from those endpoints instead of reconstructing routes from flattened device summary MAC/IP arrays. `Device.ips` and `Device.macs` remain true summary fields for operator display and compatibility, but endpoint data is authoritative for wake, freshness, source, per-IP state, and route selection.
**Status**: accepted
## Context
The previous model stored a `Device` aggregate with summary `macs`, `ips`, `presence`, and raw observation facts. The control plane then built fleet wake routes from combinations like first MAC plus first IP. That lost source, presence, interface, and MAC/IP-pair evidence, especially when hook memory contained removed IPs or when several sources described the same address differently.
## Decision
Add a typed `DeviceEndpoint` concept in `wakey-core`. A device snapshot sent by an agent contains devices with endpoints already attached. The control plane stores structured endpoint rows and derives fleet summaries and wake routes from those rows.
Endpoint shape:
```rust
DeviceEndpoint {
key: EndpointKey,
hostname: Option<String>,
interface: Option<String>,
presence: Presence,
first_seen_unix: Option<u64>,
last_seen_unix: Option<u64>,
}
EndpointKey {
source: EndpointSource,
mac: Option<MacAddr>,
ip: Option<IpAddr>,
}
AgentEndpointKey {
agent_id: String,
endpoint: EndpointKey,
}
```
`EndpointKey` must contain at least one of MAC or IP. Hostname and interface are endpoint evidence/metadata, not identity. `AgentEndpointKey` may use `serde(flatten)` for wire shape, but hash values are never durable storage keys. String keys are boundary serialization only.
## Source and Presence Rules
Endpoint sources are typed domain variants, not free strings:
```text
Neighbor
DhcpLease
HookNeighbor
HookDhcp
```
Presence answers “is this reachable?”, not “can this be woken?” The presence order is:
```text
Unknown < Offline < LikelyOnline < Online
```
Source mapping:
```text
Neighbor reachable/permanent -> Online
Neighbor stale -> LikelyOnline
Neighbor failed/remove-like evidence -> Offline
Other neighbor states -> Unknown
Current DHCP lease -> Unknown
Hook DHCP add/update/remove -> Unknown
Hook neighbor add/update/old -> LikelyOnline
Hook neighbor remove -> Offline
```
Current DHCP leases are summary-eligible and may support wake if they include a MAC, but they do not prove reachability. Hook memory creates endpoints and observation facts, but does not directly inflate summary IPs.
## Summary, Presence, and UI
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
The main fleet API embeds endpoints inside each fleet device. API responses expose all endpoints, including offline and unknown endpoints. The UI may collapse offline/unknown endpoints in the primary row, but details must expose them for copy/debug/history.
## Wakeability and Wake Route Ordering
Wakeability is derived, not stored on `DeviceEndpoint`.
An endpoint is wakeable when:
```text
endpoint has a MAC
agent is connected
agent has wake capability
endpoint source is allowed for wake route derivation
```
For now, a connected wakey-agent is assumed to have wake capability. Presence does not gate wakeability: an `Offline` endpoint may still produce a wake route, because waking powered-off devices is the core use case.
Preferred wake route order:
```text
connected agent
wakeable route
has IP
presence quality
newest last_seen
source quality
```
Presence quality for route ranking follows:
```text
Online > LikelyOnline > Offline > Unknown
```
Source quality follows:
```text
Neighbor > DhcpLease > HookNeighbor > HookDhcp
```
Equivalent wake routes may ignore endpoint source when they target the same agent, MAC, and IP. Endpoint evidence preserves source-specific rows; route grouping may deduplicate equivalent targets.
## Device and Identity Rules
`DeviceId` remains the typed observed identity enum:
```rust
DeviceId::Mac(MacAddr)
DeviceId::Ip(IpAddr)
```
`DeviceId` is derived from endpoints: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent. Local source rows are grouped into devices by endpoint-derived `DeviceId`, not exact `EndpointKey`.
Within a single complete local snapshot, a MAC+IP endpoint may absorb an IP-only endpoint with the same IP. This merge is not remembered across snapshots unless the operator creates a known-device identifier.
Known-device identifiers are source-independent ownership claims over MAC or permanent IP values. Wakey does not infer whether an IP is permanent, static, or reserved; the UI should warn the operator when attaching an IP identifier.
## Control Plane Storage
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.
## Consequences
- Wake route construction moves away from `macs.first()` / `ips.first()` guessing.
- The agent/core layer owns source interpretation because it still has local context.
- The control plane can search, filter, match known identifiers, and rank wake routes from structured endpoint rows.
- UI can show clean summaries while keeping complete endpoint evidence in details.
- This preserves “wake an offline machine by MAC” without pretending that offline means not wakeable.
-74
View File
@@ -1,74 +0,0 @@
# Report: Device State Rewrite
## What changed
The control plane now stores **complete per-agent Device snapshots** instead of flat observation rows. The agent sends `Vec<Device>` (merged from DHCP, neighbors, hooks). The control plane upserts per agent, deletes stale, fleet view merges across agents at read time.
## Architecture (before → after)
**Before**: Agent → raw hook observations → control plane flattens into rows → re-merges at fleet read → presence recomputed from flat actions
**After**: Agent → inventory → `Vec<Device>` with correct presence → control plane stores as-is → fleet merge at read time from typed rows
## Schema
Old tables dropped: `agent_device_observations`, `agent_device_observation_events`, `agent_observation_snapshots`, `agent_observation_snapshot_keys`
New tables:
- `agent_devices` — per-agent device row (keyed by `DeviceId` serialization)
- `agent_device_macs` — MACs per device, indexed for cross-agent merge
- `agent_device_ips` — IPs per device, indexed for cross-agent merge
- `agent_device_hostnames` — hostnames per device
- `agent_device_facts` — debug/source material (JSON, not identity)
## Concrete types throughout
- `FleetDevice.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`, `presence`: `Presence`
- `FleetWakeRoute.mac`: `Option<MacAddr>`, `ip`: `Option<IpAddr>`
- `AgentDeviceWithChildren.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`
- Row types (`AgentDeviceMacRow`, `AgentDeviceIpRow`) stay `String` from SQLite, convert via `TryFrom` impls
- `AgentDeviceRow` has `.presence()`, `.first_seen()`, `.last_seen()` methods — no string matching in business code
- `Presence` from wakey-core: `Ord`, `PartialOrd`, `From<&str>`, `.as_str()`
## Rust features used
- `TryFrom<&AgentDeviceMacRow> for MacAddr`, `TryFrom<&AgentDeviceIpRow> for IpAddr`
- `From<&str> for Presence`
- `#[allow(dead_code)]` where appropriate (test-only methods, debug fields)
- Workspace dependencies for shared crates (`macaddr`, `serde`, `tokio`, etc.)
- Shared `TestStore` with `Drop` guard for clean test teardown
## What was removed
- `AgentObservation` struct (replaced by `Device`)
- `AgentDeviceObservation`, `AgentDeviceObservationView`, `AgentDeviceObservationEvent`, `AgentDeviceObservationInput` types
- `inventory_result_to_observations()` — the flatten-then-rebuild function
- `upload_agent_observations`, `list_agent_observations`, `list_agent_observation_history` API endpoints
- `gc_stale_observations()` — no longer needed
- `SyncObservations` CLI command from agent
- `prune_removed_observations_from_path` calls from agent session
- Observations page from UI
## What stayed
- Agent observation store (`/tmp/wakey_observations.json`) — agent-local memory, not forwarded
- Hotplug hooks — still update agent-local store, feed next inventory
- Known devices + identifiers — manual lifecycle, never auto-deleted
- Audit events, alerts — unchanged
- `observation_retention` config field — `#[allow(dead_code)]`, kept for config file compat
## Verification
```
cargo fmt — clean
cargo clippy-all — clean (0 warnings)
cargo test-all — 89 tests pass, 6 ignored (on-device only)
pnpm typecheck — clean
```
## Remaining
- UI cleanup: remove observations page nav entry, observation API types from `api.ts`
- Known device identifier UI controls (API exists, UI needs affordance)
- The `observation_retention` config field could be removed in a future cleanup
- `list_agent_device_rows_for_agent` is `#[allow(dead_code)]` — used in tests, could be `#[cfg(test)]`
+1
View File
@@ -13,6 +13,7 @@ pub use service::{
};
pub use wakey_linux::observations::{
list_local_observations, observe_dhcp_event, observe_neighbor_event,
prune_stale_observations_from_path,
};
#[cfg(test)]
+16 -4
View File
@@ -72,7 +72,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
<label htmlFor="alert-severity" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="alert-severity"
className="grid gap-1 text-sm text-muted-foreground"
>
Severity
<Select
value={severity}
@@ -93,7 +96,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</Select>
</label>
<label htmlFor="alert-status" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="alert-status"
className="grid gap-1 text-sm text-muted-foreground"
>
Status
<Select
value={status}
@@ -114,7 +120,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</Select>
</label>
<label htmlFor="alert-kind" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="alert-kind"
className="grid gap-1 text-sm text-muted-foreground"
>
Kind
<Select
value={kind}
@@ -170,7 +179,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</span>
</CardHeader>
<CardContent className="space-y-2">
<label htmlFor="alert-transition-search" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="alert-transition-search"
className="grid gap-1 text-sm text-muted-foreground"
>
Search
<Input
id="alert-transition-search"
+12 -3
View File
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
<label htmlFor="audit-event-type" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="audit-event-type"
className="grid gap-1 text-sm text-muted-foreground"
>
Event type
<Select
value={eventType}
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
</Select>
</label>
<label htmlFor="audit-outcome" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="audit-outcome"
className="grid gap-1 text-sm text-muted-foreground"
>
Outcome
<Select
value={outcome}
@@ -112,7 +118,10 @@ export function AuditPage({ events, onRefresh }: Props) {
</Select>
</label>
<label htmlFor="audit-search" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="audit-search"
className="grid gap-1 text-sm text-muted-foreground"
>
Search
<Input
id="audit-search"
+12 -3
View File
@@ -71,7 +71,10 @@ export function CommandsPage({
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={submit}>
<label htmlFor="cmd-agent" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="cmd-agent"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Agent</span>
<AgentSelector
agents={agents}
@@ -80,7 +83,10 @@ export function CommandsPage({
/>
</label>
<label htmlFor="cmd-kind" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="cmd-kind"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Command</span>
<Select
value={kind}
@@ -98,7 +104,10 @@ export function CommandsPage({
</Select>
</label>
<label htmlFor="cmd-query" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="cmd-query"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Query</span>
<Input
id="cmd-query"
+8 -2
View File
@@ -225,7 +225,10 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
<label htmlFor="fleet-search" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="fleet-search"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Search</span>
<div className="relative">
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
@@ -250,7 +253,10 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
values={knownFilters}
onChange={(value) => setKnown(value as KnownFilter)}
/>
<label htmlFor="fleet-agent" className="grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="fleet-agent"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Agent</span>
<Select
value={agentId}
+4 -1
View File
@@ -219,7 +219,10 @@ export function TokensPage() {
</Button>
</div>
{useCustom && (
<label htmlFor="token-custom-ttl" className="mt-2 grid gap-1 text-sm text-muted-foreground">
<label
htmlFor="token-custom-ttl"
className="mt-2 grid gap-1 text-sm text-muted-foreground"
>
<span>TTL (seconds)</span>
<Input
id="token-custom-ttl"
+8 -2
View File
@@ -101,7 +101,10 @@ export function WakeToolsPage({
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label htmlFor="wake-agent" className="grid gap-1.5 text-sm text-muted-foreground">
<label
htmlFor="wake-agent"
className="grid gap-1.5 text-sm text-muted-foreground"
>
<span>Agent</span>
<AgentSelector
agents={agents}
@@ -111,7 +114,10 @@ export function WakeToolsPage({
</label>
<form onSubmit={handleWake} className="grid gap-3">
<label htmlFor="wake-target" className="grid gap-1.5 text-sm text-muted-foreground">
<label
htmlFor="wake-target"
className="grid gap-1.5 text-sm text-muted-foreground"
>
<span>Target</span>
<Input
id="wake-target"
@@ -54,7 +54,9 @@ export function FleetDeviceDetailsDialog({
}: Props) {
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
const [targetDeviceId, setTargetDeviceId] = useState("");
const [routeId, setRouteId] = useState(device?.recommended_route?.route_id ?? "");
const [routeId, setRouteId] = useState(
device?.recommended_route?.route_id ?? "",
);
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
const [addValue, setAddValue] = useState("");
const [error, setError] = useState("");
+4
View File
@@ -98,6 +98,10 @@ pub struct InitConfigArgs {
#[arg(long)]
pub agent_token: Option<String>,
/// Days to keep local hook observation rows since last seen. Zero disables pruning.
#[arg(long)]
pub observation_retention_days: Option<u64>,
/// Replace an existing config file.
#[arg(long)]
pub force: bool,
+12
View File
@@ -11,6 +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;
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig {
@@ -23,6 +24,8 @@ pub struct AgentConfig {
pub reconnect_max_ms: u64,
#[serde(default = "default_observation_sync_interval_seconds")]
pub observation_sync_interval_seconds: u64,
#[serde(default = "default_observation_retention_days")]
pub observation_retention_days: u64,
#[serde(default = "default_pid_file")]
pub pid_file: PathBuf,
#[serde(default = "default_dhcp_leases_path")]
@@ -45,6 +48,10 @@ impl fmt::Debug for AgentConfig {
"observation_sync_interval_seconds",
&self.observation_sync_interval_seconds,
)
.field(
"observation_retention_days",
&self.observation_retention_days,
)
.field("pid_file", &self.pid_file)
.field("dhcp_leases_path", &self.dhcp_leases_path)
.field("mac_name_cache_path", &self.mac_name_cache_path)
@@ -65,6 +72,10 @@ const fn default_observation_sync_interval_seconds() -> u64 {
60
}
const fn default_observation_retention_days() -> u64 {
DEFAULT_OBSERVATION_RETENTION_DAYS
}
fn default_pid_file() -> PathBuf {
DEFAULT_PID_FILE.into()
}
@@ -183,6 +194,7 @@ mod tests {
reconnect_base_ms: 123,
reconnect_max_ms: 456,
observation_sync_interval_seconds: 7,
observation_retention_days: 3,
pid_file: "/tmp/test-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
mac_name_cache_path: "/tmp/test-names.json".into(),
+24 -1
View File
@@ -64,6 +64,15 @@ async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
}
async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Result<CommandResult> {
Ok(CommandResult::Inventory(
inventory_for_config(req, config).await?,
))
}
pub async fn inventory_for_config(
req: InventoryRequest,
config: &AgentConfig,
) -> Result<wakey_core::DeviceInventory> {
let query = req.into_inventory_query();
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
@@ -72,6 +81,20 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
&config.mac_name_cache_path,
)
.await?;
match wakey::wakey_linux::observations::prune_stale_observations_from_path(
&config.observation_store_path,
config.observation_retention_days,
)
.await
{
Ok(removed) if removed > 0 => {
debug!(removed, "pruned stale local hook observations");
}
Ok(_) => {}
Err(err) => {
warn!(error = %err, "failed pruning stale local hook observations");
}
}
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
&config.observation_store_path,
)
@@ -104,7 +127,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
rows = inventory.devices.len(),
"dispatched inventory command"
);
Ok(CommandResult::Inventory(inventory))
Ok(inventory)
}
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
+5
View File
@@ -68,6 +68,9 @@ pub async fn enroll(
observation_sync_interval_seconds: base_config
.map(|config| config.observation_sync_interval_seconds)
.unwrap_or(60),
observation_retention_days: base_config
.map(|config| config.observation_retention_days)
.unwrap_or(crate::config::DEFAULT_OBSERVATION_RETENTION_DAYS),
pid_file: base_config
.map(|config| config.pid_file.clone())
.unwrap_or_else(|| crate::config::DEFAULT_PID_FILE.into()),
@@ -166,6 +169,7 @@ mod tests {
reconnect_base_ms: 2_000,
reconnect_max_ms: 60_000,
observation_sync_interval_seconds: 30,
observation_retention_days: 11,
pid_file: "/tmp/custom-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
mac_name_cache_path: "/tmp/custom-names.json".into(),
@@ -180,6 +184,7 @@ mod tests {
assert_eq!(config.agent_id, "agent-123");
assert_eq!(config.agent_token, "token-xyz");
assert_eq!(config.server_url, "https://control.example.com");
assert_eq!(config.observation_retention_days, 11);
assert_eq!(config.pid_file, base_config.pid_file);
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
assert!(outcome.backup_path.is_none());
+4
View File
@@ -255,6 +255,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
reconnect_base_ms: 1_000,
reconnect_max_ms: 30_000,
observation_sync_interval_seconds: 60,
observation_retention_days: config::DEFAULT_OBSERVATION_RETENTION_DAYS,
pid_file: config::DEFAULT_PID_FILE.into(),
dhcp_leases_path: "/tmp/dhcp.leases".into(),
mac_name_cache_path: "/tmp/wakey_mac_names.json".into(),
@@ -271,6 +272,9 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
if let Some(agent_token) = args.agent_token {
cfg.agent_token = agent_token;
}
if let Some(days) = args.observation_retention_days {
cfg.observation_retention_days = days;
}
if let Some(path) = &args.config {
config::save_config(path, &cfg)?;
+10 -3
View File
@@ -7,7 +7,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{debug, error, info, info_span, warn};
use crate::config::AgentConfig;
use crate::dispatch::dispatch_command;
use crate::dispatch::{dispatch_command, inventory_for_config};
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
pub async fn run(config: AgentConfig) -> Result<()> {
@@ -150,8 +150,15 @@ where
S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{
let query = wakey_core::InventoryQueryBuilder::new().build();
let inventory = wakey::inventory(query)
let req = crate::protocol::InventoryRequest {
query: None,
name: None,
ips: Vec::new(),
devs: Vec::new(),
nuds: Vec::new(),
macs: Vec::new(),
};
let inventory = inventory_for_config(req, config)
.await
.context("failed to run inventory for device snapshot")?;
let count = inventory.devices.len();
+3 -3
View File
@@ -8,16 +8,16 @@ use crate::parse::mac;
/// Product-level presence derived from raw neighbor state.
///
/// Variant order defines `Ord`: Offline < Unknown < LikelyOnline < Online.
/// Variant order defines `Ord`: Unknown < Offline < LikelyOnline < Online.
/// `std::cmp::max` picks the most-online signal when merging.
#[derive(
Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum Presence {
Offline,
#[default]
Unknown,
Offline,
LikelyOnline,
Online,
}
@@ -152,7 +152,7 @@ impl Device {
names.insert(name);
}
if let Some(ip) = observation.ip {
ips.insert(ip); // should we just add removed IPs? Or a separate map of presence -> IP sets?
ips.insert(ip); // is adding removed IPs right? Or should we have a separate map of presence -> IP sets?
}
if let Some(mac) = observation.mac {
macs.insert(mac);
+84
View File
@@ -161,6 +161,38 @@ pub async fn prune_removed_observations_from_path(path: impl AsRef<Path>) -> io:
Ok(removed)
}
pub async fn prune_stale_observations_from_path(
path: impl AsRef<Path>,
retention_days: u64,
) -> io::Result<usize> {
if retention_days == 0 {
return Ok(0);
}
let max_age_seconds = retention_days.saturating_mul(24 * 60 * 60);
prune_observations_older_than_from_path(path, now_unix().saturating_sub(max_age_seconds)).await
}
async fn prune_observations_older_than_from_path(
path: impl AsRef<Path>,
cutoff_unix: u64,
) -> io::Result<usize> {
let path = path.as_ref();
let mut store = load_observation_store_from_path(path).await?;
let before = store.dhcp_clients.len() + store.neighbors.len();
store
.dhcp_clients
.retain(|_, row| row.last_seen_unix >= cutoff_unix);
store
.neighbors
.retain(|_, row| row.last_seen_unix >= cutoff_unix);
let after = store.dhcp_clients.len() + store.neighbors.len();
let removed = before.saturating_sub(after);
if removed > 0 {
save_observation_store_to_path(path, &store).await?;
}
Ok(removed)
}
fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> {
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
out.extend(
@@ -577,4 +609,56 @@ mod tests {
let _ = tokio::fs::remove_file(observation_path).await;
}
#[tokio::test]
#[serial]
async fn stale_observation_prune_uses_last_seen_cutoff() {
let observation_path = temp_file("stale-observations");
let mut dhcp_clients = std::collections::BTreeMap::new();
dhcp_clients.insert(
"aa:bb:cc:dd:ee:01".to_string(),
ObservedDhcpClient {
mac: "aa:bb:cc:dd:ee:01".to_string(),
ip: Some("192.168.1.2".parse().expect("ip")),
hostname: Some("old".to_string()),
first_seen_unix: 1,
last_seen_unix: 10,
last_action: "remove".to_string(),
},
);
dhcp_clients.insert(
"aa:bb:cc:dd:ee:02".to_string(),
ObservedDhcpClient {
mac: "aa:bb:cc:dd:ee:02".to_string(),
ip: Some("192.168.1.3".parse().expect("ip")),
hostname: Some("new".to_string()),
first_seen_unix: 1,
last_seen_unix: 20,
last_action: "remove".to_string(),
},
);
let fixture = LocalObservationStore {
dhcp_clients,
neighbors: Default::default(),
};
tokio::fs::write(
&observation_path,
serde_json::to_string(&fixture).expect("fixture should serialize"),
)
.await
.expect("fixture should write");
let removed = prune_observations_older_than_from_path(&observation_path, 15)
.await
.expect("prune should succeed");
assert_eq!(removed, 1);
let store = load_observation_store_from_path(&observation_path)
.await
.expect("store should read");
assert!(!store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:01"));
assert!(store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:02"));
let _ = tokio::fs::remove_file(observation_path).await;
}
}