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:
+128
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)]`
|
|
||||||
@@ -13,6 +13,7 @@ pub use service::{
|
|||||||
};
|
};
|
||||||
pub use wakey_linux::observations::{
|
pub use wakey_linux::observations::{
|
||||||
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
||||||
|
prune_stale_observations_from_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-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
|
Severity
|
||||||
<Select
|
<Select
|
||||||
value={severity}
|
value={severity}
|
||||||
@@ -93,7 +96,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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
|
Status
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
@@ -114,7 +120,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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
|
Kind
|
||||||
<Select
|
<Select
|
||||||
value={kind}
|
value={kind}
|
||||||
@@ -170,7 +179,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<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
|
Search
|
||||||
<Input
|
<Input
|
||||||
id="alert-transition-search"
|
id="alert-transition-search"
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-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
|
Event type
|
||||||
<Select
|
<Select
|
||||||
value={eventType}
|
value={eventType}
|
||||||
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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
|
Outcome
|
||||||
<Select
|
<Select
|
||||||
value={outcome}
|
value={outcome}
|
||||||
@@ -112,7 +118,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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
|
Search
|
||||||
<Input
|
<Input
|
||||||
id="audit-search"
|
id="audit-search"
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export function CommandsPage({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="grid gap-3" onSubmit={submit}>
|
<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>
|
<span>Agent</span>
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
agents={agents}
|
agents={agents}
|
||||||
@@ -80,7 +83,10 @@ export function CommandsPage({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
<span>Command</span>
|
||||||
<Select
|
<Select
|
||||||
value={kind}
|
value={kind}
|
||||||
@@ -98,7 +104,10 @@ export function CommandsPage({
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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>
|
<span>Query</span>
|
||||||
<Input
|
<Input
|
||||||
id="cmd-query"
|
id="cmd-query"
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
<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>
|
<span>Search</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
<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}
|
values={knownFilters}
|
||||||
onChange={(value) => setKnown(value as KnownFilter)}
|
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>
|
<span>Agent</span>
|
||||||
<Select
|
<Select
|
||||||
value={agentId}
|
value={agentId}
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ export function TokensPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{useCustom && (
|
{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>
|
<span>TTL (seconds)</span>
|
||||||
<Input
|
<Input
|
||||||
id="token-custom-ttl"
|
id="token-custom-ttl"
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ export function WakeToolsPage({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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>
|
<span>Agent</span>
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
agents={agents}
|
agents={agents}
|
||||||
@@ -111,7 +114,10 @@ export function WakeToolsPage({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<form onSubmit={handleWake} className="grid gap-3">
|
<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>
|
<span>Target</span>
|
||||||
<Input
|
<Input
|
||||||
id="wake-target"
|
id="wake-target"
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function FleetDeviceDetailsDialog({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
||||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
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 [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||||
const [addValue, setAddValue] = useState("");
|
const [addValue, setAddValue] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ pub struct InitConfigArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub agent_token: Option<String>,
|
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.
|
/// Replace an existing config file.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
|
|||||||
@@ -11,6 +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;
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
@@ -23,6 +24,8 @@ pub struct AgentConfig {
|
|||||||
pub reconnect_max_ms: u64,
|
pub reconnect_max_ms: u64,
|
||||||
#[serde(default = "default_observation_sync_interval_seconds")]
|
#[serde(default = "default_observation_sync_interval_seconds")]
|
||||||
pub observation_sync_interval_seconds: u64,
|
pub observation_sync_interval_seconds: u64,
|
||||||
|
#[serde(default = "default_observation_retention_days")]
|
||||||
|
pub observation_retention_days: u64,
|
||||||
#[serde(default = "default_pid_file")]
|
#[serde(default = "default_pid_file")]
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
#[serde(default = "default_dhcp_leases_path")]
|
#[serde(default = "default_dhcp_leases_path")]
|
||||||
@@ -45,6 +48,10 @@ impl fmt::Debug for AgentConfig {
|
|||||||
"observation_sync_interval_seconds",
|
"observation_sync_interval_seconds",
|
||||||
&self.observation_sync_interval_seconds,
|
&self.observation_sync_interval_seconds,
|
||||||
)
|
)
|
||||||
|
.field(
|
||||||
|
"observation_retention_days",
|
||||||
|
&self.observation_retention_days,
|
||||||
|
)
|
||||||
.field("pid_file", &self.pid_file)
|
.field("pid_file", &self.pid_file)
|
||||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||||
@@ -65,6 +72,10 @@ const fn default_observation_sync_interval_seconds() -> u64 {
|
|||||||
60
|
60
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn default_observation_retention_days() -> u64 {
|
||||||
|
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||||
|
}
|
||||||
|
|
||||||
fn default_pid_file() -> PathBuf {
|
fn default_pid_file() -> PathBuf {
|
||||||
DEFAULT_PID_FILE.into()
|
DEFAULT_PID_FILE.into()
|
||||||
}
|
}
|
||||||
@@ -183,6 +194,7 @@ mod tests {
|
|||||||
reconnect_base_ms: 123,
|
reconnect_base_ms: 123,
|
||||||
reconnect_max_ms: 456,
|
reconnect_max_ms: 456,
|
||||||
observation_sync_interval_seconds: 7,
|
observation_sync_interval_seconds: 7,
|
||||||
|
observation_retention_days: 3,
|
||||||
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
||||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> 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 query = req.into_inventory_query();
|
||||||
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
|
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
|
||||||
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
|
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,
|
&config.mac_name_cache_path,
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||||
&config.observation_store_path,
|
&config.observation_store_path,
|
||||||
)
|
)
|
||||||
@@ -104,7 +127,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
|||||||
rows = inventory.devices.len(),
|
rows = inventory.devices.len(),
|
||||||
"dispatched inventory command"
|
"dispatched inventory command"
|
||||||
);
|
);
|
||||||
Ok(CommandResult::Inventory(inventory))
|
Ok(inventory)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
|
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ pub async fn enroll(
|
|||||||
observation_sync_interval_seconds: base_config
|
observation_sync_interval_seconds: base_config
|
||||||
.map(|config| config.observation_sync_interval_seconds)
|
.map(|config| config.observation_sync_interval_seconds)
|
||||||
.unwrap_or(60),
|
.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
|
pid_file: base_config
|
||||||
.map(|config| config.pid_file.clone())
|
.map(|config| config.pid_file.clone())
|
||||||
.unwrap_or_else(|| crate::config::DEFAULT_PID_FILE.into()),
|
.unwrap_or_else(|| crate::config::DEFAULT_PID_FILE.into()),
|
||||||
@@ -166,6 +169,7 @@ mod tests {
|
|||||||
reconnect_base_ms: 2_000,
|
reconnect_base_ms: 2_000,
|
||||||
reconnect_max_ms: 60_000,
|
reconnect_max_ms: 60_000,
|
||||||
observation_sync_interval_seconds: 30,
|
observation_sync_interval_seconds: 30,
|
||||||
|
observation_retention_days: 11,
|
||||||
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
||||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/custom-names.json".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_id, "agent-123");
|
||||||
assert_eq!(config.agent_token, "token-xyz");
|
assert_eq!(config.agent_token, "token-xyz");
|
||||||
assert_eq!(config.server_url, "https://control.example.com");
|
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.pid_file, base_config.pid_file);
|
||||||
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||||
assert!(outcome.backup_path.is_none());
|
assert!(outcome.backup_path.is_none());
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
|||||||
reconnect_base_ms: 1_000,
|
reconnect_base_ms: 1_000,
|
||||||
reconnect_max_ms: 30_000,
|
reconnect_max_ms: 30_000,
|
||||||
observation_sync_interval_seconds: 60,
|
observation_sync_interval_seconds: 60,
|
||||||
|
observation_retention_days: config::DEFAULT_OBSERVATION_RETENTION_DAYS,
|
||||||
pid_file: config::DEFAULT_PID_FILE.into(),
|
pid_file: config::DEFAULT_PID_FILE.into(),
|
||||||
dhcp_leases_path: "/tmp/dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/wakey_mac_names.json".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 {
|
if let Some(agent_token) = args.agent_token {
|
||||||
cfg.agent_token = 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 {
|
if let Some(path) = &args.config {
|
||||||
config::save_config(path, &cfg)?;
|
config::save_config(path, &cfg)?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|||||||
use tracing::{debug, error, info, info_span, warn};
|
use tracing::{debug, error, info, info_span, warn};
|
||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::dispatch::dispatch_command;
|
use crate::dispatch::{dispatch_command, inventory_for_config};
|
||||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||||
|
|
||||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||||
@@ -150,8 +150,15 @@ where
|
|||||||
S: SinkExt<Message> + Unpin,
|
S: SinkExt<Message> + Unpin,
|
||||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let query = wakey_core::InventoryQueryBuilder::new().build();
|
let req = crate::protocol::InventoryRequest {
|
||||||
let inventory = wakey::inventory(query)
|
query: None,
|
||||||
|
name: None,
|
||||||
|
ips: Vec::new(),
|
||||||
|
devs: Vec::new(),
|
||||||
|
nuds: Vec::new(),
|
||||||
|
macs: Vec::new(),
|
||||||
|
};
|
||||||
|
let inventory = inventory_for_config(req, config)
|
||||||
.await
|
.await
|
||||||
.context("failed to run inventory for device snapshot")?;
|
.context("failed to run inventory for device snapshot")?;
|
||||||
let count = inventory.devices.len();
|
let count = inventory.devices.len();
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ use crate::parse::mac;
|
|||||||
|
|
||||||
/// Product-level presence derived from raw neighbor state.
|
/// 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.
|
/// `std::cmp::max` picks the most-online signal when merging.
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize, Default,
|
Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize, Default,
|
||||||
)]
|
)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum Presence {
|
pub enum Presence {
|
||||||
Offline,
|
|
||||||
#[default]
|
#[default]
|
||||||
Unknown,
|
Unknown,
|
||||||
|
Offline,
|
||||||
LikelyOnline,
|
LikelyOnline,
|
||||||
Online,
|
Online,
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ impl Device {
|
|||||||
names.insert(name);
|
names.insert(name);
|
||||||
}
|
}
|
||||||
if let Some(ip) = observation.ip {
|
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 {
|
if let Some(mac) = observation.mac {
|
||||||
macs.insert(mac);
|
macs.insert(mac);
|
||||||
|
|||||||
@@ -161,6 +161,38 @@ pub async fn prune_removed_observations_from_path(path: impl AsRef<Path>) -> io:
|
|||||||
Ok(removed)
|
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> {
|
fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> {
|
||||||
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
||||||
out.extend(
|
out.extend(
|
||||||
@@ -577,4 +609,56 @@ mod tests {
|
|||||||
|
|
||||||
let _ = tokio::fs::remove_file(observation_path).await;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user