Compare commits
8
Commits
No files matched your search
+1
-1
@@ -18,7 +18,7 @@ tw = "t --target=x86_64-pc-windows-msvc"
|
||||
|
||||
|
||||
check-all = "check --workspace --all-features --all-targets"
|
||||
clippy-all = "clippy --workspace --all-features --all-targets"
|
||||
clippy-all = "clippy --workspace --all-targets --all-features" # -- -D warnings
|
||||
test-all = "test --workspace --all-features --all-targets"
|
||||
fmt-all = "fmt --all"
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# Wakey Checkpoint (2026-04-11)
|
||||
|
||||
## Snapshot
|
||||
|
||||
This checkpoint captures the current state after control-plane migration, logging hardening, config ergonomics, and state storage upgrades.
|
||||
|
||||
## What Is Done
|
||||
|
||||
- Legacy router-hosted HTTP/static layer removed from `wakey` crate.
|
||||
- New `wakey-control-plane` crate is active for:
|
||||
- enroll-token issuance
|
||||
- agent enrollment
|
||||
- connected-agent websocket registry
|
||||
- command relay to agent
|
||||
- `wakey-agent` is active for outbound websocket execution and local command dispatch.
|
||||
|
||||
## Logging + Telemetry
|
||||
|
||||
- High-signal logs were added across:
|
||||
- control-plane API relay path
|
||||
- control-plane websocket lifecycle
|
||||
- control-plane state lifecycle
|
||||
- agent command lifecycle, session lifecycle, and enrollment
|
||||
- Correlated relay spans include command context (`agent_id`, `request_id`, `command`).
|
||||
- Control-plane telemetry is config-driven:
|
||||
- optional OTLP endpoint
|
||||
- optional JSON logs
|
||||
- fallback local logs when OTLP endpoint is not set
|
||||
|
||||
## Config Ergonomics
|
||||
|
||||
### Control-plane
|
||||
|
||||
- Config file support is wired (`/etc/wakey-control-plane/config.toml` by default).
|
||||
- `serve` can read defaults from config file and CLI can override.
|
||||
- New `init-config` command scaffolds a control-plane config file.
|
||||
|
||||
### Agent
|
||||
|
||||
- Existing `init-config` command scaffolds agent config.
|
||||
- Enrollment can optionally signal reload of running daemon.
|
||||
|
||||
## State Storage Upgrade
|
||||
|
||||
- Control-plane state backend moved from JSON snapshot to embedded `sled` DB.
|
||||
- Default state path changed to `/var/lib/wakey-control-plane/state.db`.
|
||||
- Legacy JSON migration support has been removed; sled is now the only supported
|
||||
state format.
|
||||
- Enroll tokens include persisted expiry timestamps and are validated on enroll.
|
||||
- Periodic and explicit garbage collection remove expired tokens.
|
||||
|
||||
## Operator Commands
|
||||
|
||||
### Control-plane bootstrap
|
||||
|
||||
```sh
|
||||
wakey-control-plane init-config
|
||||
wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
|
||||
```
|
||||
|
||||
### Issue enroll token (live daemon path)
|
||||
|
||||
```sh
|
||||
wakey-control-plane issue-enroll-token --public-url https://cp.example.com
|
||||
```
|
||||
|
||||
### Agent bootstrap
|
||||
|
||||
```sh
|
||||
wakey-agent enroll --server-url https://cp.example.com --enroll-token <token>
|
||||
wakey-agent serve --config /etc/wakey-agent/config.toml
|
||||
```
|
||||
|
||||
## Build Health
|
||||
|
||||
- Last verified passing:
|
||||
- `cargo check --workspace`
|
||||
- `cargo clippy --workspace`
|
||||
|
||||
## Test Coverage Added In This Pass
|
||||
|
||||
- Unit tests in `wakey-control-plane/src/state/store.rs` now cover:
|
||||
- expired-token garbage collection removes persisted stale tokens
|
||||
- enroll rejects expired tokens and consumes stale entries
|
||||
- state stats counters for agents and expired token totals
|
||||
|
||||
## Known Tradeoffs / Follow-ups
|
||||
|
||||
- Reload semantics with `sled` are now mostly no-op for in-memory state (data is durable in DB).
|
||||
- No dedicated state-inspection CLI command yet (suggestion: add `state-stats` command).
|
||||
- OTLP configuration is currently control-plane focused; agent parity can be added if needed.
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
1. Add a control-plane `state-stats` command to print DB path, agent count, token count.
|
||||
2. Add symmetric telemetry config support in `wakey-agent` config file.
|
||||
3. Add integration tests for:
|
||||
- enroll + relay over websocket
|
||||
- legacy JSON-to-sled migration path
|
||||
- init-config command behavior and overrides
|
||||
@@ -1,82 +0,0 @@
|
||||
# Wakey Checkpoint (2026-04-12)
|
||||
|
||||
## Snapshot
|
||||
|
||||
This checkpoint captures progress after adding audit persistence, alert evaluation and transitions, control-plane alert APIs, and websocket timing diagnostics across agent and control-plane.
|
||||
|
||||
## Major Changes Landed
|
||||
|
||||
- Control-plane audit system implemented with persistent sled-backed events.
|
||||
- Audit emission wired into:
|
||||
- enroll accept/reject
|
||||
- token issue/list/revoke
|
||||
- command dispatch/result/error/timeout
|
||||
- websocket auth accept/reject and disconnect
|
||||
- Audit query API added:
|
||||
- `GET /api/v1/control/audit/events`
|
||||
- Active alert engine added with deterministic rules over audit + live session state.
|
||||
- Alert APIs added:
|
||||
- `GET /api/v1/control/alerts`
|
||||
- `GET /api/v1/control/alerts/history`
|
||||
- `GET /api/v1/control/alerts/ws`
|
||||
- Alert transition persistence added (open/resolve transitions tracked across evaluations).
|
||||
- Route classes split explicitly in runtime:
|
||||
- public routes (enroll/ws/health)
|
||||
- control routes (`/api/v1/control/*`)
|
||||
- Caddy template added for edge policy and Cloudflare Access boundary:
|
||||
- `deploy/Caddyfile.control-plane.example`
|
||||
|
||||
## Reliability and Diagnostics Improvements
|
||||
|
||||
- Agent websocket connect diagnostics now include:
|
||||
- DNS resolution timing (`dns_resolve_ms`)
|
||||
- websocket connect timing (`ws_connect_ms`)
|
||||
- Control-plane websocket lifecycle logs include:
|
||||
- connect-to-hello timing
|
||||
- connect-to-auth timing
|
||||
- hello-to-auth timing
|
||||
- Slow connect warnings are now emitted when timing thresholds are exceeded.
|
||||
|
||||
## Root-Cause Findings Captured
|
||||
|
||||
- Long agent websocket connect delays were reproduced and traced to hostname resolution path.
|
||||
- Switching agent `server_url` hostname to direct IP made connect immediate.
|
||||
- This confirms app-level relay logic was not the source of the startup delay.
|
||||
|
||||
## Verification Status
|
||||
|
||||
- `cargo check --workspace` passing after all changes.
|
||||
- Added and passing tests include:
|
||||
- audit event append/filter in state store
|
||||
- alert transition open/resolve persistence
|
||||
- alert evaluator rule checks (offline + timeout, auth/enroll rejection spikes)
|
||||
|
||||
## Current API Surface for UI Start
|
||||
|
||||
- Agents and command execution:
|
||||
- `GET /api/v1/control/agents`
|
||||
- `POST /api/v1/control/agents/{agent_id}/command`
|
||||
- Audits:
|
||||
- `GET /api/v1/control/audit/events`
|
||||
- Alerts:
|
||||
- `GET /api/v1/control/alerts`
|
||||
- `GET /api/v1/control/alerts/history`
|
||||
- websocket subscribe: `GET /api/v1/control/alerts/ws`
|
||||
|
||||
## Remaining Plan Items (Most Significant)
|
||||
|
||||
1. UI implementation (`/ui` app shell and pages) is still open.
|
||||
2. Alert dedupe/cooldown persistence and tuning are still basic and need hardening.
|
||||
3. Audit retention pruning policy and long-run storage controls are not finalized.
|
||||
4. Edge auth enforcement tests and deployment rehearsals remain to be added.
|
||||
5. Multi-day soak drills and failure-injection validation remain open.
|
||||
|
||||
## Suggested Next Actions
|
||||
|
||||
1. Build minimal UI shell with three views:
|
||||
- agents/commands
|
||||
- audit timeline
|
||||
- alerts panel (active + history + websocket stream)
|
||||
2. Add periodic retention task for audit and alert transition trees.
|
||||
3. Add proxy-level integration tests that assert private endpoints are blocked without Access headers.
|
||||
4. Run a 48-72h soak with hostname vs IP connect-path metrics collected.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Control Plane Review
|
||||
|
||||
## Findings
|
||||
|
||||
1. High: the admin/control API is effectively unauthenticated, so anyone who can reach the server can issue enroll tokens, list/revoke tokens, inspect audit/alerts, and send live commands to agents. In [`wakey-control-plane/src/runtime/mod.rs:61`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/runtime/mod.rs#L61) through [`wakey-control-plane/src/runtime/mod.rs:109`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/runtime/mod.rs#L109), `control_api_routes()` is merged straight into the app with no auth middleware despite the comment saying these routes are “admin-only.” The UI also calls those endpoints directly with plain `fetch` and no auth material in [`ui/src/api.ts:60`](c:/Users/Admin/Documents/realshit/wakey/ui/src/api.ts#L60) through [`ui/src/api.ts:123`](c:/Users/Admin/Documents/realshit/wakey/ui/src/api.ts#L123). This is a full remote-takeover issue for the control plane, not just a missing polish item.
|
||||
|
||||
2. High: a second websocket connection for the same `agent_id` silently replaces the current session, but the old authenticated socket is left alive and can still submit `result`/`error` frames against pending requests. In [`wakey-control-plane/src/ws.rs:195`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L195) through [`wakey-control-plane/src/ws.rs:200`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L200), a successful auth simply overwrites `sessions[agent_id] = tx.clone()`. The previous socket is not closed or demoted. Later, any authenticated socket can satisfy pending requests in [`wakey-control-plane/src/ws.rs:229`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L229) through [`wakey-control-plane/src/ws.rs:247`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L247), while requests are correlated only by `request_id` created in [`wakey-control-plane/src/api/commands.rs:91`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/api/commands.rs#L91) through [`wakey-control-plane/src/api/commands.rs:97`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/api/commands.rs#L97). That creates a split-brain/race condition where a stale or malicious prior session for the same agent can inject or win replies.
|
||||
|
||||
3. Medium: configured seed enroll tokens are reinserted into the database on every startup, so “one-time” tokens become reusable after a restart if they remain in config. In [`wakey-control-plane/src/state/store.rs:52`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L52) through [`wakey-control-plane/src/state/store.rs:68`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L68), `load_or_init()` blindly seeds `daemon.enroll_tokens` into sled every time the process starts. Enrollment consumes tokens in [`wakey-control-plane/src/state/store.rs:91`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L91) through [`wakey-control-plane/src/state/store.rs:109`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L109), but that consumption is undone on the next boot if the token still exists in config. That breaks the “short-lived/one-time” assumption and makes operational mistakes much more likely.
|
||||
|
||||
## Residual Risks
|
||||
|
||||
- The control-plane/UI surface is growing quickly and currently assumes a trusted environment in multiple places. Even after adding admin auth, I would expect more authz/session-boundary issues to surface.
|
||||
- The command relay path is conceptually good, but it needs stronger session ownership rules before it is trustworthy under reconnect races or duplicated agents.
|
||||
@@ -1,57 +0,0 @@
|
||||
## Plan: Audit, Alerts, UI, and Safe Edge Exposure
|
||||
|
||||
Recommended approach: ship in this order audit first, alerts second, UI third, then edge hardening and soak. This gives you observability truth before you build subscriptions and screens, and keeps risky control APIs private behind Cloudflare Access at Caddy.
|
||||
|
||||
**Steps**
|
||||
1. Phase A: Lock trust boundaries and endpoint classes.
|
||||
2. Public Agent API stays exposed: /api/v1/agents/enroll, /api/v1/agent/ws, /healthz.
|
||||
3. Private Control API stays protected: /api/v1/control/* and /ui/*.
|
||||
4. Phase A: Define AuditEvent schema and retention defaults.
|
||||
5. Phase B: Add audit persistence in sled and emit at key points.
|
||||
6. Emit audit on token issue/list/revoke, ws auth/disconnect, command dispatch/result/timeout/error, and reload/config operations.
|
||||
7. Phase B: Add audit query API with filters and pagination.
|
||||
8. Phase C: Implement deterministic alert rules and evaluator loop.
|
||||
9. Start with offline agent threshold, timeout-rate threshold, auth-failure spike, token misuse attempts.
|
||||
10. Add dedupe and cooldown so alerts do not flap.
|
||||
11. Phase C: Add alert delivery APIs.
|
||||
12. Poll-first endpoint for active alerts and recent transitions, websocket stream optional after rules stabilize.
|
||||
13. Phase D: Build same-domain UI app shell at /ui with origin-relative API client.
|
||||
14. Phase D: Build pages: Agent Health, Command Runner, Audit Timeline, Alerts Panel.
|
||||
15. Phase E: Add Caddy deployment template with Cloudflare Access policy boundaries and websocket support.
|
||||
16. Phase E: Run end-to-end drills and 48-72h soak.
|
||||
|
||||
**Relevant files**
|
||||
- [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs)
|
||||
- [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs)
|
||||
- [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs)
|
||||
- [wakey-control-plane/src/ws.rs](wakey-control-plane/src/ws.rs)
|
||||
- [wakey-control-plane/src/state/store.rs](wakey-control-plane/src/state/store.rs)
|
||||
- [wakey-control-plane/src/state/types.rs](wakey-control-plane/src/state/types.rs)
|
||||
- [wakey-control-plane/src/config/types.rs](wakey-control-plane/src/config/types.rs)
|
||||
- [wakey-control-plane/src/config/resolve.rs](wakey-control-plane/src/config/resolve.rs)
|
||||
- [wakey-control-plane/src/cli.rs](wakey-control-plane/src/cli.rs)
|
||||
- [README.md](README.md)
|
||||
- [scripts/init/openwrt/wakey](scripts/init/openwrt/wakey)
|
||||
- [.github/plan-controlPlaneAppV1.prompt.md](.github/plan-controlPlaneAppV1.prompt.md)
|
||||
|
||||
**Verification**
|
||||
1. Unit tests for audit append/query, pagination, retention pruning.
|
||||
2. Unit tests for alert rule evaluation, dedupe, cooldown.
|
||||
3. Contract tests for request_id correlation across command result and timeout/error audit records.
|
||||
4. Integration tests for ws auth/disconnect audit events and command timeout event emission.
|
||||
5. API tests for audit and alert endpoints.
|
||||
6. Edge security tests that unauthenticated /api/v1/control/* and /ui/* are denied.
|
||||
7. Soak tests for reconnect churn and audit growth stability.
|
||||
|
||||
**Decisions captured**
|
||||
- Admin auth default: Cloudflare Access only, enforced at Caddy.
|
||||
- UI host: same domain path deployment.
|
||||
- Shell bridge: excluded from v1 due high risk and low break-glass value during hard router failures.
|
||||
- Alert delivery: poll-first in v1, websocket stream optional.
|
||||
|
||||
**Caddy policy shape for this plan**
|
||||
1. Route /api/v1/agents/enroll and /api/v1/agent/ws to control-plane upstream without Cloudflare Access gate.
|
||||
2. Route /api/v1/control/* and /ui/* only when Cloudflare Access authentication is valid.
|
||||
3. Preserve websocket upgrade headers on /api/v1/agent/ws.
|
||||
4. Keep control-plane process bound to private interface or localhost behind Caddy.
|
||||
5. Deny direct exposure of /api/v1/control/* from origin network paths.
|
||||
@@ -1,32 +0,0 @@
|
||||
## Plan: Control Plane App v1
|
||||
|
||||
Build an end-to-end, ops-ready v1 over 6+ weeks by reusing current wakey and wakey-core logic, keeping wakey-agent as outbound executor, and adding a dedicated control-plane server plus minimal operator UI.
|
||||
|
||||
**Steps**
|
||||
1. Phase 1, contract baseline: finalize relay contract for command, result, error, request correlation, timeout, retry, and forward compatibility behavior.
|
||||
2. Phase 1, boundary lock: keep execution in wakey service functions and keep domain DTOs in wakey-core while removing legacy HTTP/static compatibility code.
|
||||
3. Phase 2, server skeleton: implement enrollment endpoint, agent registry, websocket acceptor, and request correlation map. Depends on step 1 and step 2.
|
||||
4. Phase 2, relay core: implement command submission to connected agents, request_id correlation, timeout paths, and structured relay errors. Depends on step 3.
|
||||
5. Phase 2, persistence and identity: durable agent records, enroll token lifecycle, and safe credential metadata. Parallel with step 4 after schema is stable.
|
||||
6. Phase 3, operator surface: add API for agent inventory, health, command execution, and recent outcomes; add minimal UI for core operations. Depends on step 4 and step 5.
|
||||
7. Phase 3, ops hardening: metrics, logs, audits, heartbeat liveness checks, and alert thresholds. Parallel with step 6.
|
||||
8. Phase 4, deployment pipeline: add server build and deploy artifacts, environment templates, and rollback workflow. Depends on step 6 and step 7.
|
||||
9. Phase 4, validation and soak: run enrollment-to-command end-to-end tests and disconnect/failure drills with multi-day soak. Depends on step 8.
|
||||
|
||||
**Relevant files to reuse**
|
||||
- [wakey-agent/src/protocol.rs](wakey-agent/src/protocol.rs)
|
||||
- [wakey-agent/src/session.rs](wakey-agent/src/session.rs)
|
||||
- [wakey-agent/src/dispatch.rs](wakey-agent/src/dispatch.rs)
|
||||
- [src/service/mod.rs](src/service/mod.rs)
|
||||
- [wakey-core/src/model](wakey-core/src/model)
|
||||
- [scripts/package_rootfs.ps1](scripts/package_rootfs.ps1)
|
||||
- [.gitea/workflows/release.yml](.gitea/workflows/release.yml)
|
||||
|
||||
**Verification**
|
||||
1. Contract tests for command and result serialization, request_id stability, and unknown frame tolerance.
|
||||
2. Relay integration tests for enrollment, websocket auth flow, correlation, and timeout handling.
|
||||
3. Security tests for token lifecycle and invalid credential rejection.
|
||||
4. API tests for registry and command execution behavior.
|
||||
5. Observability tests for heartbeat, reconnect counters, latency, and failure alerts.
|
||||
6. Deployment tests for build, release, rollback rehearsal, and staging smoke checks.
|
||||
7. On-device tests for enroll, procd lifecycle, reconnect, and remote command round trips.
|
||||
@@ -1,184 +0,0 @@
|
||||
# Plan: Observation Store And Sync V1
|
||||
|
||||
## Summary
|
||||
|
||||
Build device observation support in layers:
|
||||
|
||||
1. Local router observations are captured by `wakey observe ...` from OpenWrt hotplug.
|
||||
2. `wakey-linux` stores current observed DHCP/neigh state locally.
|
||||
3. `wakey-agent` forwards compact observations to the control plane.
|
||||
4. The control plane stores observations per agent and later joins them to known devices through `device_identifiers`.
|
||||
|
||||
This is separate from durable identity. Observations are facts an agent saw at a time; known devices are manual/user-approved identity records.
|
||||
|
||||
## Network/API Boundary
|
||||
|
||||
The control plane has two route classes:
|
||||
|
||||
- Public agent routes: intended to be reachable by agents.
|
||||
- Protected control routes: intended to sit behind Cloudflare Access/admin auth.
|
||||
|
||||
Observation upload belongs on the public agent route surface because routers/agents must call it:
|
||||
|
||||
```text
|
||||
POST /api/v1/agents/observations
|
||||
```
|
||||
|
||||
The endpoint must require normal agent authentication, using the same persistent `agent_id`/`agent_token` trust model as WebSocket auth. It must not be a Cloudflare Access-only admin endpoint.
|
||||
|
||||
Admin/UI read APIs belong under protected control routes:
|
||||
|
||||
```text
|
||||
GET /api/v1/control/observations
|
||||
GET /api/v1/control/devices
|
||||
```
|
||||
|
||||
## Step 1: Local Observation Store
|
||||
|
||||
Replace the narrow `/tmp/wakey_mac_names.json` cache with a local observation store owned by `wakey-linux`.
|
||||
|
||||
Minimum local tables/state:
|
||||
|
||||
```text
|
||||
observed_dhcp_clients:
|
||||
mac
|
||||
ip
|
||||
hostname
|
||||
first_seen_unix
|
||||
last_seen_unix
|
||||
last_action
|
||||
|
||||
observed_neighbors:
|
||||
key
|
||||
mac
|
||||
ip
|
||||
first_seen_unix
|
||||
last_seen_unix
|
||||
last_action
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Hotplug scripts stay minimal and call `wakey-agent observe ...`.
|
||||
- `wakey-agent observe ...` delegates to `wakey observe ...`.
|
||||
- `wakey observe dhcp ...` writes DHCP observations.
|
||||
- `wakey observe neigh ...` writes neighbor observations.
|
||||
- Existing `wakey leases` and `wakey inventory` still read live sources first, then enrich from the local observation store.
|
||||
- Do not make local queries depend exclusively on hotplug events yet.
|
||||
|
||||
## Step 2: Control-Plane Observation Tables
|
||||
|
||||
Add observation tables early so future API/UI work does not need another major state refactor.
|
||||
|
||||
Control-plane schema:
|
||||
|
||||
```text
|
||||
agent_device_observations:
|
||||
observation_key TEXT PRIMARY KEY
|
||||
agent_id TEXT NOT NULL
|
||||
kind TEXT NOT NULL
|
||||
mac TEXT
|
||||
ip TEXT
|
||||
hostname TEXT
|
||||
first_seen_unix INTEGER NOT NULL
|
||||
last_seen_unix INTEGER NOT NULL
|
||||
last_action TEXT NOT NULL
|
||||
|
||||
agent_device_observation_events:
|
||||
event_id TEXT PRIMARY KEY
|
||||
agent_id TEXT NOT NULL
|
||||
kind TEXT NOT NULL
|
||||
action TEXT NOT NULL
|
||||
mac TEXT
|
||||
ip TEXT
|
||||
hostname TEXT
|
||||
ts_unix INTEGER NOT NULL
|
||||
```
|
||||
|
||||
Keep `agent_device_observation_events` optional in behavior if needed, but create it in schema early. Current-state rows are the primary path; event history is for debugging/audit.
|
||||
|
||||
Indexes:
|
||||
|
||||
```text
|
||||
agent_device_observations(agent_id)
|
||||
agent_device_observations(mac)
|
||||
agent_device_observations(ip)
|
||||
agent_device_observations(hostname)
|
||||
agent_device_observations(last_seen_unix)
|
||||
agent_device_observation_events(agent_id, ts_unix)
|
||||
agent_device_observation_events(mac)
|
||||
```
|
||||
|
||||
## Step 3: Agent Upload
|
||||
|
||||
Add agent upload payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"observations": [
|
||||
{
|
||||
"kind": "dhcp",
|
||||
"action": "update",
|
||||
"mac": "04:7c:16:79:6d:ee",
|
||||
"ip": "192.168.100.94",
|
||||
"hostname": "lda",
|
||||
"first_seen_unix": 1770000000,
|
||||
"last_seen_unix": 1770000123
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Agent may send snapshots periodically and after local observe events.
|
||||
- Control plane upserts current-state rows by stable observation key.
|
||||
- Observation keys should be deterministic from `agent_id`, `kind`, and the best available identifier:
|
||||
- DHCP with MAC: `agent:{agent_id}:dhcp:mac:{mac}`
|
||||
- Neigh with MAC: `agent:{agent_id}:neigh:mac:{mac}`
|
||||
- Neigh without MAC: `agent:{agent_id}:neigh:ip:{ip}`
|
||||
- Uploads must not create known devices automatically.
|
||||
|
||||
## Step 4: Join Observations To Known Devices
|
||||
|
||||
Known devices already have durable IDs and manual identifiers:
|
||||
|
||||
```text
|
||||
known_devices
|
||||
device_identifiers
|
||||
```
|
||||
|
||||
Join rule:
|
||||
|
||||
```text
|
||||
agent_device_observations.mac
|
||||
-> device_identifiers(kind = 'mac', value = mac)
|
||||
-> known_devices.device_id
|
||||
```
|
||||
|
||||
Unknown observations are rows that do not match any manual `device_identifiers` row.
|
||||
|
||||
This enables:
|
||||
|
||||
- same known device observed by multiple agents;
|
||||
- same known device with multiple MACs;
|
||||
- UI flow to attach an unknown observed MAC to an existing known device;
|
||||
- wake flows that choose agent-local observed IP/MAC context for a known device.
|
||||
|
||||
## Step 5: UI/API Later
|
||||
|
||||
After storage and upload exist:
|
||||
|
||||
- show known devices with all matching observations grouped by agent;
|
||||
- show unknown observations;
|
||||
- add action: attach observation identifier to known device;
|
||||
- add action: create known device from observation;
|
||||
- add wake action from known device using a selected agent/observation.
|
||||
|
||||
## Defaults
|
||||
|
||||
- `wakey` remains the local router/debugging CLI.
|
||||
- `wakey-agent` is the sync bridge to the control plane.
|
||||
- `wakey-control-plane` is durable identity and multi-agent view.
|
||||
- Local observation store is not authoritative; live sources still matter.
|
||||
- Control-plane observations are not durable identity; only manual known-device identifiers are.
|
||||
@@ -1,149 +0,0 @@
|
||||
## Plan: wakey UI v1 (Device-First)
|
||||
|
||||
The Operator UI is not the end goal by itself.
|
||||
The goal is to make wakey excellent at its original purpose: quickly finding devices and waking them reliably.
|
||||
|
||||
Agent, audit, and token features remain important, but they should support the device workflow rather than dominate the navigation and development effort.
|
||||
|
||||
## Product North Star
|
||||
|
||||
An operator should be able to do this in under 10 seconds:
|
||||
1. Open the UI.
|
||||
2. Search for a device by name, IP, or MAC.
|
||||
3. See whether it looks online/reachable.
|
||||
4. Trigger wake.
|
||||
5. See immediate command result and short follow-up status.
|
||||
|
||||
## Scope Priorities
|
||||
|
||||
1. P0: Device discovery and wake UX.
|
||||
2. P1: Fast troubleshooting context around wake results.
|
||||
3. P2: Fleet/agent/admin operations.
|
||||
|
||||
This explicitly means pages and components for Agent, Audit, Alerts, and Tokens should be present but secondary in visual hierarchy and effort until P0 is complete.
|
||||
|
||||
## IA Direction (v1)
|
||||
|
||||
Primary top-level focus:
|
||||
1. Devices
|
||||
2. Wake Queue (or Recent Actions)
|
||||
|
||||
Secondary top-level focus:
|
||||
1. Fleet Health
|
||||
2. Audit
|
||||
3. Alerts
|
||||
4. Access/Tokens
|
||||
|
||||
If needed, keep current routes during transition, but adjust default landing and navigation emphasis so Devices is the home workflow.
|
||||
|
||||
## Phase Plan
|
||||
|
||||
### Phase 1: Device-Centric Foundation
|
||||
|
||||
1. Add a dedicated Devices page that merges existing status/leases/inventory signal into one operator list.
|
||||
2. Include searchable columns for name, IP, MAC, interface/dev, and recency indicators.
|
||||
3. Provide row-level wake action and bulk-safe interaction model (single-click row action, confirm for bulk).
|
||||
4. Define a compact "device confidence" heuristic from available data (for example: recent lease + reachable neighbor).
|
||||
5. Set the default route to Devices, with prominent search and wake controls above the fold.
|
||||
|
||||
Acceptance criteria:
|
||||
1. Search by hostname, IP, and MAC all work from one input.
|
||||
2. Wake action reachable in one click from list row.
|
||||
3. Response feedback shown immediately with clear success/error text.
|
||||
|
||||
### Phase 2: Wake Execution UX
|
||||
|
||||
1. Build a focused wake panel with explicit target preview before send.
|
||||
2. Provide quick presets: "wake by selected device", "wake by MAC", "wake by query".
|
||||
3. Persist recent wake targets locally for operator speed.
|
||||
4. Add post-wake verification loop (short timed refresh of status indicators).
|
||||
5. Surface request correlation id and a copy action for incident sharing.
|
||||
|
||||
Acceptance criteria:
|
||||
1. Operator can retry wake with one click.
|
||||
2. Operator can see last 20 wake attempts with outcome and timestamp.
|
||||
3. Error states differentiate validation, timeout, and execution failure.
|
||||
|
||||
### Phase 3: Context Without Workflow Drift
|
||||
|
||||
1. Keep Alerts and Audit accessible from device rows and wake outcomes.
|
||||
2. Add contextual deep links: device -> related alerts, device -> recent audit events.
|
||||
3. Improve filtering for alerts/audit with saved local filter presets.
|
||||
4. Add gentle live updates, keeping websocket optional with polling fallback.
|
||||
|
||||
Acceptance criteria:
|
||||
1. From any failed wake, operator can jump to relevant audit entries in one step.
|
||||
2. Alerts page can be filtered by kind/severity/agent and linked back to impacted devices.
|
||||
|
||||
### Phase 4: Fleet/Admin Hardening
|
||||
|
||||
1. Keep Agents page for connectivity and control routing visibility.
|
||||
2. Keep Tokens page for enrollment lifecycle operations.
|
||||
3. Keep Dashboard but reframe metrics around "device availability" and "wake success" first.
|
||||
4. Add audit-friendly confirmation flows for destructive actions.
|
||||
|
||||
Acceptance criteria:
|
||||
1. Admin flows do not block or slow P0 device workflows.
|
||||
2. All admin actions produce clear audit-visible outcomes.
|
||||
|
||||
### Phase 5: Validation and Production Readiness
|
||||
|
||||
1. Add smoke tests for P0 flow: find device -> wake -> observe result.
|
||||
2. Add contract checks for command payload/response shapes used by device and wake screens.
|
||||
3. Add scenario drills (offline agent, delayed command, websocket drop, expired token).
|
||||
4. Verify edge policy still protects control routes while preserving required public endpoints.
|
||||
|
||||
Acceptance criteria:
|
||||
1. P0 flow remains usable during partial degradation.
|
||||
2. Build/typecheck/test gates stay green in CI.
|
||||
|
||||
## Concrete UI Backlog (Ordered)
|
||||
|
||||
1. Create DevicesPage with unified searchable table.
|
||||
2. Wire wake action directly from device row.
|
||||
3. Add Recent Wake Actions panel with outcomes.
|
||||
4. Add post-wake short verification refresh.
|
||||
5. Rework Dashboard cards to device-first metrics.
|
||||
6. Add deep links from wake result to audit and alerts context.
|
||||
7. Add empty/loading/error skeleton patterns tuned for device list scale.
|
||||
|
||||
## Metrics of Success
|
||||
|
||||
1. Time-to-wake median under 10 seconds for known target.
|
||||
2. Wake success rate visible per time window.
|
||||
3. Fewer operator clicks for common tasks (device lookup + wake).
|
||||
4. Reduced navigation to agent-centric pages for everyday usage.
|
||||
|
||||
## Design and Interaction Principles
|
||||
|
||||
1. Device-first information density over generic admin dashboards.
|
||||
2. Search and action bar always visible on desktop and mobile.
|
||||
3. Action feedback must be immediate and explicit.
|
||||
4. Keep advanced infrastructure details available but visually de-emphasized.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
1. Keep same-domain /ui serving and origin-relative API requests.
|
||||
2. Keep route and API contracts stable while iterating UX aggressively.
|
||||
3. Treat websocket as progressive enhancement; polling fallback required.
|
||||
4. Preserve Cloudflare Access boundary for all control endpoints.
|
||||
|
||||
## Relevant Files
|
||||
|
||||
1. [ui/src/App.tsx](ui/src/App.tsx)
|
||||
2. [ui/src/pages/CommandsPage.tsx](ui/src/pages/CommandsPage.tsx)
|
||||
3. [ui/src/pages/DashboardPage.tsx](ui/src/pages/DashboardPage.tsx)
|
||||
4. [ui/src/api.ts](ui/src/api.ts)
|
||||
5. [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs)
|
||||
6. [wakey-control-plane/src/api/audit.rs](wakey-control-plane/src/api/audit.rs)
|
||||
7. [wakey-control-plane/src/api/alerts.rs](wakey-control-plane/src/api/alerts.rs)
|
||||
8. [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs)
|
||||
9. [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs)
|
||||
10. [README.md](README.md)
|
||||
|
||||
## Immediate Next Build Slice
|
||||
|
||||
1. Implement DevicesPage and make it default route.
|
||||
2. Add row-level wake action with in-place result feedback.
|
||||
3. Add Recent Wake Actions section backed by local state first, then audit correlation.
|
||||
4. Rebalance navigation labels/order to make device workflows primary.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Plan: SQLx Query Macro Adoption
|
||||
|
||||
## Summary
|
||||
|
||||
Move control-plane SQL from unchecked `sqlx::query(...)` calls toward `sqlx::query!` / `query_as!` where the SQL shape is static.
|
||||
|
||||
This gives compile-time checking for table/column names and Rust value types. Dynamic filter builders can stay on `QueryBuilder`.
|
||||
|
||||
## Offline Setup
|
||||
|
||||
Normal builds should not need a live database:
|
||||
|
||||
```text
|
||||
SQLX_OFFLINE=true
|
||||
```
|
||||
|
||||
`.env` is ignored, so copy:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
SQLx query metadata should be generated into `.sqlx/` and committed.
|
||||
|
||||
Prepare metadata with:
|
||||
|
||||
```sh
|
||||
cargo install sqlx-cli --no-default-features --features sqlite
|
||||
./scripts/prepare_sqlx_sqlite.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- creates a temporary SQLite database;
|
||||
- runs `wakey-control-plane/migrations`;
|
||||
- runs `cargo sqlx prepare --workspace -- -p wakey-control-plane --all-targets --all-features`;
|
||||
- writes `.sqlx/`.
|
||||
|
||||
## Conversion Rules
|
||||
|
||||
Use `query!` / `query_as!` for static SQL:
|
||||
|
||||
- inserts;
|
||||
- deletes;
|
||||
- simple selects by primary key;
|
||||
- fixed update statements;
|
||||
- count queries.
|
||||
|
||||
Keep non-macro SQL for genuinely dynamic SQL:
|
||||
|
||||
- audit event filters built with `QueryBuilder`;
|
||||
- observation listing with optional filters, unless split into fixed branches;
|
||||
- any SQL where table/column names are intentionally generated.
|
||||
|
||||
## Test/CI
|
||||
|
||||
After query macros are introduced, CI should add:
|
||||
|
||||
```sh
|
||||
cargo install sqlx-cli --no-default-features --features sqlite
|
||||
./scripts/prepare_sqlx_sqlite.sh
|
||||
cargo sqlx prepare --check --workspace -- --all-targets --all-features
|
||||
```
|
||||
|
||||
Do not add the CI check until the first `.sqlx/` metadata is committed, otherwise it adds dependency install cost without catching anything.
|
||||
|
||||
## Notes
|
||||
|
||||
The SQLx docs say offline mode needs `cargo sqlx prepare` output checked into version control, and that `DATABASE_URL` takes precedence unless `SQLX_OFFLINE=true` is set. Keep `.env` local; commit `.env.example` and `.sqlx/`.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09db61a070a351a6255e21d5949285ccb19fca7f8f29a6c18dddac5495257a45"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "fact_json!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "12e443ec0991c042cf527498b567bbbb6d02885f72eb3fa426c9493368f8a9e7"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2cd47bbce9b9ae2ebf39f31b12722bb8d19ef6d90ac634731932a4cefbde03b7"
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE agent_id = ?1\n ORDER BY last_seen_unix DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "305f50ab2691e12c74e8a222ef12a16f14a65edd7b7c5400a196fd57a9788dcf"
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mac, ip\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3b06698566cea17eed9d10a389ee12a95cee2db6c3d450dca7a3b2d08e8fed7b"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "device_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3f24dfb51be2c3051dc8a8f732bd3e3fe9ca6b2649e1202d3725693b8011f269"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "409e1e7aa2d9bc46030001e5c522d7c2812b4583dceadfe5c535a866ac76489c"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4188fb9a0dac2e19fb1f83e023eb1e8d93f6ca896cd78a53668080639fc048d9"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4dd5e580826b0f6980bb8c2adecdc81ff95992ec337f93263463c5a4e357dc72"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4f4329b0a47a00a3b881a1bc5cf8bb9855351714ff827ccff917b3e623191504"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "52d22161e936b39b57d119aab592426e88f60387ccba58888cc7d440012777ed"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "55340d50a29e0a2d324ed946b6fa6503fbd4ea0674bb7ec24e3426c5b0e5832f"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", mac as \"mac!\"\n FROM agent_device_macs\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "580f6bc100d373137db6f853b8babd7633c700dbfaf17ded507b704fd98e46a5"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", mac as \"mac!\"\n FROM agent_device_macs",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5824c6cb841f7aaeb2a039f846effe17ba8038e35998b674136a19a4d42d8b88"
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n ORDER BY last_seen_unix DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5a0f37b5da194e432c795972d5b3bf67c2d2a00f379143d1ae5871ea790d1bf2"
|
||||
}
|
||||
+13
-13
@@ -1,50 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,\n last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n WHERE agent_id = ?1\n ORDER BY device_key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "mac",
|
||||
"name": "agent_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"name": "device_key",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"name": "presence",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"name": "display_name",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92775bf2e3b9f772d108bb8d0cd1aebbe01357ed70dc2af7b50ee348e668b319"
|
||||
"hash": "5f7e043ca389f1431c7b3478ea506b0c9bb706d32c06871d1267068dbe9cb746"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "69601f4f2268a759f03a2d533a0a30dec5cf05797817fd8d2426714cd84d7ada"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)\n ON CONFLICT (agent_id, device_key) DO UPDATE SET\n presence = excluded.presence,\n display_name = excluded.display_name,\n last_seen_unix = excluded.last_seen_unix",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a29f58cb7dd1b1bc510a0d04ecd1dbc7250824ba089dbc36ed9ac2e6dc920fd7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c423e005d85659547302f506380ad217cc31329262a1c0254ef532653b3a37bb"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 12
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n ORDER BY agent_id, device_key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "presence",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cd8585b9431d223962c376a2d463f0ca8128e7759c8694631f41db0cbd8eb221"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d442949dce7a4b9a8af97dc34fc34608029564740ad327c525c7f1466bc18a11"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dba637bf03d53bf3394ead0f01466f9a72fafc54f9ce239d2e5ac7f76b63b168"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "fact_json!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e6be4d9782f66f98ebe4d4b6ec6e93ea9efe8e4c7f1147e1ff68d41a311d57f9"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f5a815d4d6e3d088aba3cd01607764d89afaae6108aad0d17e93b1bfc9f7e772"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fa09ff199445b7bf689ef4426cbd20d89040230ffff977cc1b4c73b499b1b33f"
|
||||
}
|
||||
Generated
+8
-6
@@ -3677,7 +3677,6 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -3701,7 +3700,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3722,7 +3721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3746,12 +3745,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"macaddr",
|
||||
"nix 0.31.2",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
@@ -3770,14 +3770,16 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"wakey-agent",
|
||||
"wakey-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
"thiserror 2.0.18",
|
||||
@@ -3785,7 +3787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -32,6 +32,22 @@ strip = true
|
||||
[workspace]
|
||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = [
|
||||
"fs",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"macros",
|
||||
"time",
|
||||
] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dependencies.lda-ipjs]
|
||||
path = "ipjs"
|
||||
registry = "gitea"
|
||||
|
||||
+9
-5
@@ -7,6 +7,8 @@ param(
|
||||
|
||||
$PASSWD = Get-DefaultPassword $PASSWD
|
||||
|
||||
Write-Output $PSScriptRoot
|
||||
|
||||
# idk what this does it works like that then thats how it is
|
||||
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
|
||||
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
|
||||
@@ -23,13 +25,15 @@ New-Item -ItemType Directory -Force -Path $initDir | Out-Null
|
||||
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda-override"
|
||||
$remote = $files.ForEach({ "/etc/init.d/$_" })
|
||||
$remote | ForEach-Object {
|
||||
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
|
||||
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir/"
|
||||
}
|
||||
|
||||
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
|
||||
New-Item -ItemType Directory -Force -Path $hotplugDir | Out-Null
|
||||
|
||||
$files = "dhcp/95-wakey", "neigh/95-wakey"
|
||||
$remote = $files.ForEach({ "/etc/hotplug.d/$_" })
|
||||
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
|
||||
|
||||
$remote | ForEach-Object {
|
||||
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$hotplugDir\"
|
||||
$files | ForEach-Object {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path "$hotplugDir/$_")
|
||||
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:/etc/hotplug.d/$_" "$hotplugDir/$_"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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)]`
|
||||
+3
-1
@@ -11,7 +11,9 @@ pub use service::{
|
||||
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
|
||||
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||
};
|
||||
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
|
||||
pub use wakey_linux::observations::{
|
||||
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
||||
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
||||
let leases = get_leases().await?;
|
||||
let observations = match wakey_linux::dhcp::list_local_observations().await {
|
||||
let observations = match wakey_linux::observations::list_local_observations().await {
|
||||
Ok(observations) => observations
|
||||
.into_iter()
|
||||
.filter_map(local_observation_to_fact)
|
||||
@@ -132,7 +132,7 @@ pub fn merge_devices_with_observations(
|
||||
}
|
||||
|
||||
pub fn local_observation_to_fact(
|
||||
observation: wakey_linux::dhcp::LocalDeviceObservation,
|
||||
observation: wakey_linux::observations::LocalDeviceObservation,
|
||||
) -> Option<DeviceObservationFact> {
|
||||
let mac = match observation.mac.as_deref() {
|
||||
Some(raw) => match raw.parse() {
|
||||
|
||||
@@ -16,6 +16,8 @@ pub async fn get_leases_with_neighbor_state() -> Result<Vec<DhcpLeaseWithState>>
|
||||
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||
.await
|
||||
.context("failed to read DHCP leases")?;
|
||||
// this is only used in the wakey CLI
|
||||
#[allow(deprecated)]
|
||||
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -24,7 +24,6 @@ import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
@@ -215,7 +214,7 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
<Route path="observations" element={<ObservationsPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+44
-75
@@ -96,32 +96,6 @@ export type KnownDeviceSummary = {
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
export type AgentDeviceObservation = {
|
||||
observation_key: string;
|
||||
agent_id: string;
|
||||
kind: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
first_seen_unix: number;
|
||||
last_seen_unix: number;
|
||||
last_action: string;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type AgentDeviceObservationEvent = {
|
||||
event_id: string;
|
||||
observation_key: string;
|
||||
agent_id: string;
|
||||
kind: string;
|
||||
action: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
ts_unix: number;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type FleetDeviceAgent = {
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
@@ -179,6 +153,25 @@ export type WakeFleetDeviceResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export interface APIErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
};
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details: any,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "APIError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
@@ -190,11 +183,22 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw;
|
||||
let parsed: APIErrorResponse | null = null;
|
||||
try {
|
||||
detail = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {}
|
||||
throw new Error(`${res.status} ${res.statusText}\n${detail}`);
|
||||
parsed = JSON.parse(raw);
|
||||
// assume APIError
|
||||
if (parsed?.error?.details) {
|
||||
parsed.error.details = JSON.stringify(parsed.error.details);
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
|
||||
throw new APIError(
|
||||
`${res.status} ${res.statusText}\n${raw}`,
|
||||
parsed?.error?.code ?? "unknown",
|
||||
parsed?.error?.details ?? parsed?.error?.message ?? raw,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
@@ -318,38 +322,6 @@ export function wakeFleetDevice(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchObservations(opts?: {
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
}): Promise<AgentDeviceObservation[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<AgentDeviceObservation[]>(
|
||||
`/api/v1/control/observations?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchObservationHistory(opts?: {
|
||||
agentId?: string;
|
||||
kind?: string;
|
||||
mac?: string;
|
||||
ip?: string;
|
||||
observationKey?: string;
|
||||
limit?: number;
|
||||
}): Promise<AgentDeviceObservationEvent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
if (opts?.kind) params.set("kind", opts.kind);
|
||||
if (opts?.mac) params.set("mac", opts.mac);
|
||||
if (opts?.ip) params.set("ip", opts.ip);
|
||||
if (opts?.observationKey) params.set("observation_key", opts.observationKey);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<AgentDeviceObservationEvent[]>(
|
||||
`/api/v1/control/observations/history?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createKnownDevice(input: {
|
||||
display_name: string;
|
||||
pinned?: boolean;
|
||||
@@ -367,19 +339,6 @@ export function createKnownDevice(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function attachObservationIdentifier(
|
||||
deviceId: string,
|
||||
observationKey: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/from-observation`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ observation_key: observationKey }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function attachDeviceIdentifier(
|
||||
deviceId: string,
|
||||
identifier: { kind: string; value: string },
|
||||
@@ -393,6 +352,16 @@ export function attachDeviceIdentifier(
|
||||
);
|
||||
}
|
||||
|
||||
export function detachDeviceIdentifier(
|
||||
deviceId: string,
|
||||
identifierKey: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/${encodeURIComponent(identifierKey)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeKnownDevice(
|
||||
targetDeviceId: string,
|
||||
sourceDeviceId: string,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Bot,
|
||||
FileText,
|
||||
Key,
|
||||
Eye,
|
||||
Terminal,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
@@ -49,7 +48,6 @@ const navSections: NavSection[] = [
|
||||
items: [
|
||||
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||
{ to: "/observations", label: "Observations", icon: Eye },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -78,6 +78,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
presence,
|
||||
known,
|
||||
agentId: agentId === "all" ? "" : agentId,
|
||||
visibility: "all",
|
||||
limit: 500,
|
||||
}),
|
||||
fetchKnownDevices(),
|
||||
@@ -97,8 +98,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const result = await refreshFleetDevices();
|
||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||
const msg = failed.length
|
||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
||||
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
|
||||
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
|
||||
toast.success(msg);
|
||||
await loadFleet();
|
||||
} catch (err) {
|
||||
@@ -160,6 +161,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const sortedDevices = useMemo(() => {
|
||||
const rows = [...devices];
|
||||
rows.sort((a, b) => {
|
||||
// Pinned always float to top
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const dir = sort.dir === "asc" ? 1 : -1;
|
||||
if (sort.key === "presence") {
|
||||
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
|
||||
@@ -190,7 +193,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
<div>
|
||||
<CardTitle>Devices</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{devices.length} fleet rows from stored observations and known
|
||||
{devices.length} fleet rows from agent snapshots and known
|
||||
devices
|
||||
</p>
|
||||
</div>
|
||||
@@ -361,8 +364,19 @@ function FleetDeviceRow({
|
||||
onDetails: () => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
const presenceBorder =
|
||||
device.presence === "online"
|
||||
? "border-l-emerald-500/70"
|
||||
: device.presence === "likely_online"
|
||||
? "border-l-sky-500/60"
|
||||
: device.presence === "offline"
|
||||
? "border-l-muted-foreground/20"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-card px-3 py-3 text-sm xl:grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] xl:items-center xl:gap-2">
|
||||
<div
|
||||
className={`grid gap-3 rounded-md border border-l-[3px] ${presenceBorder} bg-card px-3 py-3 text-sm xl:grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] xl:items-center xl:gap-2`}
|
||||
>
|
||||
<button className="min-w-0 text-left" type="button" onClick={onDetails}>
|
||||
<div className="truncate font-medium">{device.display_name}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Minus, Plus, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
attachDeviceIdentifier,
|
||||
createKnownDevice,
|
||||
detachDeviceIdentifier,
|
||||
mergeKnownDevice,
|
||||
type FleetDevice,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -52,67 +55,138 @@ export function FleetDeviceDetailsDialog({
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||
const [addValue, setAddValue] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(device?.display_name ?? "");
|
||||
setTargetDeviceId("");
|
||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||
setAddKind("mac");
|
||||
setAddValue("");
|
||||
setError("");
|
||||
setActionBusy(false);
|
||||
}, [device]);
|
||||
|
||||
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||
|
||||
// Resolve the full KnownDevice object if this fleet row is known
|
||||
const fullKnown = knownDevices.find(
|
||||
(kd) => kd.device_id === device?.known_device?.device_id,
|
||||
);
|
||||
|
||||
// Identifiers from the row that are NOT already on the known device
|
||||
const unattachedIdentifiers = useMemo(() => {
|
||||
if (!fullKnown) return rowIdentifiers;
|
||||
const existing = new Set(
|
||||
fullKnown.identifiers.map((id) => `${id.kind}:${id.value}`),
|
||||
);
|
||||
return rowIdentifiers.filter(
|
||||
(id) => !existing.has(`${id.kind}:${id.value}`),
|
||||
);
|
||||
}, [fullKnown, rowIdentifiers]);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const currentDevice = device;
|
||||
const identifiers = identifiersFor(currentDevice);
|
||||
|
||||
const otherKnownDevices = knownDevices.filter(
|
||||
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
||||
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
|
||||
);
|
||||
|
||||
async function createRemembered() {
|
||||
async function wrap(fn: () => Promise<void | { changed?: boolean }>) {
|
||||
setError("");
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await createKnownDevice({
|
||||
const res = await fn();
|
||||
if (!res || res.changed !== false) {
|
||||
onChanged();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(String(err));
|
||||
if (err && (err.changed || err.partial)) {
|
||||
onChanged();
|
||||
}
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRemembered() {
|
||||
await wrap(async () => {
|
||||
const result = await createKnownDevice({
|
||||
display_name: displayName.trim() || currentDevice.display_name,
|
||||
pinned: true,
|
||||
identifiers,
|
||||
identifiers: rowIdentifiers,
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
toast.success(`Remembered as "${result.display_name}"`);
|
||||
});
|
||||
}
|
||||
|
||||
async function attachToExisting() {
|
||||
async function handleAttachToExisting() {
|
||||
if (!targetDeviceId) return;
|
||||
setError("");
|
||||
try {
|
||||
for (const identifier of identifiers) {
|
||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
||||
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||
await wrap(async () => {
|
||||
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
|
||||
);
|
||||
|
||||
const changed = results.some((r) => r.status === "fulfilled");
|
||||
const errors = results.filter((r) => r.status === "rejected");
|
||||
|
||||
if (errors.length) {
|
||||
const err = errors[0].reason as Error;
|
||||
if (changed) (err as any).changed = true;
|
||||
throw err;
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
|
||||
);
|
||||
return { changed };
|
||||
});
|
||||
}
|
||||
|
||||
async function mergeIntoExisting() {
|
||||
async function handleMerge() {
|
||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||
setError("");
|
||||
try {
|
||||
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||
await wrap(async () => {
|
||||
await mergeKnownDevice(
|
||||
targetDeviceId,
|
||||
currentDevice.known_device.device_id,
|
||||
currentDevice.known_device!.device_id,
|
||||
);
|
||||
onChanged();
|
||||
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddIdentifier() {
|
||||
if (!fullKnown || !addValue.trim()) return;
|
||||
await wrap(async () => {
|
||||
await attachDeviceIdentifier(fullKnown!.device_id, {
|
||||
kind: addKind,
|
||||
value: addValue.trim(),
|
||||
});
|
||||
setAddValue("");
|
||||
toast.success(`Added ${addKind} identifier`);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDetachIdentifier(identifierKey: string) {
|
||||
if (!fullKnown) return;
|
||||
await wrap(async () => {
|
||||
await detachDeviceIdentifier(fullKnown!.device_id, identifierKey);
|
||||
toast.success("Identifier removed");
|
||||
});
|
||||
}
|
||||
|
||||
const selectedRoute =
|
||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
||||
device.route_candidates.find((r) => r.route_id === routeId) ??
|
||||
device.recommended_route;
|
||||
|
||||
return (
|
||||
@@ -127,7 +201,7 @@ export function FleetDeviceDetailsDialog({
|
||||
<DialogDescription>
|
||||
{device.known_device
|
||||
? "Remembered fleet device"
|
||||
: "Observed fleet device"}
|
||||
: "Unidentified fleet device"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<PresenceBadge presence={device.presence} />
|
||||
@@ -150,8 +224,8 @@ export function FleetDeviceDetailsDialog({
|
||||
/>
|
||||
<DetailBlock
|
||||
label="Agents"
|
||||
values={device.agents.map((agent) =>
|
||||
agentLabel(agent.agent_id, agent.nickname),
|
||||
values={device.agents.map((a) =>
|
||||
agentLabel(a.agent_id, a.nickname),
|
||||
)}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
@@ -159,6 +233,7 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Wake route ── */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Wake route</h3>
|
||||
@@ -171,10 +246,7 @@ export function FleetDeviceDetailsDialog({
|
||||
{busy ? "Waking" : "Wake"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={routeId}
|
||||
onValueChange={(value) => setRouteId(value ?? "")}
|
||||
>
|
||||
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? "")}>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{selectedRoute
|
||||
@@ -194,68 +266,250 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Identity section ── */}
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-sm font-medium">Remember / merge</h3>
|
||||
{!device.known_device && (
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void createRemembered()}
|
||||
disabled={!identifiers.length}
|
||||
>
|
||||
Remember device
|
||||
</Button>
|
||||
<h3 className="text-sm font-medium">Identity</h3>
|
||||
|
||||
{/* ── Known device: show metadata + identifiers ── */}
|
||||
{fullKnown && (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant="secondary">known</Badge>
|
||||
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
|
||||
<span className="font-medium">{fullKnown.display_name}</span>
|
||||
{fullKnown.notes && (
|
||||
<span className="text-muted-foreground">
|
||||
— {fullKnown.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current identifiers with remove buttons */}
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
Identifiers
|
||||
</div>
|
||||
{fullKnown.identifiers.length ? (
|
||||
<div className="grid gap-1">
|
||||
{fullKnown.identifiers.map((id) => (
|
||||
<div
|
||||
key={id.identifier_key}
|
||||
className="flex items-center justify-between gap-2 rounded px-1 py-0.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mr-1.5 text-[0.65rem]"
|
||||
>
|
||||
{id.kind}
|
||||
</Badge>
|
||||
{id.value}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
void handleDetachIdentifier(id.identifier_key)
|
||||
}
|
||||
disabled={actionBusy}
|
||||
aria-label={`Remove ${id.kind} ${id.value}`}
|
||||
>
|
||||
<Minus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No identifiers attached
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick-add from row values */}
|
||||
{unattachedIdentifiers.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add from this device row:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{unattachedIdentifiers.map((id) => (
|
||||
<Button
|
||||
key={`${id.kind}:${id.value}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={() =>
|
||||
void wrap(async () => {
|
||||
await attachDeviceIdentifier(
|
||||
fullKnown!.device_id,
|
||||
id,
|
||||
);
|
||||
toast.success(`Added ${id.kind}: ${id.value}`);
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{id.kind}: {id.value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual add identifier */}
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add identifier manually:
|
||||
</p>
|
||||
<div className="grid grid-cols-[6rem_minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={addKind}
|
||||
onValueChange={(v) =>
|
||||
setAddKind((v as "mac" | "ip") ?? "mac")
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{addKind}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectItem value="mac">mac</SelectItem>
|
||||
<SelectItem value="ip">ip</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={addValue}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
placeholder={
|
||||
addKind === "mac" ? "aa:bb:cc:dd:ee:ff" : "192.168.1.100"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleAddIdentifier()}
|
||||
disabled={actionBusy || !addValue.trim()}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merge into another known device */}
|
||||
<Separator />
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Merge this known device into another:
|
||||
</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(kd) => kd.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select target device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{otherKnownDevices.map((kd) => (
|
||||
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||
{kd.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleMerge()}
|
||||
disabled={actionBusy || !targetDeviceId}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Unknown device: remember or attach ── */}
|
||||
{!device.known_device && (
|
||||
<div className="grid gap-3">
|
||||
{/* Remember as new known device */}
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remember as a new known device:
|
||||
</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleCreateRemembered()}
|
||||
disabled={actionBusy || !rowIdentifiers.length}
|
||||
>
|
||||
Remember device
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Identifiers:{" "}
|
||||
{rowIdentifiers
|
||||
.map((id) => `${id.kind}:${id.value}`)
|
||||
.join(", ") || "none"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attach to existing known device */}
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or attach to an existing known device:
|
||||
</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(kd) => kd.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select known device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{knownDevices.map((kd) => (
|
||||
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||
{kd.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleAttachToExisting()}
|
||||
disabled={
|
||||
actionBusy || !targetDeviceId || !rowIdentifiers.length
|
||||
}
|
||||
>
|
||||
Attach IDs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(known) => known.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select known device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{otherKnownDevices.map((known) => (
|
||||
<SelectItem key={known.device_id} value={known.device_id}>
|
||||
{known.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void attachToExisting()}
|
||||
disabled={!targetDeviceId || !identifiers.length}
|
||||
>
|
||||
Attach IDs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void mergeIntoExisting()}
|
||||
disabled={!targetDeviceId || !device.known_device}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proposed identifiers:{" "}
|
||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
||||
"-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationHistory,
|
||||
type AgentDeviceObservation,
|
||||
type AgentDeviceObservationEvent,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
formatSeen,
|
||||
observationLabel,
|
||||
observationStateLabel,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
type Props = {
|
||||
observation: AgentDeviceObservation | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function ObservationHistoryDialog({
|
||||
observation,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const [events, setEvents] = useState<AgentDeviceObservationEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !observation) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetchObservationHistory({
|
||||
observationKey: observation.observation_key,
|
||||
limit: 100,
|
||||
})
|
||||
.then(setEvents)
|
||||
.catch((err) => {
|
||||
setEvents([]);
|
||||
setError(String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, observation]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Observation History</DialogTitle>
|
||||
<DialogDescription>
|
||||
{observation
|
||||
? observationLabel(observation)
|
||||
: "No observation selected"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||
<div className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Time</span>
|
||||
<span>State</span>
|
||||
<span>Source</span>
|
||||
<span>Network</span>
|
||||
<span>Known Device</span>
|
||||
</div>
|
||||
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{formatSeen(event.ts_unix)}
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
variant={event.action === "remove" ? "outline" : "secondary"}
|
||||
>
|
||||
{observationStateLabel(event.action)}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="flex min-w-0 gap-1">
|
||||
<Badge variant="outline">{event.kind}</Badge>
|
||||
<Badge variant="outline">{event.action}</Badge>
|
||||
</span>
|
||||
<span className="min-w-0 text-muted-foreground">
|
||||
<span className="block truncate">{event.ip ?? "-"}</span>
|
||||
<span className="block truncate">{event.mac ?? "-"}</span>
|
||||
{event.hostname && (
|
||||
<span className="block truncate">{event.hostname}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{event.known_device?.display_name ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!events.length && (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{loading ? "Loading history..." : "No history found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
identifierSummary,
|
||||
observationLabel,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
type Props = {
|
||||
observation: AgentDeviceObservation;
|
||||
devices: KnownDevice[];
|
||||
busy: boolean;
|
||||
selectedDeviceId: string;
|
||||
newName: string;
|
||||
onSelectDevice: (deviceId: string) => void;
|
||||
onNewNameChange: (name: string) => void;
|
||||
onAttachExisting: () => void;
|
||||
onCreateAndAttach: () => void;
|
||||
};
|
||||
|
||||
export function ObservationIdentityActions({
|
||||
observation,
|
||||
devices,
|
||||
busy,
|
||||
selectedDeviceId,
|
||||
newName,
|
||||
onSelectDevice,
|
||||
onNewNameChange,
|
||||
onAttachExisting,
|
||||
onCreateAndAttach,
|
||||
}: Props) {
|
||||
const attachable = Boolean(observation.mac || observation.ip);
|
||||
const selectedDevice = devices.find(
|
||||
(device) => device.device_id === selectedDeviceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={selectedDeviceId}
|
||||
onValueChange={(value: string | null) => onSelectDevice(value ?? "")}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 flex-1">
|
||||
<span className="truncate">
|
||||
{selectedDevice?.display_name ?? "Attach to existing"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,28rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.device_id} value={device.device_id}>
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate">{device.display_name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{identifierSummary(device)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !attachable || !selectedDeviceId}
|
||||
onClick={onAttachExisting}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(event) => onNewNameChange(event.target.value)}
|
||||
placeholder={`Create as ${observationLabel(observation)}`}
|
||||
disabled={busy || !attachable}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || !attachable}
|
||||
onClick={onCreateAndAttach}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ObservationIdentityActions } from "@/pages/observations/ObservationIdentityActions";
|
||||
import {
|
||||
formatSeen,
|
||||
observationLabel,
|
||||
observationStateLabel,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
type Props = {
|
||||
observations: AgentDeviceObservation[];
|
||||
devices: KnownDevice[];
|
||||
selectedDevices: Record<string, string>;
|
||||
newNames: Record<string, string>;
|
||||
busyKey: string;
|
||||
onSelectDevice: (observationKey: string, deviceId: string) => void;
|
||||
onNewNameChange: (observationKey: string, name: string) => void;
|
||||
onAttachExisting: (observation: AgentDeviceObservation) => void;
|
||||
onCreateAndAttach: (observation: AgentDeviceObservation) => void;
|
||||
onOpenHistory: (observation: AgentDeviceObservation) => void;
|
||||
};
|
||||
|
||||
export function ObservationTable({
|
||||
observations,
|
||||
devices,
|
||||
selectedDevices,
|
||||
newNames,
|
||||
busyKey,
|
||||
onSelectDevice,
|
||||
onNewNameChange,
|
||||
onAttachExisting,
|
||||
onCreateAndAttach,
|
||||
onOpenHistory,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="grid gap-2 overflow-x-auto">
|
||||
<div className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||
<span>Device</span>
|
||||
<span>Network</span>
|
||||
<span>Agent</span>
|
||||
<span>Seen</span>
|
||||
<span>Known Device</span>
|
||||
<span>History</span>
|
||||
</div>
|
||||
|
||||
{observations.map((observation) => {
|
||||
const busy = busyKey === observation.observation_key;
|
||||
return (
|
||||
<div
|
||||
key={observation.observation_key}
|
||||
className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{observationLabel(observation)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{observation.kind}</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
observation.last_action === "remove"
|
||||
? "outline"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{observationStateLabel(observation.last_action)}
|
||||
</Badge>
|
||||
<Badge variant="outline">{observation.last_action}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 text-muted-foreground">
|
||||
<div className="truncate" title={observation.ip ?? "-"}>
|
||||
{observation.ip ?? "-"}
|
||||
</div>
|
||||
<div className="truncate" title={observation.mac ?? "-"}>
|
||||
{observation.mac ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-muted-foreground">
|
||||
{observation.agent_id}
|
||||
</div>
|
||||
<div className="min-w-0 text-muted-foreground">
|
||||
<div className="truncate">
|
||||
{formatSeen(observation.last_seen_unix)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{observation.known_device ? (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary">known</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{observation.known_device.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{observation.known_device.pinned ? "pinned" : "not pinned"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ObservationIdentityActions
|
||||
observation={observation}
|
||||
devices={devices}
|
||||
busy={busy}
|
||||
selectedDeviceId={
|
||||
selectedDevices[observation.observation_key] ?? ""
|
||||
}
|
||||
newName={newNames[observation.observation_key] ?? ""}
|
||||
onSelectDevice={(deviceId) =>
|
||||
onSelectDevice(observation.observation_key, deviceId)
|
||||
}
|
||||
onNewNameChange={(name) =>
|
||||
onNewNameChange(observation.observation_key, name)
|
||||
}
|
||||
onAttachExisting={() => onAttachExisting(observation)}
|
||||
onCreateAndAttach={() => onCreateAndAttach(observation)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenHistory(observation)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!observations.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No observations found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
attachObservationIdentifier,
|
||||
createKnownDevice,
|
||||
fetchKnownDevices,
|
||||
fetchObservations,
|
||||
type AgentDeviceObservation,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { ObservationHistoryDialog } from "@/pages/observations/ObservationHistoryDialog";
|
||||
import { ObservationTable } from "@/pages/observations/ObservationTable";
|
||||
import {
|
||||
observationLabel,
|
||||
type ObservationFilter,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
export function ObservationsPage() {
|
||||
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
||||
[],
|
||||
);
|
||||
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
||||
const [filter, setFilter] = useState<ObservationFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedDevices, setSelectedDevices] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
||||
const [busyKey, setBusyKey] = useState("");
|
||||
const [historyObservation, setHistoryObservation] =
|
||||
useState<AgentDeviceObservation | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextObservations, nextDevices] = await Promise.all([
|
||||
fetchObservations({ limit: 500 }),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
setObservations(nextObservations);
|
||||
setDevices(nextDevices);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return observations.filter((observation) => {
|
||||
if (filter === "known" && !observation.known_device) return false;
|
||||
if (filter === "unknown" && observation.known_device) return false;
|
||||
if (!q) return true;
|
||||
|
||||
const haystack = [
|
||||
observation.hostname,
|
||||
observation.mac,
|
||||
observation.ip,
|
||||
observation.agent_id,
|
||||
observation.kind,
|
||||
observation.last_action,
|
||||
observation.known_device?.display_name,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [filter, observations, query]);
|
||||
|
||||
async function attachExisting(observation: AgentDeviceObservation) {
|
||||
const deviceId = selectedDevices[observation.observation_key];
|
||||
if (!deviceId) return;
|
||||
setBusyKey(observation.observation_key);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const device = await attachObservationIdentifier(
|
||||
deviceId,
|
||||
observation.observation_key,
|
||||
);
|
||||
setStatus(
|
||||
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAttach(observation: AgentDeviceObservation) {
|
||||
const displayName =
|
||||
newNames[observation.observation_key]?.trim() ||
|
||||
observationLabel(observation);
|
||||
if (!displayName.trim()) return;
|
||||
setBusyKey(observation.observation_key);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const device = await createKnownDevice({
|
||||
display_name: displayName,
|
||||
pinned: true,
|
||||
identifiers: [],
|
||||
});
|
||||
const updated = await attachObservationIdentifier(
|
||||
device.device_id,
|
||||
observation.observation_key,
|
||||
);
|
||||
setStatus(
|
||||
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Observed Devices</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(value: ObservationFilter | null) => {
|
||||
if (value) setFilter(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<span>{filter}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectItem value="unknown">unknown</SelectItem>
|
||||
<SelectItem value="known">known</SelectItem>
|
||||
<SelectItem value="all">all</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Search</span>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="hostname, mac, ip, agent, known device"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filtered.length} of {observations.length}
|
||||
</p>
|
||||
{status && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{status}
|
||||
</pre>
|
||||
)}
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<ObservationTable
|
||||
observations={filtered}
|
||||
devices={devices}
|
||||
selectedDevices={selectedDevices}
|
||||
newNames={newNames}
|
||||
busyKey={busyKey}
|
||||
onSelectDevice={(observationKey, deviceId) =>
|
||||
setSelectedDevices((prev) => ({
|
||||
...prev,
|
||||
[observationKey]: deviceId,
|
||||
}))
|
||||
}
|
||||
onNewNameChange={(observationKey, name) =>
|
||||
setNewNames((prev) => ({ ...prev, [observationKey]: name }))
|
||||
}
|
||||
onAttachExisting={(observation) => void attachExisting(observation)}
|
||||
onCreateAndAttach={(observation) => void createAndAttach(observation)}
|
||||
onOpenHistory={setHistoryObservation}
|
||||
/>
|
||||
|
||||
<ObservationHistoryDialog
|
||||
observation={historyObservation}
|
||||
open={Boolean(historyObservation)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setHistoryObservation(null);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||
|
||||
export type ObservationFilter = "all" | "unknown" | "known";
|
||||
|
||||
export function observationLabel(observation: AgentDeviceObservation): string {
|
||||
return (
|
||||
observation.hostname?.trim() ||
|
||||
observation.mac?.trim() ||
|
||||
observation.ip?.trim() ||
|
||||
observation.observation_key
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSeen(tsUnix: number): string {
|
||||
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
||||
return new Date(tsUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function identifierSummary(device: KnownDevice): string {
|
||||
if (!device.identifiers.length) return "no identifiers";
|
||||
return device.identifiers
|
||||
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function observationStateLabel(action: string): string {
|
||||
if (action === "remove") return "stale";
|
||||
if (action === "add" || action === "update" || action === "old") {
|
||||
return "present";
|
||||
}
|
||||
return action;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
+40
-21
@@ -4,8 +4,6 @@ use clap::{ArgAction, Args, Parser, Subcommand};
|
||||
|
||||
use crate::config;
|
||||
|
||||
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "wakey-agent")]
|
||||
#[command(version, about = "Outbound control-plane agent for Wakey")]
|
||||
@@ -32,9 +30,6 @@ pub enum Command {
|
||||
InitConfig(InitConfigArgs),
|
||||
/// Reload a running agent daemon by sending SIGHUP.
|
||||
Reload(ReloadArgs),
|
||||
/// Upload local observations to the control plane once and exit.
|
||||
#[command(visible_alias = "sync")]
|
||||
SyncObservations(SyncObservationsArgs),
|
||||
/// Pass local hotplug observations through to the wakey CLI.
|
||||
Observe(ObserveArgs),
|
||||
}
|
||||
@@ -45,9 +40,9 @@ pub struct ServeArgs {
|
||||
#[arg(long, short = 'c', default_value = config::DEFAULT_CONFIG_PATH)]
|
||||
pub config: PathBuf,
|
||||
|
||||
/// Path to pid file for reload signaling.
|
||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||
pub pid_file: PathBuf,
|
||||
/// Path to pid file. Overrides `pid_file` in config.
|
||||
#[arg(long)]
|
||||
pub pid_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -72,9 +67,9 @@ pub struct EnrollArgs {
|
||||
#[arg(long)]
|
||||
pub reload_running: bool,
|
||||
|
||||
/// Path to pid file used when `--reload-running` is enabled.
|
||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||
pub pid_file: PathBuf,
|
||||
/// Path to pid file used when `--reload-running` is enabled. Overrides `pid_file` in config.
|
||||
#[arg(long)]
|
||||
pub pid_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -110,16 +105,9 @@ pub struct InitConfigArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ReloadArgs {
|
||||
/// Path to pid file for reload signaling.
|
||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||
pub pid_file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SyncObservationsArgs {
|
||||
/// Path to the agent config file.
|
||||
#[arg(long, short, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||
pub config: PathBuf,
|
||||
/// Path to pid file for reload signaling. Overrides `pid_file` in config.
|
||||
#[arg(long)]
|
||||
pub pid_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -161,3 +149,34 @@ pub struct ObserveNeighArgs {
|
||||
#[arg(long)]
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn global_config_is_accepted_before_enroll() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"wakey-agent",
|
||||
"--config",
|
||||
"/tmp/wakey-agent.toml",
|
||||
"enroll",
|
||||
"--server-url",
|
||||
"https://wakey.example.com",
|
||||
"--enroll-token",
|
||||
"token-123",
|
||||
])
|
||||
.expect("global --config should parse before enroll");
|
||||
|
||||
assert_eq!(cli.config, Some(PathBuf::from("/tmp/wakey-agent.toml")));
|
||||
let Command::Enroll(args) = cli.command else {
|
||||
panic!("expected enroll command");
|
||||
};
|
||||
assert_eq!(
|
||||
args.server_url.as_deref(),
|
||||
Some("https://wakey.example.com")
|
||||
);
|
||||
assert_eq!(args.enroll_token, "token-123");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
||||
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid";
|
||||
const WAKEY_DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
||||
const WAKEY_MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||
const WAKEY_OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||
@@ -22,6 +23,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_pid_file")]
|
||||
pub pid_file: PathBuf,
|
||||
#[serde(default = "default_dhcp_leases_path")]
|
||||
pub dhcp_leases_path: PathBuf,
|
||||
#[serde(default = "default_mac_name_cache_path")]
|
||||
@@ -42,6 +45,7 @@ impl fmt::Debug for AgentConfig {
|
||||
"observation_sync_interval_seconds",
|
||||
&self.observation_sync_interval_seconds,
|
||||
)
|
||||
.field("pid_file", &self.pid_file)
|
||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||
.field("observation_store_path", &self.observation_store_path)
|
||||
@@ -61,6 +65,10 @@ const fn default_observation_sync_interval_seconds() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_pid_file() -> PathBuf {
|
||||
DEFAULT_PID_FILE.into()
|
||||
}
|
||||
|
||||
fn default_dhcp_leases_path() -> PathBuf {
|
||||
DEFAULT_DHCP_LEASES_PATH.into()
|
||||
}
|
||||
@@ -175,6 +183,7 @@ mod tests {
|
||||
reconnect_base_ms: 123,
|
||||
reconnect_max_ms: 456,
|
||||
observation_sync_interval_seconds: 7,
|
||||
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(),
|
||||
observation_store_path: "/tmp/test-observations.json".into(),
|
||||
|
||||
@@ -29,6 +29,8 @@ async fn dispatch_leases(req: LeasesRequest, config: &AgentConfig) -> Result<Com
|
||||
)
|
||||
.await?;
|
||||
let leases = if req.include_state {
|
||||
// only runs when requested explicitly to the endpoint
|
||||
#[allow(deprecated)]
|
||||
wakey::wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await
|
||||
} else {
|
||||
wakey::leases_without_state(leases)
|
||||
@@ -70,7 +72,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
||||
&config.mac_name_cache_path,
|
||||
)
|
||||
.await?;
|
||||
let observations = match wakey::wakey_linux::dhcp::list_local_observations_from_path(
|
||||
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||
&config.observation_store_path,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -27,6 +27,7 @@ pub async fn enroll(
|
||||
server_url: &str,
|
||||
enroll_token: &str,
|
||||
config_path: &Path,
|
||||
base_config: Option<&AgentConfig>,
|
||||
) -> Result<EnrollOutcome> {
|
||||
let server_url = normalize_server_url(server_url);
|
||||
let endpoint = format!("{server_url}/api/v1/agents/enroll");
|
||||
@@ -58,12 +59,27 @@ pub async fn enroll(
|
||||
server_url: payload.server_url.unwrap_or(server_url),
|
||||
agent_id: payload.agent_id,
|
||||
agent_token: payload.agent_token,
|
||||
reconnect_base_ms: 1_000,
|
||||
reconnect_max_ms: 30_000,
|
||||
observation_sync_interval_seconds: 60,
|
||||
dhcp_leases_path: "/tmp/dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/wakey_mac_names.json".into(),
|
||||
observation_store_path: "/tmp/wakey_observations.json".into(),
|
||||
reconnect_base_ms: base_config
|
||||
.map(|config| config.reconnect_base_ms)
|
||||
.unwrap_or(1_000),
|
||||
reconnect_max_ms: base_config
|
||||
.map(|config| config.reconnect_max_ms)
|
||||
.unwrap_or(30_000),
|
||||
observation_sync_interval_seconds: base_config
|
||||
.map(|config| config.observation_sync_interval_seconds)
|
||||
.unwrap_or(60),
|
||||
pid_file: base_config
|
||||
.map(|config| config.pid_file.clone())
|
||||
.unwrap_or_else(|| crate::config::DEFAULT_PID_FILE.into()),
|
||||
dhcp_leases_path: base_config
|
||||
.map(|config| config.dhcp_leases_path.clone())
|
||||
.unwrap_or_else(|| "/tmp/dhcp.leases".into()),
|
||||
mac_name_cache_path: base_config
|
||||
.map(|config| config.mac_name_cache_path.clone())
|
||||
.unwrap_or_else(|| "/tmp/wakey_mac_names.json".into()),
|
||||
observation_store_path: base_config
|
||||
.map(|config| config.observation_store_path.clone())
|
||||
.unwrap_or_else(|| "/tmp/wakey_observations.json".into()),
|
||||
};
|
||||
let backup_path = save_config_with_backup(config_path, &config)?;
|
||||
info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written");
|
||||
@@ -143,7 +159,20 @@ mod tests {
|
||||
));
|
||||
let path = dir.join("config.toml");
|
||||
|
||||
let outcome = enroll(&server_url, "enroll-abc", &path)
|
||||
let base_config = AgentConfig {
|
||||
server_url: "https://old.example.com".into(),
|
||||
agent_id: "old-agent".into(),
|
||||
agent_token: "old-token".into(),
|
||||
reconnect_base_ms: 2_000,
|
||||
reconnect_max_ms: 60_000,
|
||||
observation_sync_interval_seconds: 30,
|
||||
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(),
|
||||
observation_store_path: "/tmp/custom-observations.json".into(),
|
||||
};
|
||||
|
||||
let outcome = enroll(&server_url, "enroll-abc", &path, Some(&base_config))
|
||||
.await
|
||||
.expect("enroll should succeed");
|
||||
let config = outcome.config;
|
||||
@@ -151,6 +180,8 @@ 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.pid_file, base_config.pid_file);
|
||||
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||
assert!(outcome.backup_path.is_none());
|
||||
|
||||
let persisted = crate::config::load_config(&path).expect("load persisted config");
|
||||
|
||||
+101
-26
@@ -29,21 +29,42 @@ async fn main() -> Result<()> {
|
||||
if let Some(config) = global_config {
|
||||
args.config = config.to_path_buf();
|
||||
}
|
||||
let existing_config = match config::load_config(&args.config) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => {
|
||||
if e.root_cause()
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
let resolved_server_url = if let Some(server_url) = args.server_url.as_deref() {
|
||||
server_url.to_string()
|
||||
} else if let Some(cfg) = existing_config.as_ref() {
|
||||
cfg.server_url.clone()
|
||||
} else {
|
||||
match config::load_config(&args.config) {
|
||||
Ok(cfg) => cfg.server_url,
|
||||
Err(_) => anyhow::bail!(
|
||||
"missing control-plane URL: pass --server-url or provide server_url in {}",
|
||||
args.config.display()
|
||||
),
|
||||
}
|
||||
anyhow::bail!(
|
||||
"missing control-plane URL: pass --server-url or provide server_url in {}",
|
||||
args.config.display()
|
||||
);
|
||||
};
|
||||
|
||||
::tracing::info!(server_url = %resolved_server_url, config = %args.config.display(), "wakey-agent command: enroll");
|
||||
let outcome =
|
||||
enroll::enroll(&resolved_server_url, &args.enroll_token, &args.config).await?;
|
||||
let outcome = enroll::enroll(
|
||||
&resolved_server_url,
|
||||
&args.enroll_token,
|
||||
&args.config,
|
||||
existing_config.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let pid_file = args
|
||||
.pid_file
|
||||
.as_deref()
|
||||
.unwrap_or(outcome.config.pid_file.as_path());
|
||||
println!("agent_id={}", outcome.config.agent_id);
|
||||
println!("config={}", args.config.display());
|
||||
println!("config_write=updated");
|
||||
@@ -51,10 +72,10 @@ async fn main() -> Result<()> {
|
||||
println!("config_backup={}", backup_path.display());
|
||||
}
|
||||
if args.reload_running {
|
||||
match serve::reload_daemon(&args.pid_file) {
|
||||
Ok(()) => println!("reload=signaled pid_file={}", args.pid_file.display()),
|
||||
match serve::reload_daemon(pid_file) {
|
||||
Ok(()) => println!("reload=signaled pid_file={}", pid_file.display()),
|
||||
Err(err) => {
|
||||
::tracing::warn!(error = %err, pid_file = %args.pid_file.display(), "enroll completed but daemon reload failed");
|
||||
::tracing::warn!(error = %err, pid_file = %pid_file.display(), "enroll completed but daemon reload failed");
|
||||
if let Some(backup_path) = &outcome.backup_path {
|
||||
match config::restore_backup(&args.config, backup_path) {
|
||||
Ok(()) => {
|
||||
@@ -83,9 +104,10 @@ async fn main() -> Result<()> {
|
||||
println!("reload=not_requested");
|
||||
println!("runtime_config=unchanged_until_reload_or_restart");
|
||||
println!(
|
||||
"next=wakey-agent reload --pid-file {} # or restart wakey-agent",
|
||||
args.pid_file.display()
|
||||
"next=wakey-agent reload --pid-file {} # if daemon is already running",
|
||||
pid_file.display()
|
||||
);
|
||||
println!("run={}", serve_command_for_config(&args.config));
|
||||
}
|
||||
}
|
||||
Command::InitConfig(mut args) => {
|
||||
@@ -98,17 +120,9 @@ async fn main() -> Result<()> {
|
||||
init_config(args)?
|
||||
}
|
||||
Command::Reload(args) => {
|
||||
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
|
||||
serve::reload_daemon(&args.pid_file)?
|
||||
}
|
||||
Command::SyncObservations(mut args) => {
|
||||
if let Some(config) = global_config {
|
||||
args.config = config.to_path_buf();
|
||||
}
|
||||
::tracing::info!(config = %args.config.display(), "wakey-agent command: sync-observations");
|
||||
let cfg = config::load_config(&args.config)?;
|
||||
let accepted = session::sync_observations_once(&cfg).await?;
|
||||
println!("observations_synced={accepted}");
|
||||
let pid_file = resolve_pid_file(global_config, args.pid_file.as_deref())?;
|
||||
::tracing::info!(pid_file = %pid_file.display(), "wakey-agent command: reload");
|
||||
serve::reload_daemon(&pid_file)?
|
||||
}
|
||||
Command::Observe(mut args) => {
|
||||
if let Some(config) = global_config {
|
||||
@@ -121,6 +135,27 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_command_for_config(config: &std::path::Path) -> String {
|
||||
if config == std::path::Path::new(config::DEFAULT_CONFIG_PATH) {
|
||||
"wakey-agent serve".to_string()
|
||||
} else {
|
||||
format!("wakey-agent --config {} serve", config.display())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_pid_file(
|
||||
config_path: Option<&std::path::Path>,
|
||||
explicit_pid_file: Option<&std::path::Path>,
|
||||
) -> Result<std::path::PathBuf> {
|
||||
if let Some(pid_file) = explicit_pid_file {
|
||||
return Ok(pid_file.to_path_buf());
|
||||
}
|
||||
if let Some(config_path) = config_path {
|
||||
return Ok(config::load_config(config_path)?.pid_file);
|
||||
}
|
||||
Ok(config::DEFAULT_PID_FILE.into())
|
||||
}
|
||||
|
||||
fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
let mut cmd = std::process::Command::new(resolve_wakey_binary());
|
||||
cmd.arg("observe");
|
||||
@@ -220,6 +255,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
reconnect_base_ms: 1_000,
|
||||
reconnect_max_ms: 30_000,
|
||||
observation_sync_interval_seconds: 60,
|
||||
pid_file: config::DEFAULT_PID_FILE.into(),
|
||||
dhcp_leases_path: "/tmp/dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/wakey_mac_names.json".into(),
|
||||
observation_store_path: "/tmp/wakey_observations.json".into(),
|
||||
@@ -239,10 +275,49 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
if let Some(path) = &args.config {
|
||||
config::save_config(path, &cfg)?;
|
||||
println!("config={}", path.display());
|
||||
println!("next=wakey-agent serve --config {}", path.display());
|
||||
println!("run={}", serve_command_for_config(path));
|
||||
} else {
|
||||
let rendered = toml::to_string_pretty(&cfg)?;
|
||||
print!("{}", rendered);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serve_command_omits_default_config_path() {
|
||||
assert_eq!(
|
||||
serve_command_for_config(std::path::Path::new(config::DEFAULT_CONFIG_PATH)),
|
||||
"wakey-agent serve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_command_includes_custom_config_path() {
|
||||
assert_eq!(
|
||||
serve_command_for_config(std::path::Path::new("/tmp/wakey-agent.toml")),
|
||||
"wakey-agent --config /tmp/wakey-agent.toml serve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_pid_file_wins_without_loading_config() {
|
||||
let pid_file = resolve_pid_file(
|
||||
Some(std::path::Path::new("/tmp/missing-agent.toml")),
|
||||
Some(std::path::Path::new("/tmp/explicit.pid")),
|
||||
)
|
||||
.expect("explicit pid should resolve");
|
||||
|
||||
assert_eq!(pid_file, std::path::PathBuf::from("/tmp/explicit.pid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_file_defaults_when_no_config_is_available() {
|
||||
let pid_file = resolve_pid_file(None, None).expect("default pid should resolve");
|
||||
|
||||
assert_eq!(pid_file, std::path::PathBuf::from(config::DEFAULT_PID_FILE));
|
||||
}
|
||||
}
|
||||
+10
-29
@@ -4,8 +4,8 @@ use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use wakey_core::parse::mac;
|
||||
use wakey_core::{
|
||||
DeviceInventory, DhcpLeaseWithState, InterfaceSummary, InventoryQuery, InventoryQueryBuilder,
|
||||
WakeResult,
|
||||
Device, DeviceInventory, DhcpLeaseWithState, InterfaceSummary, InventoryQuery,
|
||||
InventoryQueryBuilder, WakeResult,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -130,17 +130,6 @@ pub struct WakeRequest {
|
||||
pub ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentObservation {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AgentCommand {
|
||||
@@ -172,9 +161,9 @@ pub enum ClientMessage {
|
||||
Heartbeat {
|
||||
agent_id: String,
|
||||
},
|
||||
Observations {
|
||||
DeviceSnapshot {
|
||||
agent_id: String,
|
||||
observations: Vec<AgentObservation>,
|
||||
devices: Vec<Device>,
|
||||
},
|
||||
Result {
|
||||
request_id: RequestId,
|
||||
@@ -193,7 +182,7 @@ pub enum ServerMessage {
|
||||
request_id: RequestId,
|
||||
command: AgentCommand,
|
||||
},
|
||||
SyncObservations,
|
||||
SyncDeviceSnapshot,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -233,22 +222,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_message_serializes() {
|
||||
let msg = ClientMessage::Observations {
|
||||
fn device_snapshot_serializes() {
|
||||
let msg = ClientMessage::DeviceSnapshot {
|
||||
agent_id: "agent-a".into(),
|
||||
observations: vec![AgentObservation {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".into()),
|
||||
ip: Some("192.168.1.10".parse().expect("ip")),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
devices: vec![],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(json.contains("\"type\":\"observations\""));
|
||||
assert!(json.contains("\"kind\":\"dhcp\""));
|
||||
assert!(json.contains("\"type\":\"device_snapshot\""));
|
||||
assert!(json.contains("\"devices\""));
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,11 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
write_pid_file(&args.pid_file)?;
|
||||
info!(pid_file = %args.pid_file.display(), "wrote wakey-agent pid file");
|
||||
|
||||
let mut cfg = config::load_config(&args.config)?;
|
||||
let pid_file = args.pid_file.unwrap_or_else(|| cfg.pid_file.clone());
|
||||
write_pid_file(&pid_file)?;
|
||||
info!(pid_file = %pid_file.display(), "wrote wakey-agent pid file");
|
||||
|
||||
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "starting wakey-agent");
|
||||
|
||||
let mut worker = tokio::spawn(session::run(cfg.clone()));
|
||||
@@ -51,7 +52,7 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
||||
}
|
||||
join = &mut worker => {
|
||||
warn!("agent worker task exited; shutting down daemon");
|
||||
let _ = remove_pid_file(&args.pid_file);
|
||||
let _ = remove_pid_file(&pid_file);
|
||||
return join.context("agent session join failed")?;
|
||||
}
|
||||
}
|
||||
@@ -66,7 +67,7 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
||||
worker.abort();
|
||||
}
|
||||
|
||||
let _ = remove_pid_file(&args.pid_file);
|
||||
let _ = remove_pid_file(&pid_file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+23
-98
@@ -1,6 +1,5 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Serialize;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
|
||||
@@ -9,7 +8,7 @@ use tracing::{debug, error, info, info_span, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::dispatch::dispatch_command;
|
||||
use crate::protocol::{AgentCommand, AgentObservation, ClientMessage, ErrorPayload, ServerMessage};
|
||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||
|
||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
let mut backoff = config.reconnect_base_ms.max(100);
|
||||
@@ -87,11 +86,11 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
|
||||
let mut heartbeat = interval(Duration::from_secs(30));
|
||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
let mut observation_sync = interval(Duration::from_secs(
|
||||
let mut snapshot_sync = interval(Duration::from_secs(
|
||||
config.observation_sync_interval_seconds.max(1),
|
||||
));
|
||||
observation_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
observation_sync.reset();
|
||||
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
snapshot_sync.reset();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -101,9 +100,9 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
}).await?;
|
||||
debug!(agent_id = %config.agent_id, "heartbeat sent");
|
||||
}
|
||||
_ = observation_sync.tick() => {
|
||||
if let Err(err) = send_agent_observations_ws(&mut sink, config).await {
|
||||
warn!(agent_id = %config.agent_id, error = %err, "failed to sync local observations");
|
||||
_ = snapshot_sync.tick() => {
|
||||
if let Err(err) = send_device_snapshot_ws(&mut sink, config).await {
|
||||
warn!(agent_id = %config.agent_id, error = %err, "failed to sync device snapshot");
|
||||
}
|
||||
}
|
||||
maybe_msg = source.next() => {
|
||||
@@ -119,7 +118,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(message) => {
|
||||
handle_server_message(config, &mut sink, &mut observation_sync, message).await?;
|
||||
handle_server_message(config, &mut sink, &mut snapshot_sync, message).await?;
|
||||
}
|
||||
Err(err) => {
|
||||
// Allow the server to introduce extra frame types without
|
||||
@@ -146,94 +145,28 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UploadAgentObservationsRequest {
|
||||
agent_id: String,
|
||||
agent_token: String,
|
||||
observations: Vec<AgentObservation>,
|
||||
}
|
||||
|
||||
pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
||||
let client = reqwest::Client::new();
|
||||
send_agent_observations(&client, config).await
|
||||
}
|
||||
|
||||
async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig) -> Result<usize> {
|
||||
let observations = load_agent_observations(config).await?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let url = observations_url(&config.server_url)?;
|
||||
let payload = UploadAgentObservationsRequest {
|
||||
agent_id: config.agent_id.clone(),
|
||||
agent_token: config.agent_token.clone(),
|
||||
observations,
|
||||
};
|
||||
|
||||
let response = client
|
||||
.post(url.clone())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to call observation endpoint {url}"))?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<unreadable error body>".to_string());
|
||||
anyhow::bail!("observation upload failed with {status}: {body}");
|
||||
}
|
||||
debug!(
|
||||
agent_id = %config.agent_id,
|
||||
observations = payload.observations.len(),
|
||||
"synced local observations"
|
||||
);
|
||||
Ok(payload.observations.len())
|
||||
}
|
||||
|
||||
async fn send_agent_observations_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let observations = load_agent_observations(config).await?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = observations.len();
|
||||
let query = wakey_core::InventoryQueryBuilder::new().build();
|
||||
let inventory = wakey::inventory(query)
|
||||
.await
|
||||
.context("failed to run inventory for device snapshot")?;
|
||||
let count = inventory.devices.len();
|
||||
send_json(
|
||||
sink,
|
||||
&ClientMessage::Observations {
|
||||
&ClientMessage::DeviceSnapshot {
|
||||
agent_id: config.agent_id.clone(),
|
||||
observations,
|
||||
devices: inventory.devices,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
debug!(agent_id = %config.agent_id, observations = count, "sent observations over websocket");
|
||||
debug!(agent_id = %config.agent_id, devices = count, "sent device snapshot over websocket");
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn load_agent_observations(config: &AgentConfig) -> Result<Vec<AgentObservation>> {
|
||||
let observations =
|
||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
||||
.await
|
||||
.context("failed to read local observations")?;
|
||||
Ok(observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentObservation {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
let cap = max_ms.max(current_ms);
|
||||
current_ms.saturating_mul(2).min(cap)
|
||||
@@ -242,7 +175,7 @@ pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
async fn handle_server_message<S>(
|
||||
config: &AgentConfig,
|
||||
sink: &mut S,
|
||||
observation_sync: &mut tokio::time::Interval,
|
||||
snapshot_sync: &mut tokio::time::Interval,
|
||||
message: ServerMessage,
|
||||
) -> Result<()>
|
||||
where
|
||||
@@ -278,10 +211,10 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerMessage::SyncObservations => {
|
||||
info!("received observation sync request from control-plane");
|
||||
send_agent_observations_ws(sink, config).await?;
|
||||
observation_sync.reset();
|
||||
ServerMessage::SyncDeviceSnapshot => {
|
||||
info!("received device snapshot sync request from control-plane");
|
||||
send_device_snapshot_ws(sink, config).await?;
|
||||
snapshot_sync.reset();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -358,7 +291,7 @@ fn client_message_kind(message: &ClientMessage) -> &'static str {
|
||||
ClientMessage::Hello { .. } => "hello",
|
||||
ClientMessage::Auth { .. } => "auth",
|
||||
ClientMessage::Heartbeat { .. } => "heartbeat",
|
||||
ClientMessage::Observations { .. } => "observations",
|
||||
ClientMessage::DeviceSnapshot { .. } => "device_snapshot",
|
||||
ClientMessage::Result { .. } => "result",
|
||||
ClientMessage::Error { .. } => "error",
|
||||
}
|
||||
@@ -383,14 +316,6 @@ pub fn websocket_url(server_url: &str) -> Result<url::Url> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn observations_url(server_url: &str) -> Result<url::Url> {
|
||||
let mut url = url::Url::parse(server_url).context("invalid server_url")?;
|
||||
url.set_path("/api/v1/agents/observations");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn dns_resolution_diagnostics(ws_url: &url::Url) -> Option<(u64, usize)> {
|
||||
let host = ws_url.host_str()?;
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09db61a070a351a6255e21d5949285ccb19fca7f8f29a6c18dddac5495257a45"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "fact_json!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "12e443ec0991c042cf527498b567bbbb6d02885f72eb3fa426c9493368f8a9e7"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2cd47bbce9b9ae2ebf39f31b12722bb8d19ef6d90ac634731932a4cefbde03b7"
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE agent_id = ?1\n ORDER BY last_seen_unix DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "305f50ab2691e12c74e8a222ef12a16f14a65edd7b7c5400a196fd57a9788dcf"
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mac, ip\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3b06698566cea17eed9d10a389ee12a95cee2db6c3d450dca7a3b2d08e8fed7b"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "device_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3f24dfb51be2c3051dc8a8f732bd3e3fe9ca6b2649e1202d3725693b8011f269"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "409e1e7aa2d9bc46030001e5c522d7c2812b4583dceadfe5c535a866ac76489c"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4188fb9a0dac2e19fb1f83e023eb1e8d93f6ca896cd78a53668080639fc048d9"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4dd5e580826b0f6980bb8c2adecdc81ff95992ec337f93263463c5a4e357dc72"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4f4329b0a47a00a3b881a1bc5cf8bb9855351714ff827ccff917b3e623191504"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "52d22161e936b39b57d119aab592426e88f60387ccba58888cc7d440012777ed"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "55340d50a29e0a2d324ed946b6fa6503fbd4ea0674bb7ec24e3426c5b0e5832f"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", mac as \"mac!\"\n FROM agent_device_macs\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "580f6bc100d373137db6f853b8babd7633c700dbfaf17ded507b704fd98e46a5"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", mac as \"mac!\"\n FROM agent_device_macs",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5824c6cb841f7aaeb2a039f846effe17ba8038e35998b674136a19a4d42d8b88"
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n ORDER BY last_seen_unix DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5a0f37b5da194e432c795972d5b3bf67c2d2a00f379143d1ae5871ea790d1bf2"
|
||||
}
|
||||
+13
-13
@@ -1,50 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,\n last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n WHERE agent_id = ?1\n ORDER BY device_key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "mac",
|
||||
"name": "agent_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"name": "device_key",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"name": "presence",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"name": "display_name",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92775bf2e3b9f772d108bb8d0cd1aebbe01357ed70dc2af7b50ee348e668b319"
|
||||
"hash": "5f7e043ca389f1431c7b3478ea506b0c9bb706d32c06871d1267068dbe9cb746"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "69601f4f2268a759f03a2d533a0a30dec5cf05797817fd8d2426714cd84d7ada"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)\n ON CONFLICT (agent_id, device_key) DO UPDATE SET\n presence = excluded.presence,\n display_name = excluded.display_name,\n last_seen_unix = excluded.last_seen_unix",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a29f58cb7dd1b1bc510a0d04ecd1dbc7250824ba089dbc36ed9ac2e6dc920fd7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c423e005d85659547302f506380ad217cc31329262a1c0254ef532653b3a37bb"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 12
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n ORDER BY agent_id, device_key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "presence",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cd8585b9431d223962c376a2d463f0ca8128e7759c8694631f41db0cbd8eb221"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d442949dce7a4b9a8af97dc34fc34608029564740ad327c525c7f1466bc18a11"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dba637bf03d53bf3394ead0f01466f9a72fafc54f9ce239d2e5ac7f76b63b168"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts\n WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "fact_json!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e6be4d9782f66f98ebe4d4b6ec6e93ea9efe8e4c7f1147e1ff68d41a311d57f9"
|
||||
}
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user