Compare commits
67
Commits
58511e0f00
..
v0.3.6
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"
|
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"
|
test-all = "test --workspace --all-features --all-targets"
|
||||||
fmt-all = "fmt --all"
|
fmt-all = "fmt --all"
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -1 +1,10 @@
|
|||||||
SQLX_OFFLINE=true
|
# Live mode (local dev): query! macros validate every query against the real
|
||||||
|
# schema on each build, so the .sqlx cache is only needed for DB-less builds.
|
||||||
|
# Run scripts/prepare_sqlx_sqlite.sh once first — it creates and migrates the DB.
|
||||||
|
# Use an absolute path so builds work from any working directory.
|
||||||
|
# Replace the placeholder below with the DATABASE_URL printed by the script.
|
||||||
|
DATABASE_URL=sqlite:///absolute/path/to/wakey-prepare.sqlite3
|
||||||
|
|
||||||
|
# Offline mode (CI / packaging): force the committed .sqlx cache.
|
||||||
|
# Takes precedence over DATABASE_URL when set.
|
||||||
|
#SQLX_OFFLINE=true
|
||||||
@@ -18,6 +18,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Ensure toolchain and targets
|
- name: Ensure toolchain and targets
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -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/`.
|
|
||||||
@@ -11,6 +11,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ target/
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
.codex
|
.codex
|
||||||
|
|
||||||
|
# sqlx prepare DB (created by scripts/prepare_sqlx_sqlite.sh)
|
||||||
|
wakey-prepare.sqlite3*
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/vt100"]
|
||||||
|
path = vendor/vt100
|
||||||
|
url = https://github.com/ldlda/vt100-rust-1
|
||||||
+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": "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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"name": "mac",
|
"name": "agent_id",
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ip",
|
"name": "device_key",
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hostname",
|
"name": "presence",
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "first_seen_unix",
|
"name": "display_name",
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"type_info": "Integer"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "last_seen_unix",
|
"name": "first_seen_unix",
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "last_action!",
|
"name": "last_seen_unix",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 1
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
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 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, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "6ce45cc94cbfdd948aad5fb986f9b5976c79bb63759bc9ecff1a7873e2d70137"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b324587b94ee2e98dc8eff5f6735303a2784b74f49068ce8e946cedcd8d12c4e"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bd1666cea8840df04ddcdc8a0c42b9b7771f9bf63c45613c4b6f8b86be479d70"
|
||||||
|
}
|
||||||
+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": "DELETE FROM audit_events WHERE ts_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ca612fee8c80f815b33dbc088cc7e0a7552f1f4e38390959e14ea5cc765bf2c7"
|
||||||
|
}
|
||||||
+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 alert_transitions WHERE ts_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ce850a32c8702595bbc3207bf86d53209c48be145562192b3a8f84903d39eac1"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "dc7056b7421dcc83648cbd360273ebcce1156ce0f7da2d39f054025ab9b66b6b"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+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, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e83a43217a3bfaacd2f5e76df1fd05447558646fb2b47963751a7ab53b3a99b6"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
# Wakey
|
||||||
|
|
||||||
|
Wakey models devices discovered on a local network and the control-plane view used to wake and manage them across agents.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**Device**:
|
||||||
|
An operator-facing aggregate that represents one discovered network identity candidate.
|
||||||
|
_Avoid_: Host, machine, route
|
||||||
|
|
||||||
|
**Device Summary**:
|
||||||
|
The derived names, MACs, IPs, and presence shown on a device aggregate.
|
||||||
|
_Avoid_: Source of truth, route data
|
||||||
|
|
||||||
|
**Endpoint**:
|
||||||
|
One observed network contact point for a device, including the agent/source, optional MAC, optional IP, presence, and observation time.
|
||||||
|
_Avoid_: Raw observation, route, IP row
|
||||||
|
|
||||||
|
**Endpoint Source**:
|
||||||
|
The typed origin of an endpoint, such as neighbor data, DHCP lease data, or a hotplug hook.
|
||||||
|
_Avoid_: String kind, source label
|
||||||
|
|
||||||
|
**Observation Fact**:
|
||||||
|
Debug evidence copied from local observation inputs before it is interpreted into endpoints.
|
||||||
|
_Avoid_: Endpoint, identifier, source of truth
|
||||||
|
|
||||||
|
**Presence**:
|
||||||
|
The current reachability interpretation for a device or endpoint.
|
||||||
|
_Avoid_: Endpoint state, status
|
||||||
|
|
||||||
|
**Wake Route**:
|
||||||
|
An endpoint that has enough information and agent availability to attempt waking a device.
|
||||||
|
_Avoid_: Device, endpoint when not wakeable
|
||||||
|
|
||||||
|
**Known Device**:
|
||||||
|
A device identity manually curated by the operator.
|
||||||
|
_Avoid_: Auto-pinned device, saved observation
|
||||||
|
|
||||||
|
**Identifier**:
|
||||||
|
A manually approved MAC or IP value that links an observed device or endpoint to a known device.
|
||||||
|
_Avoid_: Observation key, route id
|
||||||
|
|
||||||
|
**Endpoint Key**:
|
||||||
|
The typed identity of endpoint evidence, based on source and optional MAC/IP.
|
||||||
|
_Avoid_: Stringly typed route id
|
||||||
|
|
||||||
|
**Agent Endpoint Key**:
|
||||||
|
An endpoint key scoped to the agent that observed it.
|
||||||
|
_Avoid_: Concatenated agent/source/MAC/IP string
|
||||||
|
|
||||||
|
**Agent**:
|
||||||
|
A fleet member that observes local network state and sends device snapshots to the control plane.
|
||||||
|
_Avoid_: Router when referring to the software actor
|
||||||
|
|
||||||
|
**Agent Host**:
|
||||||
|
The machine running an agent, including host identity and runtime metadata.
|
||||||
|
_Avoid_: Device when referring to the agent machine
|
||||||
|
|
||||||
|
**Agent Capability**:
|
||||||
|
Something an agent host can observe or do on the network.
|
||||||
|
_Avoid_: Router flag
|
||||||
|
|
||||||
|
**Terminal Capability**:
|
||||||
|
An explicitly enabled agent capability that allows the agent host to create remote PTY sessions.
|
||||||
|
_Avoid_: Command runner, implicit root access
|
||||||
|
|
||||||
|
**Terminal Session**:
|
||||||
|
One operator attachment to one PTY process hosted by an agent.
|
||||||
|
_Avoid_: Command, SSH session, stored shell
|
||||||
|
|
||||||
|
**Terminal Relay**:
|
||||||
|
The control plane's in-memory pairing of one operator terminal socket with one outbound agent terminal socket.
|
||||||
|
_Avoid_: Terminal session storage, terminal emulator
|
||||||
|
|
||||||
|
**Interface Telemetry**:
|
||||||
|
Current operational measurements for a network interface visible to an agent.
|
||||||
|
_Avoid_: Endpoint state, device presence
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- A **Device** has zero or more **Endpoints**.
|
||||||
|
- A **Device Summary** is derived from a **Device** and its **Endpoints**.
|
||||||
|
- Local source rows are grouped into **Devices** by endpoint-derived **DeviceId**, not by exact **Endpoint Key**.
|
||||||
|
- Within one complete local snapshot, MAC+IP endpoints may absorb IP-only endpoints with the same IP; this merge is not remembered across snapshots unless the operator creates an **Identifier**.
|
||||||
|
- An **Endpoint** belongs to exactly one **Agent**.
|
||||||
|
- An **Endpoint** has one **Endpoint Key**.
|
||||||
|
- An **Agent Endpoint Key** combines an **Agent** with an **Endpoint Key**.
|
||||||
|
- An **Endpoint** may have both MAC and IP, MAC only, or IP only.
|
||||||
|
- An **Endpoint** has exactly one **Endpoint Source**.
|
||||||
|
- An **Endpoint** has one **Presence**.
|
||||||
|
- An **Endpoint** may name the network interface where it was observed.
|
||||||
|
- An **Observation Fact** may explain why an **Endpoint** exists, but does not define identity or wakeability.
|
||||||
|
- A **Wake Route** is derived from exactly one **Endpoint**.
|
||||||
|
- A **Wake Route** may carry the interface from its source **Endpoint**.
|
||||||
|
- A **Known Device** has zero or more **Identifiers**.
|
||||||
|
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
|
||||||
|
- An **Agent** runs on one **Agent Host**.
|
||||||
|
- An **Agent Capability** describes what an **Agent Host** can observe or do.
|
||||||
|
- **Terminal Capability** is an **Agent Capability** and must be explicitly enabled before an agent can host a **Terminal Session**.
|
||||||
|
- A **Terminal Session** owns one PTY and is independent of one-shot agent commands.
|
||||||
|
- A **Terminal Relay** pairs exactly one operator socket with exactly one agent socket for a **Terminal Session**.
|
||||||
|
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
|
||||||
|
|
||||||
|
## Example dialogue
|
||||||
|
|
||||||
|
> **Dev:** "This **Device** has three IPs. Which one should the UI wake?"
|
||||||
|
> **Domain expert:** "Do not infer that from the **Device** summary. Use the **Wake Routes** derived from its **Endpoints**."
|
||||||
|
|
||||||
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
- "route" was used for both observed network contact points and wakeable paths — resolved: use **Endpoint** for observed contact points and **Wake Route** only when the endpoint can be used for wake.
|
||||||
|
- "device" was used for both an identity aggregate and each MAC/IP pair — resolved: use **Device** for the aggregate and **Endpoint** for each observed MAC/IP/source/state tuple.
|
||||||
|
- `Device.ips` and `Device.macs` are true summary values, but they are not authoritative for wake, freshness, or per-IP state decisions — resolved: use **Endpoint** for those decisions.
|
||||||
|
- MAC-only and IP-only observations are still **Endpoints** — resolved: **Wake Routes** require a MAC and a connected **Agent**, while IP is optional route detail.
|
||||||
|
- Endpoint-specific states such as "removed" or "stale" are not separate concepts yet — resolved: use **Presence** until a distinction appears that presence cannot express cleanly.
|
||||||
|
- Endpoint origin is not a free string — resolved: use **Endpoint Source** for domain logic and reserve raw strings for debug facts only.
|
||||||
|
- Local observation file rows map naturally to **Observation Facts** — resolved: keep them for debugging, but do not use them as the operational model.
|
||||||
|
- Offline **Endpoints** remain part of the API model — resolved: UI may collapse them from the primary view, but the API should still expose them for details, copy, and debugging.
|
||||||
|
- **Device Summary** IPs come only from concrete live-ish sources such as neighbor table rows and current DHCP lease rows — resolved: hotplug hook memory creates **Endpoints** and **Observation Facts**, but does not directly inflate summary IPs.
|
||||||
|
- Router-ness is not a boolean domain concept — resolved: model **Agent Capabilities** and **Agent Host** metadata instead.
|
||||||
|
- **Presence** ordering should treat **Unknown** as non-voting/no evidence, weaker than **Offline** — resolved: concrete offline evidence should not be overridden by unknown hook or DHCP ambiguity.
|
||||||
|
- **Device** presence is derived from all **Endpoints**, not only summary-eligible endpoints.
|
||||||
|
- Current DHCP leases are summary-eligible **Endpoints** with **Unknown** presence; they may support wake when a MAC is present, but they do not prove reachability.
|
||||||
|
- DHCP hook events, including removal, indicate **Unknown** because they describe lease churn rather than reachability.
|
||||||
|
- Neighbor table and neighbor hook events may indicate **Offline** when they report failed or removed reachability.
|
||||||
|
- **Presence** and wakeability are separate: an **Offline** endpoint may still produce a **Wake Route** if it has a MAC and a connected **Agent**.
|
||||||
|
- **Endpoint** does not store wakeability; wakeability is derived from endpoint data plus **Agent** runtime and capability context.
|
||||||
|
- Preferred **Wake Routes** are ordered by connected agent, wakeability, having an IP, presence quality, recency, then source quality.
|
||||||
|
- **Endpoint** evidence preserves source-specific rows; equivalent **Wake Routes** may ignore source when they target the same agent, MAC, and IP.
|
||||||
|
- The control plane stores **Endpoints** as structured data so fleet search, identity matching, and wake route selection do not depend on parsing opaque device JSON.
|
||||||
|
- Summary MAC/IP collections are derived from **Endpoints**; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
|
||||||
|
- API arrays should be domain-sorted by default; UI may sort top-level fleet rows for presentation, but route ranking and recommended route selection are backend-owned.
|
||||||
|
- The main fleet API embeds **Endpoints** in each **Device** response; a separate endpoint API is unnecessary until payload size or access patterns require it.
|
||||||
|
- The control plane stores current per-agent endpoint snapshots, not endpoint event history.
|
||||||
|
- Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement; default retention is 7 days unless configured otherwise.
|
||||||
|
- Live-source **Endpoints** use snapshot time as their observed time; hook-derived **Endpoints** use the hook row's recorded last-seen time.
|
||||||
|
- **DeviceId**, **Endpoint Key**, and **Agent Endpoint Key** are typed concepts; string keys are boundary serialization only.
|
||||||
|
- **DeviceId** is derived from a device's **Endpoints**: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent.
|
||||||
|
- Hostnames are endpoint evidence/display data, not part of **Endpoint Key** identity.
|
||||||
|
- Interface names are endpoint metadata, not part of **Endpoint Key** identity; multi-segment routers may require scoped endpoint identity later if the same source/MAC/IP has different meaning on different interfaces.
|
||||||
|
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
|
||||||
|
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
|
||||||
|
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
|
||||||
|
- **Terminal Sessions** are not agent commands — resolved: command dispatch remains request/result, while terminals use dedicated streaming sockets.
|
||||||
|
- **Terminal Relay** is ephemeral control-plane state — resolved: terminal bytes and scrollback are not stored or audited.
|
||||||
|
- Terminal input, output, and terminal-generated control sequences are byte streams; only lifecycle and resize operations are structured terminal protocol messages.
|
||||||
Generated
+64
-127
@@ -97,6 +97,12 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -233,12 +239,6 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "1.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -475,24 +475,6 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crc32fast"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-epoch"
|
|
||||||
version = "0.9.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
@@ -514,10 +496,10 @@ version = "0.29.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"document-features",
|
"document-features",
|
||||||
"parking_lot 0.12.5",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -747,16 +729,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fs2"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -813,7 +785,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"lock_api",
|
"lock_api",
|
||||||
"parking_lot 0.12.5",
|
"parking_lot",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -862,15 +834,6 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fxhash"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -1309,15 +1272,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "instant"
|
|
||||||
version = "0.1.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1470,7 +1424,7 @@ version = "0.1.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.7.4",
|
"redox_syscall 0.7.4",
|
||||||
@@ -1617,7 +1571,7 @@ version = "0.28.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445"
|
checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"netlink-packet-core",
|
"netlink-packet-core",
|
||||||
@@ -1656,7 +1610,7 @@ version = "0.30.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1668,7 +1622,7 @@ version = "0.31.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1859,17 +1813,6 @@ version = "2.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking_lot"
|
|
||||||
version = "0.11.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
|
|
||||||
dependencies = [
|
|
||||||
"instant",
|
|
||||||
"lock_api",
|
|
||||||
"parking_lot_core 0.8.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1877,21 +1820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
"parking_lot_core 0.9.12",
|
"parking_lot_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking_lot_core"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"instant",
|
|
||||||
"libc",
|
|
||||||
"redox_syscall 0.2.16",
|
|
||||||
"smallvec",
|
|
||||||
"winapi",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2053,6 +1982,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pty-process"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2189,22 +2128,13 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_syscall"
|
|
||||||
version = "0.2.16"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2213,7 +2143,7 @@ version = "0.7.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2394,7 +2324,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -2557,7 +2487,7 @@ version = "3.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2696,7 +2626,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot 0.12.5",
|
"parking_lot",
|
||||||
"scc",
|
"scc",
|
||||||
"serial_test_derive",
|
"serial_test_derive",
|
||||||
]
|
]
|
||||||
@@ -2775,22 +2705,6 @@ version = "0.4.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sled"
|
|
||||||
version = "0.34.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
|
|
||||||
dependencies = [
|
|
||||||
"crc32fast",
|
|
||||||
"crossbeam-epoch",
|
|
||||||
"crossbeam-utils",
|
|
||||||
"fs2",
|
|
||||||
"fxhash",
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"parking_lot 0.11.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -2923,7 +2837,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -2966,7 +2880,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -3399,7 +3313,7 @@ version = "0.6.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3677,7 +3591,6 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3699,9 +3612,19 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.3"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3722,7 +3645,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.2.3"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3742,16 +3665,18 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wakey",
|
"wakey",
|
||||||
"wakey-core",
|
"wakey-core",
|
||||||
|
"wakey-vt100",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.2.3"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"macaddr",
|
||||||
"nix 0.31.2",
|
"nix 0.31.2",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry-otlp",
|
"opentelemetry-otlp",
|
||||||
@@ -3759,7 +3684,6 @@ dependencies = [
|
|||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sled",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3770,14 +3694,16 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wakey-agent",
|
"wakey-agent",
|
||||||
|
"wakey-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.2.3"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"strum",
|
"strum",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -3785,12 +3711,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.2.3"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
"lda-ipjs",
|
"lda-ipjs",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
|
"nix 0.31.2",
|
||||||
|
"pty-process",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
@@ -3798,6 +3726,15 @@ dependencies = [
|
|||||||
"wakey-core",
|
"wakey-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wakey-vt100"
|
||||||
|
version = "0.16.2-wakey.2"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -3930,7 +3867,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4435,7 +4372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.0",
|
"bitflags",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.3"
|
version = "0.3.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
@@ -31,6 +31,23 @@ strip = true
|
|||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||||
|
exclude = ["vendor/vt100"]
|
||||||
|
|
||||||
|
[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]
|
[dependencies.lda-ipjs]
|
||||||
path = "ipjs"
|
path = "ipjs"
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ Relative paths in config are resolved under `data_dir`.
|
|||||||
Legacy sled state can be imported explicitly:
|
Legacy sled state can be imported explicitly:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wakey-control-plane import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /var/lib/wakey-control-plane/state.sqlite3
|
wakey-control-plane migrate-sqlite-state --from /var/lib/wakey-control-plane/legacy.sqlite3 --to /var/lib/wakey-control-plane/state.sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
||||||
@@ -207,10 +207,23 @@ Control-plane admin API includes token management endpoints:
|
|||||||
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
||||||
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
||||||
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
||||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
|
- `GET /api/v1/control/alerts/ws` (websocket snapshots, recent transitions, and evaluator health)
|
||||||
|
- `GET /api/v1/control/terminals/events/ws` (websocket session-list invalidations)
|
||||||
- `DELETE /api/v1/control/agents/{agent_id}`
|
- `DELETE /api/v1/control/agents/{agent_id}`
|
||||||
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
|
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
|
||||||
|
|
||||||
|
Alert evaluation runs once per control plane every five seconds. The alerts GET
|
||||||
|
returns `503 alerts_unavailable` until a successful evaluation, after evaluation
|
||||||
|
failure, or when its snapshot is stale. The existing alerts stream also sends
|
||||||
|
`alerts_health` messages with `available` and an optional `reason`; the UI keeps
|
||||||
|
the last results visible with a stale warning until recovery. Custom alert-rule
|
||||||
|
query overrides are rejected with `400 unsupported_alert_override`.
|
||||||
|
|
||||||
|
Terminal session events carry only `sessions_changed` invalidations. Clients
|
||||||
|
refetch the session list after changes or reconnection and retain a 15-second
|
||||||
|
fallback refresh. Events do not grant session ownership; attachment still uses
|
||||||
|
the existing server-side locking and scoped credentials.
|
||||||
|
|
||||||
If commands still appear silent, verify both processes are running with `-v`
|
If commands still appear silent, verify both processes are running with `-v`
|
||||||
and that `RUST_LOG` is not overriding to a stricter level.
|
and that `RUST_LOG` is not overriding to a stricter level.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Caddy template for wakey-control-plane with Cloudflare Access.
|
# Caddy template for wakey-control-plane with Cloudflare Access.
|
||||||
#
|
#
|
||||||
# Security model:
|
# Security model:
|
||||||
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
|
# - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay WS
|
||||||
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
||||||
#
|
#
|
||||||
# Replace cp.example.com with your public control-plane domain.
|
# Replace cp.example.com with your public control-plane domain.
|
||||||
@@ -10,7 +10,7 @@ wakey.ldlda.com {
|
|||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Public agent-facing endpoints.
|
# Public agent-facing endpoints.
|
||||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws /api/v1/agent/terminals/*
|
||||||
handle @public {
|
handle @public {
|
||||||
reverse_proxy 127.0.0.1:6767
|
reverse_proxy 127.0.0.1:6767
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Core Endpoints and Wake Routes
|
||||||
|
|
||||||
|
Wakey will model observed network contact points as **Endpoints** in `wakey-core`, and derive **Wake Routes** from those endpoints instead of reconstructing routes from flattened device summary MAC/IP arrays. `Device.ips` and `Device.macs` remain true summary fields for operator display and compatibility, but endpoint data is authoritative for wake, freshness, source, per-IP state, and route selection.
|
||||||
|
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The previous model stored a `Device` aggregate with summary `macs`, `ips`, `presence`, and raw observation facts. The control plane then built fleet wake routes from combinations like first MAC plus first IP. That lost source, presence, interface, and MAC/IP-pair evidence, especially when hook memory contained removed IPs or when several sources described the same address differently.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a typed `DeviceEndpoint` concept in `wakey-core`. A device snapshot sent by an agent contains devices with endpoints already attached. The control plane stores structured endpoint rows and derives fleet summaries and wake routes from those rows.
|
||||||
|
|
||||||
|
Endpoint shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
DeviceEndpoint {
|
||||||
|
key: EndpointKey,
|
||||||
|
hostname: Option<String>,
|
||||||
|
interface: Option<String>,
|
||||||
|
presence: Presence,
|
||||||
|
first_seen_unix: Option<u64>,
|
||||||
|
last_seen_unix: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
EndpointKey {
|
||||||
|
source: EndpointSource,
|
||||||
|
mac: Option<MacAddr>,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentEndpointKey {
|
||||||
|
agent_id: String,
|
||||||
|
endpoint: EndpointKey,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`EndpointKey` must contain at least one of MAC or IP. Hostname and interface are endpoint evidence/metadata, not identity. `AgentEndpointKey` may use `serde(flatten)` for wire shape, but hash values are never durable storage keys. String keys are boundary serialization only.
|
||||||
|
|
||||||
|
## Source and Presence Rules
|
||||||
|
|
||||||
|
Endpoint sources are typed domain variants, not free strings:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Neighbor
|
||||||
|
DhcpLease
|
||||||
|
HookNeighbor
|
||||||
|
HookDhcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Presence answers “is this reachable?”, not “can this be woken?” The presence order is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Unknown < Offline < LikelyOnline < Online
|
||||||
|
```
|
||||||
|
|
||||||
|
Source mapping:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Neighbor reachable/permanent -> Online
|
||||||
|
Neighbor stale -> LikelyOnline
|
||||||
|
Neighbor failed/remove-like evidence -> Offline
|
||||||
|
Other neighbor states -> Unknown
|
||||||
|
|
||||||
|
Current DHCP lease -> Unknown
|
||||||
|
Hook DHCP add/update/remove -> Unknown
|
||||||
|
Hook neighbor add/update/old -> LikelyOnline
|
||||||
|
Hook neighbor remove -> Offline
|
||||||
|
```
|
||||||
|
|
||||||
|
Current DHCP leases are summary-eligible and may support wake if they include a MAC, but they do not prove reachability. Hook memory creates endpoints and observation facts, but does not directly inflate summary IPs.
|
||||||
|
|
||||||
|
## Summary, Presence, and UI
|
||||||
|
|
||||||
|
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
|
||||||
|
|
||||||
|
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
|
||||||
|
|
||||||
|
The main fleet API embeds endpoints inside each fleet device. API responses expose all endpoints, including offline and unknown endpoints. The UI may collapse offline/unknown endpoints in the primary row, but details must expose them for copy/debug/history.
|
||||||
|
|
||||||
|
## Wakeability and Wake Route Ordering
|
||||||
|
|
||||||
|
Wakeability is derived, not stored on `DeviceEndpoint`.
|
||||||
|
|
||||||
|
An endpoint is wakeable when:
|
||||||
|
|
||||||
|
```text
|
||||||
|
endpoint has a MAC
|
||||||
|
agent is connected
|
||||||
|
agent has wake capability
|
||||||
|
endpoint source is allowed for wake route derivation
|
||||||
|
```
|
||||||
|
|
||||||
|
For now, a connected wakey-agent is assumed to have wake capability. Presence does not gate wakeability: an `Offline` endpoint may still produce a wake route, because waking powered-off devices is the core use case.
|
||||||
|
|
||||||
|
Preferred wake route order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
connected agent
|
||||||
|
wakeable route
|
||||||
|
has IP
|
||||||
|
presence quality
|
||||||
|
newest last_seen
|
||||||
|
source quality
|
||||||
|
```
|
||||||
|
|
||||||
|
Presence quality for route ranking follows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Online > LikelyOnline > Offline > Unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
Source quality follows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Neighbor > DhcpLease > HookNeighbor > HookDhcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent wake routes may ignore endpoint source when they target the same agent, MAC, and IP. Endpoint evidence preserves source-specific rows; route grouping may deduplicate equivalent targets.
|
||||||
|
|
||||||
|
## Device and Identity Rules
|
||||||
|
|
||||||
|
`DeviceId` remains the typed observed identity enum:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
DeviceId::Mac(MacAddr)
|
||||||
|
DeviceId::Ip(IpAddr)
|
||||||
|
```
|
||||||
|
|
||||||
|
`DeviceId` is derived from endpoints: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent. Local source rows are grouped into devices by endpoint-derived `DeviceId`, not exact `EndpointKey`.
|
||||||
|
|
||||||
|
Within a single complete local snapshot, a MAC+IP endpoint may absorb an IP-only endpoint with the same IP. This merge is not remembered across snapshots unless the operator creates a known-device identifier.
|
||||||
|
|
||||||
|
Known-device identifiers are source-independent ownership claims over MAC or permanent IP values. Wakey does not infer whether an IP is permanent, static, or reserved; the UI should warn the operator when attaching an IP identifier.
|
||||||
|
|
||||||
|
## Control Plane Storage
|
||||||
|
|
||||||
|
The control plane stores current per-agent endpoint snapshots as structured columns, not opaque `Device` JSON and not endpoint event history. Summary MAC/IP collections are derived from endpoints; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
|
||||||
|
|
||||||
|
Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement. The default retention is 7 days unless configured otherwise. Live-source endpoints use snapshot time as their observed time; hook-derived endpoints use the hook row's recorded last-seen time.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Wake route construction moves away from `macs.first()` / `ips.first()` guessing.
|
||||||
|
- The agent/core layer owns source interpretation because it still has local context.
|
||||||
|
- The control plane can search, filter, match known identifiers, and rank wake routes from structured endpoint rows.
|
||||||
|
- UI can show clean summaries while keeping complete endpoint evidence in details.
|
||||||
|
- This preserves “wake an offline machine by MAC” without pretending that offline means not wakeable.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Remote Terminal Sessions
|
||||||
|
|
||||||
|
Wakey will provide opt-in remote terminal access by pairing a browser terminal WebSocket with a dedicated outbound agent terminal WebSocket. The existing agent WebSocket remains the control channel and does not carry terminal byte streams.
|
||||||
|
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The control plane already maintains an authenticated outbound WebSocket from each connected agent. Its command protocol is intentionally request/result shaped: the control plane sends one command, the agent executes it, and one result completes the pending request.
|
||||||
|
|
||||||
|
An interactive terminal has different semantics. It is long-lived, bidirectional, byte-oriented, and must handle window resizing, process exit, disconnect cleanup, and backpressure. Treating it as an ordinary command would either block the agent session loop or turn the main agent WebSocket into a general stream multiplexer. It would also allow terminal output to compete with heartbeats, snapshots, and wake commands.
|
||||||
|
|
||||||
|
Agents commonly run behind NAT and must not expose an inbound listener. Terminal connections therefore remain agent-initiated and outbound.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use one dedicated agent WebSocket and one dedicated operator WebSocket for each terminal session. The control plane holds an in-memory terminal relay that pairs those sockets.
|
||||||
|
|
||||||
|
Session establishment follows this sequence:
|
||||||
|
|
||||||
|
1. An authenticated operator requests a terminal for a connected, terminal-capable agent.
|
||||||
|
2. The control plane creates an ephemeral terminal ID plus short-lived, single-use attachment credentials.
|
||||||
|
3. The control plane sends an open-terminal control message over the existing authenticated agent WebSocket.
|
||||||
|
4. The agent opens a new outbound terminal WebSocket to the control plane and authenticates it for that terminal ID.
|
||||||
|
5. The browser opens the corresponding protected operator terminal WebSocket.
|
||||||
|
6. The control plane pairs the sockets and relays terminal frames while an operator is attached.
|
||||||
|
|
||||||
|
The main agent WebSocket carries terminal creation and cancellation control only. It does not carry PTY input or output.
|
||||||
|
|
||||||
|
## PTY Ownership
|
||||||
|
|
||||||
|
The agent owns the PTY, shell process, process group, and all process cleanup. The control plane never spawns or emulates a shell.
|
||||||
|
|
||||||
|
Use `pty-process` with its async feature as the initial PTY abstraction because it provides:
|
||||||
|
|
||||||
|
- Tokio `AsyncRead` and `AsyncWrite` integration;
|
||||||
|
- owned read and write halves for concurrent tasks;
|
||||||
|
- terminal resizing;
|
||||||
|
- child session-leader and controlling-terminal setup; and
|
||||||
|
- a small implementation built on `rustix`.
|
||||||
|
|
||||||
|
The PTY wrapper is cross-compiled for `armv7-unknown-linux-musleabihf` and its input, output, resize, and exit test has passed on the target router. That focused test remains part of the remote compatibility workflow. Wakey does not call `forkpty` or add its own unsafe PTY implementation.
|
||||||
|
|
||||||
|
The executable, working directory, environment, UID, and GID are agent-controlled configuration. The browser cannot supply an arbitrary executable or process environment.
|
||||||
|
|
||||||
|
## Wire Protocol
|
||||||
|
|
||||||
|
WebSocket frame type separates terminal data from terminal control:
|
||||||
|
|
||||||
|
- Binary frames carry raw PTY input and output bytes.
|
||||||
|
- Text JSON frames carry lifecycle, error, and resize messages.
|
||||||
|
|
||||||
|
The terminal control vocabulary is deliberately small:
|
||||||
|
|
||||||
|
```text
|
||||||
|
operator -> agent: resize, close
|
||||||
|
control plane -> agent: snapshot
|
||||||
|
agent -> operator: ready, exited, error
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial terminal dimensions are part of session attachment. Resize carries rows and columns; pixel dimensions may be added later if a demonstrated program requires them.
|
||||||
|
|
||||||
|
Terminal behavior such as Ctrl-C, Ctrl-Z, Ctrl-D, cursor keys, mouse reporting, bracketed paste, colors, and terminal-title changes remains in the byte stream. The control plane does not parse ANSI escape sequences. The browser terminal emulator renders output and owns connected-session scrollback. The agent also feeds PTY output into a `vt100` parser so it can reconstruct the current visible screen and terminal input modes after detachment. Agent PTYs advertise `TERM=xterm-256color` and `COLORTERM=truecolor` because xterm.js is the actual frontend; applications may therefore negotiate xterm mouse reporting without a Wakey-specific mouse protocol.
|
||||||
|
|
||||||
|
WebSockets already provide ordered, reliable delivery, so terminal frames do not carry sequence numbers in the initial protocol.
|
||||||
|
|
||||||
|
## Lifecycle and Cleanup
|
||||||
|
|
||||||
|
Terminal sessions allow only one attached operator. Their lifetime belongs to the agent process, not to a browser route or a particular control-plane connection.
|
||||||
|
|
||||||
|
When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane does not retain terminal output.
|
||||||
|
|
||||||
|
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
|
||||||
|
|
||||||
|
The protected `/api/v1/control/terminals/events/ws` stream sends `sessions_changed` invalidations when the volatile session catalog or attachment ownership changes. These events contain no terminal bytes or attachment credentials: browsers refetch the ordinary session list, coalescing bursts and repeating a fetch if another event arrives during it. Subscribing and reconnecting also invalidate the list. The 15-second refresh remains a recovery fallback; the stream is a freshness hint, never an authorization or locking mechanism.
|
||||||
|
|
||||||
|
The React page composes three custom hooks rather than owning every resource itself. `useXterm` owns the single renderer, its addons, fitting, fonts, and display operations. `useTerminalSessions` owns the catalog subscription, coalesced refreshes, and fallback timer. `useTerminalSession` owns active attachment, input, restoration, and session operations. The attachment hook waits for renderer readiness; stable renderer operations prevent ordinary state updates from rebuilding the terminal or reconnecting its transport. Each hook cleans up its own resources on unmount, which detaches the browser without deleting the agent-owned PTY.
|
||||||
|
|
||||||
|
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 1,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session. The connected browser independently retains up to 10,000 rows because that memory belongs to the operator device rather than the agent host.
|
||||||
|
|
||||||
|
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
|
||||||
|
|
||||||
|
The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into the agent-owned terminal parser while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite.
|
||||||
|
|
||||||
|
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
|
||||||
|
|
||||||
|
The agent configuration sets `terminal.session_ttl_seconds` for newly created PTYs. The default is 43,200 seconds (12 hours); explicit zero disables automatic expiry. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped.
|
||||||
|
|
||||||
|
An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design.
|
||||||
|
|
||||||
|
The agent reports a normal exit status or terminating signal when available. The UI must distinguish an exited shell from a transport failure.
|
||||||
|
|
||||||
|
## Backpressure and Resource Limits
|
||||||
|
|
||||||
|
Terminal transport must not use unbounded queues.
|
||||||
|
|
||||||
|
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 1,000-row reconnect scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||||
|
|
||||||
|
While a relay is attached, saturation applies lossless backpressure instead of dropping terminal bytes or disconnecting the relay. The agent retains the frame that discovered saturation and pauses further PTY reads until bounded relay capacity returns. Input, resize, close, lifecycle, and reconnect handling remain active while output is backpressured. Reconnect snapshots use the same ordered output path and duplicate snapshot requests are coalesced while one is pending. This is required for terminal protocols such as sixel, where dropping one output frame corrupts the rest of the stream.
|
||||||
|
|
||||||
|
When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and snapshot traffic responsive while commands produce sustained output.
|
||||||
|
|
||||||
|
The implementation also enforces:
|
||||||
|
|
||||||
|
- maximum terminal frame size;
|
||||||
|
- per-agent concurrent-session limits;
|
||||||
|
- idle and absolute session timeouts;
|
||||||
|
- bounded parsed terminal state;
|
||||||
|
- bounded control-plane relay buffers; and
|
||||||
|
- cleanup when any relay task exits unexpectedly.
|
||||||
|
|
||||||
|
Exact limits are configuration and may be tuned from router testing. The bounded behavior is an architectural requirement.
|
||||||
|
|
||||||
|
## Security and Audit
|
||||||
|
|
||||||
|
Remote terminal access is root-equivalent on agents that run as root. It is therefore an explicit agent capability and is disabled by default.
|
||||||
|
|
||||||
|
Operator terminal endpoints belong to the protected control API surface. Session identifiers are not credentials. Agent and operator attachment credentials are short-lived, scoped to one terminal session, and single-use. The operator WebSocket must enforce the same-origin/protected-control assumptions used when the session was created.
|
||||||
|
|
||||||
|
Audit records include terminal request, open, ready, disconnect, close, timeout, and exit metadata. Wakey never stores or audits terminal input, terminal output, command history, or scrollback.
|
||||||
|
|
||||||
|
Terminal capability and session state are separate from device inventory, known-device identity, wake routes, and the debug Command Runner.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
Persistence across agent restart or machine reboot is not supported. A later design would need an external process owner, reconnectable local IPC, and explicit secret handling; persisted metadata alone cannot recover a PTY.
|
||||||
|
|
||||||
|
File transfer, multi-operator attachment, terminal recording, shell-history storage, and arbitrary process launch are also deferred.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Multiplex terminal bytes over the main agent WebSocket
|
||||||
|
|
||||||
|
Rejected for the initial version. It requires refactoring the current request/result session into a general concurrent writer and stream router, adds head-of-line coupling with fleet traffic, and requires terminal-byte framing within a shared connection.
|
||||||
|
|
||||||
|
### Model a terminal as a long-running agent command
|
||||||
|
|
||||||
|
Rejected. Commands have one result and a timeout; terminals have continuous bidirectional traffic and independent lifecycle.
|
||||||
|
|
||||||
|
### Accept an inbound connection on the agent
|
||||||
|
|
||||||
|
Rejected. Agents run behind NAT and should not expose a new network service.
|
||||||
|
|
||||||
|
### Use `portable-pty`
|
||||||
|
|
||||||
|
Viable, but not selected initially. Its cross-platform abstraction is broader than the Linux agent requires and its reader/writer API would need blocking-task bridges. It remains a fallback if `pty-process` does not build or behave correctly on the target router.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Each active terminal consumes two additional WebSockets and one PTY process on the agent.
|
||||||
|
- Terminal traffic cannot delay main agent heartbeat, snapshot, or command messages.
|
||||||
|
- Control-plane terminal state remains ephemeral; agents rebuild it from their in-memory live-session inventory after a control-plane restart.
|
||||||
|
- The agent session protocol gains terminal-open control and capability advertisement but does not become a terminal-data multiplexer.
|
||||||
|
- Browser and agent terminal transports can be tested independently before adding xterm.js.
|
||||||
|
- Browser navigation and control-plane restart do not terminate an agent-owned PTY.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
*
|
*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!copy.ps1
|
!copy.ps1
|
||||||
|
!wslfix.sh
|
||||||
+9
-5
@@ -7,6 +7,8 @@ param(
|
|||||||
|
|
||||||
$PASSWD = Get-DefaultPassword $PASSWD
|
$PASSWD = Get-DefaultPassword $PASSWD
|
||||||
|
|
||||||
|
Write-Output $PSScriptRoot
|
||||||
|
|
||||||
# idk what this does it works like that then thats how it is
|
# 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 -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
|
||||||
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $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"
|
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda-override"
|
||||||
$remote = $files.ForEach({ "/etc/init.d/$_" })
|
$remote = $files.ForEach({ "/etc/init.d/$_" })
|
||||||
$remote | ForEach-Object {
|
$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"
|
$files = "dhcp/95-wakey", "neigh/95-wakey"
|
||||||
$remote = $files.ForEach({ "/etc/hotplug.d/$_" })
|
|
||||||
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
|
|
||||||
|
|
||||||
$remote | ForEach-Object {
|
$files | ForEach-Object {
|
||||||
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$hotplugDir\"
|
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,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# sometimes it breaks, run to fix
|
||||||
|
|
||||||
|
sudo sh -c 'echo :WSLInterop:M::MZ::/init:PF > /usr/lib/binfmt.d/WSLInterop.conf'
|
||||||
|
sudo systemctl restart systemd-binfmt
|
||||||
@@ -12,7 +12,7 @@ start_service() {
|
|||||||
procd_open_instance
|
procd_open_instance
|
||||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||||
procd_set_param file "$CONFIG"
|
procd_set_param file "$CONFIG"
|
||||||
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
# procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
||||||
procd_set_param respawn 5 1 0
|
procd_set_param respawn 5 1 0
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
|
DB="${SQLX_PREPARE_DB:-$ROOT/wakey-prepare.sqlite3}"
|
||||||
DATABASE_URL="sqlite://$DB"
|
DATABASE_URL="sqlite://$DB"
|
||||||
|
|
||||||
if ! cargo sqlx --version >/dev/null 2>&1; then
|
if ! cargo sqlx --version >/dev/null 2>&1; then
|
||||||
@@ -23,3 +23,5 @@ cp .sqlx/*.json wakey-control-plane/.sqlx/
|
|||||||
|
|
||||||
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||||
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
|
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
|
||||||
|
echo "Prepare DB (referenced by .env for live compile-time checking): $DB"
|
||||||
|
echo "DATABASE_URL=$DATABASE_URL"
|
||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = [
|
||||||
|
# "brotli==1.2.0",
|
||||||
|
# "fonttools==4.63.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""Rebuild Wakey's self-hosted terminal webfont from Nerd Fonts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
|
NERD_FONTS_VERSION = "v3.4.0"
|
||||||
|
ARCHIVE_SHA256 = "ef552a3e638f25125c6ad4c51176a6adcdce295ab1d2ffacf0db060caf8c1582"
|
||||||
|
WOFF2_SHA256 = "50680a64466fbcc3eff68e00f84505f55681c86f8450c042416af5a8dbc1cce1"
|
||||||
|
FONT_FILE = "JetBrainsMonoNerdFontMono-Regular"
|
||||||
|
ARCHIVE_URL = (
|
||||||
|
"https://github.com/ryanoasis/nerd-fonts/releases/download/"
|
||||||
|
f"{NERD_FONTS_VERSION}/JetBrainsMono.tar.xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUTPUT_DIR = ROOT / "ui/src/assets/fonts/jetbrains-mono-nerd"
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_sha256(path: Path, expected: str) -> None:
|
||||||
|
actual = sha256(path)
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"checksum mismatch for {path}\nexpected: {expected}\nactual: {actual}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download(url: str, destination: Path) -> None:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "wakey-font-preparer"})
|
||||||
|
with (
|
||||||
|
urllib.request.urlopen(request, timeout=60) as response,
|
||||||
|
destination.open("wb") as output,
|
||||||
|
):
|
||||||
|
shutil.copyfileobj(response, output)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_member(archive: tarfile.TarFile, name: str, destination: Path) -> None:
|
||||||
|
source = archive.extractfile(name)
|
||||||
|
if source is None:
|
||||||
|
raise RuntimeError(f"missing {name} in Nerd Fonts archive")
|
||||||
|
with source, destination.open("wb") as output:
|
||||||
|
shutil.copyfileobj(source, output)
|
||||||
|
|
||||||
|
|
||||||
|
def install_file(source: Path, destination: Path) -> None:
|
||||||
|
"""Atomically replace an asset without exposing a partial file to Vite."""
|
||||||
|
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||||
|
try:
|
||||||
|
shutil.copyfile(source, temporary)
|
||||||
|
os.chmod(temporary, 0o644)
|
||||||
|
temporary.replace(destination)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="wakey-terminal-font.") as temp:
|
||||||
|
work_dir = Path(temp)
|
||||||
|
archive_path = work_dir / "JetBrainsMono.tar.xz"
|
||||||
|
ttf_path = work_dir / f"{FONT_FILE}.ttf"
|
||||||
|
woff2_path = work_dir / f"{FONT_FILE}.woff2"
|
||||||
|
license_path = work_dir / "OFL.txt"
|
||||||
|
|
||||||
|
print(f"Downloading Nerd Fonts {NERD_FONTS_VERSION} JetBrainsMono archive")
|
||||||
|
download(ARCHIVE_URL, archive_path)
|
||||||
|
verify_sha256(archive_path, ARCHIVE_SHA256)
|
||||||
|
|
||||||
|
with tarfile.open(archive_path, "r:xz") as archive:
|
||||||
|
extract_member(archive, ttf_path.name, ttf_path)
|
||||||
|
extract_member(archive, license_path.name, license_path)
|
||||||
|
|
||||||
|
# FontTools otherwise stamps the current time into the head table,
|
||||||
|
# making equivalent rebuilds produce different WOFF2 files.
|
||||||
|
font = TTFont(ttf_path, recalcTimestamp=False)
|
||||||
|
font.flavor = "woff2"
|
||||||
|
font.save(woff2_path)
|
||||||
|
verify_sha256(woff2_path, WOFF2_SHA256)
|
||||||
|
|
||||||
|
install_file(woff2_path, OUTPUT_DIR / woff2_path.name)
|
||||||
|
install_file(license_path, OUTPUT_DIR / license_path.name)
|
||||||
|
|
||||||
|
print(f"Prepared {OUTPUT_DIR / f'{FONT_FILE}.woff2'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+13
-9
@@ -54,17 +54,21 @@ if ($Publish) {
|
|||||||
Write-Host 'No -Registry provided; defaulting publish target to registry: gitea'
|
Write-Host 'No -Registry provided; defaulting publish target to registry: gitea'
|
||||||
}
|
}
|
||||||
|
|
||||||
$publishOrder = @(
|
# Patched external crates are excluded from the workspace, so publish
|
||||||
'lda-ipjs',
|
# them by manifest path before workspace packages that depend on them.
|
||||||
'wakey-core',
|
$publishTargets = @(
|
||||||
'wakey-linux',
|
@{ Name = 'wakey-vt100'; Args = @('publish', '--manifest-path', 'vendor/vt100/Cargo.toml') },
|
||||||
'wakey',
|
@{ Name = 'lda-ipjs'; Args = @('publish', '-p', 'lda-ipjs') },
|
||||||
'wakey-agent',
|
@{ Name = 'wakey-core'; Args = @('publish', '-p', 'wakey-core') },
|
||||||
'wakey-control-plane'
|
@{ Name = 'wakey-linux'; Args = @('publish', '-p', 'wakey-linux') },
|
||||||
|
@{ Name = 'wakey'; Args = @('publish', '-p', 'wakey') },
|
||||||
|
@{ Name = 'wakey-agent'; Args = @('publish', '-p', 'wakey-agent') },
|
||||||
|
@{ Name = 'wakey-control-plane'; Args = @('publish', '-p', 'wakey-control-plane') }
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($pkg in $publishOrder) {
|
foreach ($target in $publishTargets) {
|
||||||
$pubArgs = @('publish', '-p', $pkg)
|
$pkg = $target.Name
|
||||||
|
$pubArgs = @($target.Args)
|
||||||
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
||||||
|
|
||||||
Write-Host ("publishing {0}..." -f $pkg)
|
Write-Host ("publishing {0}..." -f $pkg)
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ update_existing_repo() {
|
|||||||
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update_submodules() {
|
||||||
|
repo_dir=$1
|
||||||
|
|
||||||
|
# Sync first so an updated .gitmodules URL is honored by an existing
|
||||||
|
# checkout, then initialize and pin every nested submodule to the commit
|
||||||
|
# recorded by the parent repository.
|
||||||
|
git -C "$repo_dir" submodule sync --recursive || fail 'git submodule sync failed'
|
||||||
|
git -C "$repo_dir" submodule update --init --recursive || fail 'git submodule update failed'
|
||||||
|
}
|
||||||
|
|
||||||
install_executable_force() {
|
install_executable_force() {
|
||||||
src=$1
|
src=$1
|
||||||
dst=$2
|
dst=$2
|
||||||
@@ -186,6 +196,7 @@ main() {
|
|||||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||||
fail 'git clone failed'
|
fail 'git clone failed'
|
||||||
fi
|
fi
|
||||||
|
update_submodules "$WORKDIR"
|
||||||
|
|
||||||
cd "$WORKDIR"
|
cd "$WORKDIR"
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,10 @@ pub use service::{
|
|||||||
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
|
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
|
||||||
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
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,
|
||||||
|
prune_stale_observations_from_path,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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> {
|
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
||||||
let leases = get_leases().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
|
Ok(observations) => observations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(local_observation_to_fact)
|
.filter_map(local_observation_to_fact)
|
||||||
@@ -77,7 +77,7 @@ pub fn merge_devices_with_observations(
|
|||||||
let key = row
|
let key = row
|
||||||
.mac
|
.mac
|
||||||
.map(|m| m.to_string())
|
.map(|m| m.to_string())
|
||||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
.unwrap_or_else(|| format!("ip:{}", row.ip)); // key by mac or by FAILED ip
|
||||||
by_key.entry(key).or_default().0.push(row);
|
by_key.entry(key).or_default().0.push(row);
|
||||||
}
|
}
|
||||||
for lease in leases {
|
for lease in leases {
|
||||||
@@ -99,7 +99,7 @@ pub fn merge_devices_with_observations(
|
|||||||
Device::from_parts_with_observations(neighbors, leases, observations)
|
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
if !query.is_empty() {
|
||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
let mut devs = Vec::new();
|
let mut devs = Vec::new();
|
||||||
let mut ips = Vec::new();
|
let mut ips = Vec::new();
|
||||||
@@ -123,6 +123,7 @@ pub fn merge_devices_with_observations(
|
|||||||
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
||||||
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
||||||
});
|
});
|
||||||
|
}
|
||||||
devices.sort_by(|a, b| {
|
devices.sort_by(|a, b| {
|
||||||
presence_rank(b.presence)
|
presence_rank(b.presence)
|
||||||
.cmp(&presence_rank(a.presence))
|
.cmp(&presence_rank(a.presence))
|
||||||
@@ -132,7 +133,7 @@ pub fn merge_devices_with_observations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_observation_to_fact(
|
pub fn local_observation_to_fact(
|
||||||
observation: wakey_linux::dhcp::LocalDeviceObservation,
|
observation: wakey_linux::observations::LocalDeviceObservation,
|
||||||
) -> Option<DeviceObservationFact> {
|
) -> Option<DeviceObservationFact> {
|
||||||
let mac = match observation.mac.as_deref() {
|
let mac = match observation.mac.as_deref() {
|
||||||
Some(raw) => match raw.parse() {
|
Some(raw) => match raw.parse() {
|
||||||
@@ -180,7 +181,7 @@ const fn presence_rank(presence: Presence) -> u8 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
|
use wakey_core::{DeviceId, DhcpLease, EndpointSource, InventoryQueryBuilder, NeighborState};
|
||||||
|
|
||||||
fn sample_neighbors() -> Vec<NeighborEntry> {
|
fn sample_neighbors() -> Vec<NeighborEntry> {
|
||||||
vec![NeighborEntry {
|
vec![NeighborEntry {
|
||||||
@@ -278,11 +279,14 @@ mod tests {
|
|||||||
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
||||||
);
|
);
|
||||||
assert_eq!(out[0].observations.len(), 1);
|
assert_eq!(out[0].observations.len(), 1);
|
||||||
|
assert_eq!(out[0].endpoints.len(), 1);
|
||||||
|
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
|
||||||
|
assert!(out[0].ips.is_empty());
|
||||||
assert_eq!(out[0].presence, Presence::Unknown);
|
assert_eq!(out[0].presence, Presence::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_devices_marks_remove_only_observation_offline() {
|
fn merge_devices_marks_neigh_remove_observation_offline() {
|
||||||
let query = InventoryQueryBuilder::new().build();
|
let query = InventoryQueryBuilder::new().build();
|
||||||
let out = merge_devices_with_observations(
|
let out = merge_devices_with_observations(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -301,5 +305,56 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
assert_eq!(out[0].presence, Presence::Offline);
|
assert_eq!(out[0].presence, Presence::Offline);
|
||||||
|
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookNeighbor);
|
||||||
|
assert!(out[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_keeps_dhcp_remove_observation_unknown() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let out = merge_devices_with_observations(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec![DeviceObservationFact {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "remove".into(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||||
|
hostname: None,
|
||||||
|
first_seen_unix: Some(10),
|
||||||
|
last_seen_unix: Some(20),
|
||||||
|
}],
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].presence, Presence::Unknown);
|
||||||
|
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
|
||||||
|
assert!(out[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_uses_ip_id_for_ip_only_hook_endpoint_without_summary_ip() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 31));
|
||||||
|
let out = merge_devices_with_observations(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec![DeviceObservationFact {
|
||||||
|
kind: "neigh".into(),
|
||||||
|
action: "remove".into(),
|
||||||
|
mac: None,
|
||||||
|
ip: Some(ip),
|
||||||
|
hostname: None,
|
||||||
|
first_seen_unix: Some(10),
|
||||||
|
last_seen_unix: Some(20),
|
||||||
|
}],
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].id, Some(DeviceId::Ip(ip)));
|
||||||
|
assert!(out[0].ips.is_empty());
|
||||||
|
assert_eq!(out[0].endpoints.len(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||||
.await
|
.await
|
||||||
.context("failed to read DHCP leases")?;
|
.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)
|
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
+18
-1
@@ -2,8 +2,25 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||||
|
/>
|
||||||
<title>Wakey Operator UI</title>
|
<title>Wakey Operator UI</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("wakey-ui-theme");
|
||||||
|
if (
|
||||||
|
t === "dark" ||
|
||||||
|
((t === "system" || !t) &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@xterm/addon-clipboard": "0.3.0-beta.301",
|
||||||
|
"@xterm/addon-fit": "0.12.0-beta.301",
|
||||||
|
"@xterm/addon-image": "0.10.0-beta.301",
|
||||||
|
"@xterm/addon-unicode-graphemes": "0.5.0-beta.301",
|
||||||
|
"@xterm/addon-unicode11": "0.10.0-beta.301",
|
||||||
|
"@xterm/addon-web-links": "0.13.0-beta.301",
|
||||||
|
"@xterm/xterm": "6.1.0-beta.301",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
|||||||
Generated
+1413
-2728
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: false
|
||||||
|
msw: false
|
||||||
+130
-17
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
setAgentNickname,
|
setAgentNickname,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AppLayout } from "@/layout/AppLayout";
|
import { AppLayout } from "@/layout/AppLayout";
|
||||||
import { AgentsPage } from "@/pages/AgentsPage";
|
import { AgentsPage } from "@/pages/AgentsPage";
|
||||||
import { AlertsPage } from "@/pages/AlertsPage";
|
import { AlertsPage } from "@/pages/AlertsPage";
|
||||||
@@ -23,8 +24,13 @@ import { CommandsPage } from "@/pages/CommandsPage";
|
|||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { DevicesPage } from "@/pages/DevicesPage";
|
import { DevicesPage } from "@/pages/DevicesPage";
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
|
||||||
|
const TerminalPage = lazy(() =>
|
||||||
|
import("@/pages/TerminalPage").then((module) => ({
|
||||||
|
default: module.TerminalPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -34,22 +40,48 @@ export function App() {
|
|||||||
const [history, setHistory] = useState<AlertTransition[]>([]);
|
const [history, setHistory] = useState<AlertTransition[]>([]);
|
||||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||||
|
const [alertsHealth, setAlertsHealth] = useState<{
|
||||||
|
available: boolean;
|
||||||
|
reason?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [alertsStreamConnected, setAlertsStreamConnected] = useState(false);
|
||||||
|
const streamHealthReceived = useRef(false);
|
||||||
|
const alertsRevision = useRef(0);
|
||||||
|
|
||||||
const [state, setState] = useState<LoadState>("idle");
|
const [state, setState] = useState<LoadState>("idle");
|
||||||
|
|
||||||
|
async function loadAlertsWithoutBlocking() {
|
||||||
|
try {
|
||||||
|
const nextAlerts = await fetchAlerts();
|
||||||
|
if (!streamHealthReceived.current) {
|
||||||
|
setAlertsHealth({ available: true });
|
||||||
|
}
|
||||||
|
return nextAlerts;
|
||||||
|
} catch (err) {
|
||||||
|
if (!streamHealthReceived.current) {
|
||||||
|
setAlertsHealth({ available: false, reason: String(err) });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
|
const revision = alertsRevision.current;
|
||||||
setState("loading");
|
setState("loading");
|
||||||
try {
|
try {
|
||||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchAgents(),
|
fetchAgents(),
|
||||||
fetchAlerts(),
|
loadAlertsWithoutBlocking(),
|
||||||
fetchAlertHistory(20),
|
fetchAlertHistory(20),
|
||||||
fetchAudit(30),
|
fetchAudit(30),
|
||||||
]);
|
]);
|
||||||
setAgents(nextAgents);
|
setAgents(nextAgents);
|
||||||
setAlerts(nextAlerts);
|
// Streamed state wins over HTTP requests started before its arrival.
|
||||||
|
if (revision === alertsRevision.current) {
|
||||||
|
if (nextAlerts) setAlerts(nextAlerts);
|
||||||
setHistory(nextHistory);
|
setHistory(nextHistory);
|
||||||
|
}
|
||||||
setAudit(nextAudit);
|
setAudit(nextAudit);
|
||||||
const firstConnectedAgentId =
|
const firstConnectedAgentId =
|
||||||
nextAgents.find((agent) => agent.connected)?.agent_id ?? "";
|
nextAgents.find((agent) => agent.connected)?.agent_id ?? "";
|
||||||
@@ -94,43 +126,103 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAlertsAndHistory() {
|
async function refreshAlertsAndHistory() {
|
||||||
|
const revision = alertsRevision.current;
|
||||||
const [nextAlerts, nextHistory] = await Promise.all([
|
const [nextAlerts, nextHistory] = await Promise.all([
|
||||||
fetchAlerts(),
|
loadAlertsWithoutBlocking(),
|
||||||
fetchAlertHistory(20),
|
fetchAlertHistory(20),
|
||||||
]);
|
]);
|
||||||
setAlerts(nextAlerts);
|
if (revision === alertsRevision.current) {
|
||||||
|
if (nextAlerts) setAlerts(nextAlerts);
|
||||||
setHistory(nextHistory);
|
setHistory(nextHistory);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAll();
|
void loadAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
let socket: WebSocket | null = null;
|
||||||
const ws = new WebSocket(wsUrl);
|
let disposed = false;
|
||||||
|
let attempt = 0;
|
||||||
|
let openedAt = 0;
|
||||||
|
let reconnectTimer: number | undefined;
|
||||||
|
|
||||||
ws.onmessage = (evt) => {
|
const resync = () => {
|
||||||
|
const generation = alertsRevision.current;
|
||||||
|
void Promise.all([fetchAlerts(), fetchAlertHistory(20)])
|
||||||
|
.then(([nextAlerts, nextHistory]) => {
|
||||||
|
// A WS snapshot delivered while this request was in flight is newer.
|
||||||
|
if (disposed || generation !== alertsRevision.current) return;
|
||||||
|
setAlerts(nextAlerts);
|
||||||
|
setHistory(nextHistory);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||||
|
socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
openedAt = Date.now();
|
||||||
|
setAlertsStreamConnected(true);
|
||||||
|
// Pull anything missed while the stream was down.
|
||||||
|
resync();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (evt) => {
|
||||||
|
if (disposed) return;
|
||||||
|
alertsRevision.current += 1;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(String(evt.data)) as {
|
const payload = JSON.parse(String(evt.data)) as {
|
||||||
|
type?: string;
|
||||||
|
available?: boolean;
|
||||||
|
reason?: string;
|
||||||
alerts?: Alert[];
|
alerts?: Alert[];
|
||||||
recent_transitions?: AlertTransition[];
|
recent_transitions?: AlertTransition[];
|
||||||
};
|
};
|
||||||
|
if (payload.type === "alerts_health") {
|
||||||
|
streamHealthReceived.current = true;
|
||||||
|
setAlertsHealth({
|
||||||
|
available: payload.available === true,
|
||||||
|
reason: payload.reason,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (payload.alerts) setAlerts(payload.alerts);
|
if (payload.alerts) setAlerts(payload.alerts);
|
||||||
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
if (payload.recent_transitions)
|
||||||
|
setHistory(payload.recent_transitions);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed stream payloads and keep current UI state.
|
// Ignore malformed stream payloads and keep current UI state.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
socket.onclose = () => {
|
||||||
const id = window.setInterval(() => {
|
socket = null;
|
||||||
void refreshAlertsAndHistory().catch(() => undefined);
|
if (disposed) return;
|
||||||
}, 8000);
|
setAlertsStreamConnected(false);
|
||||||
ws.onclose = () => window.clearInterval(id);
|
// Only a connection that stayed up proves the stream is healthy; an
|
||||||
|
// accept-then-close server must not pin us to the first backoff step.
|
||||||
|
if (openedAt && Date.now() - openedAt > 10_000) attempt = 0;
|
||||||
|
openedAt = 0;
|
||||||
|
// Browsers never reconnect a WebSocket on their own.
|
||||||
|
const delay =
|
||||||
|
Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000;
|
||||||
|
attempt += 1;
|
||||||
|
reconnectTimer = window.setTimeout(connect, delay);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => ws.close();
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,6 +237,7 @@ export function App() {
|
|||||||
selectedAgentId={selectedAgentId}
|
selectedAgentId={selectedAgentId}
|
||||||
onSelectAgent={setSelectedAgentId}
|
onSelectAgent={setSelectedAgentId}
|
||||||
onAfterWake={loadAll}
|
onAfterWake={loadAll}
|
||||||
|
onRefresh={loadAll}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -168,6 +261,8 @@ export function App() {
|
|||||||
transitions={history}
|
transitions={history}
|
||||||
audit={audit}
|
audit={audit}
|
||||||
loading={state === "loading"}
|
loading={state === "loading"}
|
||||||
|
alertsHealth={alertsHealth}
|
||||||
|
alertsStreamConnected={alertsStreamConnected}
|
||||||
onRefresh={loadAll}
|
onRefresh={loadAll}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -210,12 +305,30 @@ export function App() {
|
|||||||
<AlertsPage
|
<AlertsPage
|
||||||
alerts={alerts}
|
alerts={alerts}
|
||||||
transitions={history}
|
transitions={history}
|
||||||
|
alertsHealth={alertsHealth}
|
||||||
|
alertsStreamConnected={alertsStreamConnected}
|
||||||
onRefresh={refreshAlertsAndHistory}
|
onRefresh={refreshAlertsAndHistory}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="tokens" element={<TokensPage />} />
|
<Route path="tokens" element={<TokensPage />} />
|
||||||
<Route path="observations" element={<ObservationsPage />} />
|
<Route
|
||||||
|
path="terminal"
|
||||||
|
element={
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Skeleton className="h-[calc(100dvh-2.5rem)] w-full" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TerminalPage
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelectAgent={setSelectedAgentId}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+114
-75
@@ -2,6 +2,21 @@ export type Agent = {
|
|||||||
agent_id: string;
|
agent_id: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
|
capabilities: "terminal"[];
|
||||||
|
capability_options?: {
|
||||||
|
terminal?: { max_sessions: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalSession = {
|
||||||
|
terminal_id: string;
|
||||||
|
agent_id: string;
|
||||||
|
created_at_unix: number;
|
||||||
|
expires_at_unix?: number | null;
|
||||||
|
agent_attached: boolean;
|
||||||
|
operator_attached: boolean;
|
||||||
|
websocket_url: string;
|
||||||
|
attachment_token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Alert = {
|
export type Alert = {
|
||||||
@@ -96,32 +111,6 @@ export type KnownDeviceSummary = {
|
|||||||
pinned: boolean;
|
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 = {
|
export type FleetDeviceAgent = {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
@@ -129,6 +118,20 @@ export type FleetDeviceAgent = {
|
|||||||
last_seen_unix: number;
|
last_seen_unix: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FleetDeviceEndpoint = {
|
||||||
|
agent_id: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
source: string;
|
||||||
|
mac: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
interface: string | null;
|
||||||
|
presence: string;
|
||||||
|
first_seen_unix: number | null;
|
||||||
|
last_seen_unix: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type FleetWakeRoute = {
|
export type FleetWakeRoute = {
|
||||||
route_id: string;
|
route_id: string;
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
@@ -137,7 +140,9 @@ export type FleetWakeRoute = {
|
|||||||
mac: string | null;
|
mac: string | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
|
interface: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
presence: string;
|
||||||
last_seen_unix: number;
|
last_seen_unix: number;
|
||||||
wakeable: boolean;
|
wakeable: boolean;
|
||||||
};
|
};
|
||||||
@@ -152,6 +157,7 @@ export type FleetDevice = {
|
|||||||
hostnames: string[];
|
hostnames: string[];
|
||||||
agents: FleetDeviceAgent[];
|
agents: FleetDeviceAgent[];
|
||||||
sources: string[];
|
sources: string[];
|
||||||
|
endpoints: FleetDeviceEndpoint[];
|
||||||
first_seen_unix: number | null;
|
first_seen_unix: number | null;
|
||||||
last_seen_unix: number | null;
|
last_seen_unix: number | null;
|
||||||
presence: string;
|
presence: string;
|
||||||
@@ -179,6 +185,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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
...init,
|
...init,
|
||||||
@@ -190,11 +215,22 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const raw = await res.text();
|
const raw = await res.text();
|
||||||
let detail = raw;
|
let parsed: APIErrorResponse | null = null;
|
||||||
try {
|
try {
|
||||||
detail = JSON.stringify(JSON.parse(raw), null, 2);
|
parsed = JSON.parse(raw);
|
||||||
} catch {}
|
// assume APIError
|
||||||
throw new Error(`${res.status} ${res.statusText}\n${detail}`);
|
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>;
|
return res.json() as Promise<T>;
|
||||||
@@ -204,6 +240,44 @@ export function fetchAgents(): Promise<Agent[]> {
|
|||||||
return request<Agent[]>("/api/v1/control/agents");
|
return request<Agent[]>("/api/v1/control/agents");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createTerminal(
|
||||||
|
agentId: string,
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
): Promise<TerminalSession> {
|
||||||
|
return request<TerminalSession>("/api/v1/control/terminals", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ agent_id: agentId, rows, cols }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTerminals(): Promise<TerminalSession[]> {
|
||||||
|
return request<TerminalSession[]>("/api/v1/control/terminals");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
operatorId: string,
|
||||||
|
): Promise<TerminalSession> {
|
||||||
|
return request<TerminalSession>(
|
||||||
|
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ operator_id: operatorId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeTerminal(terminalId: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to close terminal: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAlerts(): Promise<Alert[]> {
|
export function fetchAlerts(): Promise<Alert[]> {
|
||||||
return request<Alert[]>("/api/v1/control/alerts");
|
return request<Alert[]>("/api/v1/control/alerts");
|
||||||
}
|
}
|
||||||
@@ -318,38 +392,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: {
|
export function createKnownDevice(input: {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
@@ -367,19 +409,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(
|
export function attachDeviceIdentifier(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
identifier: { kind: string; value: string },
|
identifier: { kind: string; value: string },
|
||||||
@@ -393,6 +422,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(
|
export function mergeKnownDevice(
|
||||||
targetDeviceId: string,
|
targetDeviceId: string,
|
||||||
sourceDeviceId: string,
|
sourceDeviceId: string,
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# JetBrainsMono Nerd Font Mono
|
||||||
|
|
||||||
|
`JetBrainsMonoNerdFontMono-Regular.woff2` is the regular monospaced variant
|
||||||
|
from Nerd Fonts v3.4.0 (JetBrains Mono v2.304), converted from the release TTF
|
||||||
|
to WOFF2 without subsetting.
|
||||||
|
|
||||||
|
Source archive:
|
||||||
|
https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/JetBrainsMono.tar.xz
|
||||||
|
|
||||||
|
The font is distributed under the SIL Open Font License in `OFL.txt`.
|
||||||
|
|
||||||
|
Rebuild the checked-in webfont with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/prepare_terminal_font.py
|
||||||
|
```
|
||||||
@@ -51,7 +51,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Popup
|
<DialogPrimitive.Popup
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 inset-s-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -63,7 +63,7 @@ function DialogContent({
|
|||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute top-2 end-2"
|
className="absolute top-2 inset-e-2"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function TooltipContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-start-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-end-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-inset-s-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-inset-e-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
</TooltipPrimitive.Popup>
|
</TooltipPrimitive.Popup>
|
||||||
</TooltipPrimitive.Positioner>
|
</TooltipPrimitive.Positioner>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
FileText,
|
FileText,
|
||||||
Key,
|
Key,
|
||||||
Eye,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
|
SquareTerminal,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -49,7 +49,6 @@ const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||||
{ to: "/alerts", label: "Alerts", icon: Bell },
|
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||||
{ to: "/observations", label: "Observations", icon: Eye },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,6 +58,7 @@ const navSections: NavSection[] = [
|
|||||||
{ to: "/audit", label: "Audit", icon: FileText },
|
{ to: "/audit", label: "Audit", icon: FileText },
|
||||||
{ to: "/tokens", label: "Tokens", icon: Key },
|
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||||
{ to: "/commands", label: "Commands", icon: Terminal },
|
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||||
|
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -92,7 +92,15 @@ export function AppLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<aside
|
<aside
|
||||||
className={`sidebar ${collapsed ? "sidebar--collapsed" : ""}`}
|
className={[
|
||||||
|
"sidebar",
|
||||||
|
collapsed ? "sidebar--collapsed" : "",
|
||||||
|
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-100 ease-in-out",
|
||||||
|
"md:sticky md:top-0 md:z-30 md:shadow-none",
|
||||||
|
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
data-collapsed={collapsed}
|
data-collapsed={collapsed}
|
||||||
>
|
>
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
@@ -210,7 +218,35 @@ export function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="app-main">
|
{/* Mobile backdrop */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
|
||||||
|
}}
|
||||||
|
aria-label="Close menu"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile floating button when sidebar is collapsed/hidden */}
|
||||||
|
{collapsed && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="fixed bottom-3 left-3 z-40 md:hidden"
|
||||||
|
aria-label="Open menu"
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
>
|
||||||
|
<PanelLeft className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="app-main @container">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
type Props = {
|
type Props = {
|
||||||
alerts: Alert[];
|
alerts: Alert[];
|
||||||
transitions: AlertTransition[];
|
transitions: AlertTransition[];
|
||||||
|
alertsHealth: { available: boolean; reason?: string } | null;
|
||||||
|
alertsStreamConnected: boolean;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
export function AlertsPage({
|
||||||
|
alerts,
|
||||||
|
transitions,
|
||||||
|
alertsHealth,
|
||||||
|
alertsStreamConnected,
|
||||||
|
onRefresh,
|
||||||
|
}: Props) {
|
||||||
const [severity, setSeverity] = useState("all");
|
const [severity, setSeverity] = useState("all");
|
||||||
const [status, setStatus] = useState("all");
|
const [status, setStatus] = useState("all");
|
||||||
const [kind, setKind] = useState("all");
|
const [kind, setKind] = useState("all");
|
||||||
@@ -66,13 +74,34 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex items-center justify-between gap-2">
|
<CardHeader className="flex items-center justify-between gap-2">
|
||||||
<CardTitle>Active Alerts</CardTitle>
|
<CardTitle>Active Alerts</CardTitle>
|
||||||
<Button size="sm" variant="outline" onClick={() => void onRefresh()}>
|
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-right text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||||
|
{alertsHealth?.available === false && (
|
||||||
|
<div>
|
||||||
|
Alert results may be stale or unavailable
|
||||||
|
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void onRefresh()}
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="alert-severity"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Severity
|
Severity
|
||||||
<Select
|
<Select
|
||||||
value={severity}
|
value={severity}
|
||||||
@@ -80,7 +109,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
if (value) setSeverity(value);
|
if (value) setSeverity(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="alert-severity" className="w-full">
|
||||||
<SelectValue placeholder="Severity" />
|
<SelectValue placeholder="Severity" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -93,7 +122,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="alert-status"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Status
|
Status
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
@@ -101,7 +133,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
if (value) setStatus(value);
|
if (value) setStatus(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="alert-status" className="w-full">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -114,7 +146,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="alert-kind"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Kind
|
Kind
|
||||||
<Select
|
<Select
|
||||||
value={kind}
|
value={kind}
|
||||||
@@ -122,7 +157,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
if (value) setKind(value);
|
if (value) setKind(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="alert-kind" className="w-full">
|
||||||
<SelectValue placeholder="Kind" />
|
<SelectValue placeholder="Kind" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -170,9 +205,13 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="alert-transition-search"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Search
|
Search
|
||||||
<Input
|
<Input
|
||||||
|
id="alert-transition-search"
|
||||||
value={transitionQ}
|
value={transitionQ}
|
||||||
onChange={(e) => setTransitionQ(e.target.value)}
|
onChange={(e) => setTransitionQ(e.target.value)}
|
||||||
placeholder="kind, status, message, agent"
|
placeholder="kind, status, message, agent"
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="audit-event-type"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Event type
|
Event type
|
||||||
<Select
|
<Select
|
||||||
value={eventType}
|
value={eventType}
|
||||||
@@ -78,7 +81,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
if (value) setEventType(value);
|
if (value) setEventType(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="audit-event-type" className="w-full">
|
||||||
<SelectValue placeholder="Event type" />
|
<SelectValue placeholder="Event type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="audit-outcome"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Outcome
|
Outcome
|
||||||
<Select
|
<Select
|
||||||
value={outcome}
|
value={outcome}
|
||||||
@@ -99,7 +105,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
if (value) setOutcome(value);
|
if (value) setOutcome(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="audit-outcome" className="w-full">
|
||||||
<SelectValue placeholder="Outcome" />
|
<SelectValue placeholder="Outcome" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -112,9 +118,13 @@ export function AuditPage({ events, onRefresh }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="audit-search"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Search
|
Search
|
||||||
<Input
|
<Input
|
||||||
|
id="audit-search"
|
||||||
value={needle}
|
value={needle}
|
||||||
onChange={(e) => setNeedle(e.target.value)}
|
onChange={(e) => setNeedle(e.target.value)}
|
||||||
placeholder="message, agent, actor"
|
placeholder="message, agent, actor"
|
||||||
|
|||||||
@@ -66,12 +66,15 @@ export function CommandsPage({
|
|||||||
Command Runner
|
Command Runner
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Debug tool — send raw commands to agents and inspect JSON responses
|
Debug tool: send raw commands to agents and inspect JSON responses
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="grid gap-3" onSubmit={submit}>
|
<form className="grid gap-3" onSubmit={submit}>
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="cmd-agent"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
agents={agents}
|
agents={agents}
|
||||||
@@ -80,13 +83,16 @@ export function CommandsPage({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="cmd-kind"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Command</span>
|
<span>Command</span>
|
||||||
<Select
|
<Select
|
||||||
value={kind}
|
value={kind}
|
||||||
onValueChange={(value) => setKind(value as CommandKind)}
|
onValueChange={(value) => setKind(value as CommandKind)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="cmd-kind" className="w-full">
|
||||||
<SelectValue placeholder="Pick command" />
|
<SelectValue placeholder="Pick command" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -98,9 +104,13 @@ export function CommandsPage({
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="cmd-query"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Query</span>
|
<span>Query</span>
|
||||||
<Input
|
<Input
|
||||||
|
id="cmd-query"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="optional"
|
placeholder="optional"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type Props = {
|
|||||||
transitions: AlertTransition[];
|
transitions: AlertTransition[];
|
||||||
audit: AuditEvent[];
|
audit: AuditEvent[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
alertsHealth: { available: boolean; reason?: string } | null;
|
||||||
|
alertsStreamConnected: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +68,8 @@ export function DashboardPage({
|
|||||||
transitions,
|
transitions,
|
||||||
audit,
|
audit,
|
||||||
loading,
|
loading,
|
||||||
|
alertsHealth,
|
||||||
|
alertsStreamConnected,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const connected = agents.filter((a) => a.connected).length;
|
const connected = agents.filter((a) => a.connected).length;
|
||||||
@@ -115,7 +119,7 @@ export function DashboardPage({
|
|||||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{/* Fleet Status */}
|
{/* Fleet Status */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Bot className="size-3.5" />
|
<Bot className="size-3.5" />
|
||||||
Fleet Status
|
Fleet Status
|
||||||
@@ -152,7 +156,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Active Alerts */}
|
{/* Active Alerts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Bell className="size-3.5" />
|
<Bell className="size-3.5" />
|
||||||
Active Alerts
|
Active Alerts
|
||||||
@@ -165,6 +169,20 @@ export function DashboardPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{(!alertsStreamConnected || alertsHealth?.available === false) && (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="mb-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||||
|
{alertsHealth?.available === false && (
|
||||||
|
<div>
|
||||||
|
Alert results may be stale or unavailable
|
||||||
|
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
|
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
|
||||||
{Object.keys(severityBuckets).length > 0 ? (
|
{Object.keys(severityBuckets).length > 0 ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
@@ -188,7 +206,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Transitions */}
|
{/* Transitions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Activity className="size-3.5" />
|
<Activity className="size-3.5" />
|
||||||
Transitions
|
Transitions
|
||||||
@@ -206,7 +224,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Token Health */}
|
{/* Token Health */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||||
<CardDescription className="flex items-center gap-1.5">
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<Key className="size-3.5" />
|
<Key className="size-3.5" />
|
||||||
Enroll Tokens
|
Enroll Tokens
|
||||||
@@ -228,7 +246,7 @@ export function DashboardPage({
|
|||||||
|
|
||||||
{/* Recent Activity Feed */}
|
{/* Recent Activity Feed */}
|
||||||
<Card className="sm:col-span-2 lg:col-span-4">
|
<Card className="sm:col-span-2 lg:col-span-4">
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Recent Activity</CardTitle>
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -51,9 +51,10 @@ type Props = {
|
|||||||
selectedAgentId: string;
|
selectedAgentId: string;
|
||||||
onSelectAgent: (agentId: string) => void;
|
onSelectAgent: (agentId: string) => void;
|
||||||
onAfterWake?: () => Promise<void>;
|
onAfterWake?: () => Promise<void>;
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DevicesPage({ agents, onAfterWake }: Props) {
|
export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -68,8 +69,12 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||||
|
const loadSeqRef = useRef(0);
|
||||||
|
|
||||||
async function loadFleet() {
|
async function loadFleet() {
|
||||||
|
// Latest request wins: filter changes and debounced searches overlap, and
|
||||||
|
// an older response must never overwrite fresher state.
|
||||||
|
const seq = ++loadSeqRef.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextDevices, nextKnownDevices] = await Promise.all([
|
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||||
@@ -78,16 +83,19 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
presence,
|
presence,
|
||||||
known,
|
known,
|
||||||
agentId: agentId === "all" ? "" : agentId,
|
agentId: agentId === "all" ? "" : agentId,
|
||||||
|
visibility: "all",
|
||||||
limit: 500,
|
limit: 500,
|
||||||
}),
|
}),
|
||||||
fetchKnownDevices(),
|
fetchKnownDevices(),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== loadSeqRef.current) return;
|
||||||
setDevices(nextDevices);
|
setDevices(nextDevices);
|
||||||
setKnownDevices(nextKnownDevices);
|
setKnownDevices(nextKnownDevices);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (seq !== loadSeqRef.current) return;
|
||||||
toast.error("Failed to load fleet", { description: String(err) });
|
toast.error("Failed to load fleet", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeqRef.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +105,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
const result = await refreshFleetDevices();
|
const result = await refreshFleetDevices();
|
||||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||||
const msg = failed.length
|
const msg = failed.length
|
||||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
|
||||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
await loadFleet();
|
await loadFleet();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -136,8 +144,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
} catch {
|
} catch {
|
||||||
const area = document.createElement("textarea");
|
const area = document.createElement("textarea");
|
||||||
area.value = text;
|
area.value = text;
|
||||||
area.style.position = "fixed";
|
area.style.cssText = "position:fixed;opacity:0";
|
||||||
area.style.opacity = "0";
|
|
||||||
document.body.appendChild(area);
|
document.body.appendChild(area);
|
||||||
area.select();
|
area.select();
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
@@ -151,7 +158,13 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [presence, known, agentId]);
|
}, [presence, known, agentId]);
|
||||||
|
|
||||||
|
// The filter effect above already loads on mount; only react to query edits.
|
||||||
|
const mountedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timer = window.setTimeout(() => void loadFleet(), 220);
|
const timer = window.setTimeout(() => void loadFleet(), 220);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -160,6 +173,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
const sortedDevices = useMemo(() => {
|
const sortedDevices = useMemo(() => {
|
||||||
const rows = [...devices];
|
const rows = [...devices];
|
||||||
rows.sort((a, b) => {
|
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;
|
const dir = sort.dir === "asc" ? 1 : -1;
|
||||||
if (sort.key === "presence") {
|
if (sort.key === "presence") {
|
||||||
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
|
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
|
||||||
@@ -190,7 +205,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>Devices</CardTitle>
|
<CardTitle>Devices</CardTitle>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<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
|
devices
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +213,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void loadFleet()}
|
onClick={() => {
|
||||||
|
void loadFleet();
|
||||||
|
if (onRefresh) void onRefresh();
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4" aria-hidden />
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
@@ -219,11 +237,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="fleet-search"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
id="fleet-search"
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
@@ -243,7 +265,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
values={knownFilters}
|
values={knownFilters}
|
||||||
onChange={(value) => setKnown(value as KnownFilter)}
|
onChange={(value) => setKnown(value as KnownFilter)}
|
||||||
/>
|
/>
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="fleet-agent"
|
||||||
|
className="grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<Select
|
<Select
|
||||||
value={agentId}
|
value={agentId}
|
||||||
@@ -331,6 +356,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<FleetDeviceDetailsDialog
|
<FleetDeviceDetailsDialog
|
||||||
|
key={details?.device_key ?? "__none"}
|
||||||
device={details}
|
device={details}
|
||||||
knownDevices={knownDevices}
|
knownDevices={knownDevices}
|
||||||
open={Boolean(details)}
|
open={Boolean(details)}
|
||||||
@@ -361,8 +387,19 @@ function FleetDeviceRow({
|
|||||||
onDetails: () => void;
|
onDetails: () => void;
|
||||||
onCopy: (label: string, value: string) => 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 (
|
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}>
|
<button className="min-w-0 text-left" type="button" onClick={onDetails}>
|
||||||
<div className="truncate font-medium">{device.display_name}</div>
|
<div className="truncate font-medium">{device.display_name}</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import type { Agent } from "@/api";
|
||||||
|
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TerminalAccessoryBar } from "@/pages/terminal/TerminalAccessoryBar";
|
||||||
|
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||||
|
import { useTerminalSession } from "@/pages/terminal/useTerminalSession";
|
||||||
|
import { useTerminalSessions } from "@/pages/terminal/useTerminalSessions";
|
||||||
|
import { useXterm } from "@/pages/terminal/useXterm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelectAgent: (agentId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TerminalTouch = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||||
|
|
||||||
|
export function TerminalPage({
|
||||||
|
agents,
|
||||||
|
selectedAgentId,
|
||||||
|
onSelectAgent,
|
||||||
|
}: Props) {
|
||||||
|
const pageRef = useRef<HTMLElement>(null);
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||||
|
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||||
|
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||||
|
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||||
|
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
const catalog = useTerminalSessions();
|
||||||
|
const { sessions } = catalog;
|
||||||
|
const terminal = useXterm({
|
||||||
|
pageRef,
|
||||||
|
hostRef,
|
||||||
|
});
|
||||||
|
const terminalSession = useTerminalSession({
|
||||||
|
terminal: terminal.terminal,
|
||||||
|
catalog,
|
||||||
|
selectedAgentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clock = window.setInterval(
|
||||||
|
() => setNowUnix(Math.floor(Date.now() / 1000)),
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(clock);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedAgent = agents.find(
|
||||||
|
(agent) => agent.agent_id === selectedAgentId,
|
||||||
|
);
|
||||||
|
const selectedAgentSessionCount = sessions.filter(
|
||||||
|
(item) => item.agent_id === selectedAgentId,
|
||||||
|
).length;
|
||||||
|
const selectedAgentSessionLimit = Math.max(
|
||||||
|
1,
|
||||||
|
selectedAgent?.capability_options?.terminal?.max_sessions ?? 2,
|
||||||
|
);
|
||||||
|
const agentAtSessionLimit =
|
||||||
|
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
||||||
|
const canRequestStart = Boolean(
|
||||||
|
selectedAgent?.connected &&
|
||||||
|
selectedAgent.capabilities.includes("terminal") &&
|
||||||
|
terminalSession.connection !== "connecting" &&
|
||||||
|
terminal.terminal.ready,
|
||||||
|
);
|
||||||
|
const terminalInputReady = terminalSession.connection === "ready";
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (agentAtSessionLimit) {
|
||||||
|
toast.error("Terminal session limit reached", {
|
||||||
|
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void terminalSession.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTerminalKeyboard() {
|
||||||
|
const terminalInstance = terminal.terminal;
|
||||||
|
const blurTerminalInput = () => {
|
||||||
|
terminalInstance.blurInput();
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (
|
||||||
|
activeElement instanceof HTMLElement &&
|
||||||
|
hostRef.current?.contains(activeElement)
|
||||||
|
) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shouldHide =
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current ||
|
||||||
|
Boolean(hostRef.current?.contains(document.activeElement));
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||||
|
if (shouldHide) {
|
||||||
|
blurTerminalInput();
|
||||||
|
// Base UI may restore the previously focused element after pointer handling.
|
||||||
|
window.requestAnimationFrame(blurTerminalInput);
|
||||||
|
} else {
|
||||||
|
terminalInstance.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="terminal-page"
|
||||||
|
aria-label="Remote terminal"
|
||||||
|
ref={pageRef}
|
||||||
|
>
|
||||||
|
<header className="terminal-toolbar">
|
||||||
|
<div className="terminal-heading">
|
||||||
|
<Terminal className="size-5 text-primary" aria-hidden />
|
||||||
|
<h1>Remote Terminal</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-controls">
|
||||||
|
<AgentSelector
|
||||||
|
agents={agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.connected && agent.capabilities.includes("terminal"),
|
||||||
|
)}
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={onSelectAgent}
|
||||||
|
disabled={terminalSession.connection === "connecting"}
|
||||||
|
className="w-full min-w-0 sm:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!selectedAgent && terminalSession.connection === "idle" ? (
|
||||||
|
<p className="terminal-notice">
|
||||||
|
No connected terminal-capable agent is selected.
|
||||||
|
</p>
|
||||||
|
) : selectedAgent && !selectedAgent.capabilities.includes("terminal") ? (
|
||||||
|
<p className="terminal-notice">
|
||||||
|
This agent has not enabled remote terminal capability.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="terminal-frame">
|
||||||
|
<div className="terminal-framebar">
|
||||||
|
<TerminalSessionTabs
|
||||||
|
agents={agents}
|
||||||
|
sessions={sessions}
|
||||||
|
activeTerminalId={terminalSession.session?.terminal_id}
|
||||||
|
connection={terminalSession.connection}
|
||||||
|
sessionTitles={terminal.sessionTitles}
|
||||||
|
nowUnix={nowUnix}
|
||||||
|
canRequestStart={canRequestStart}
|
||||||
|
agentAtSessionLimit={agentAtSessionLimit}
|
||||||
|
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||||
|
onActivate={(item) => void terminalSession.activate(item)}
|
||||||
|
onClose={(item) => void terminalSession.close(item)}
|
||||||
|
onStart={start}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="terminal-frame-actions">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="terminal-status"
|
||||||
|
data-state={terminalSession.connection}
|
||||||
|
>
|
||||||
|
<span aria-hidden />
|
||||||
|
{terminalSession.connection}
|
||||||
|
</Badge>
|
||||||
|
{terminalSession.connection === "disconnected" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void terminalSession.reconnect()}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" aria-hidden />
|
||||||
|
Reconnect
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-accessories-toggle"
|
||||||
|
data-active={accessoriesOpen || undefined}
|
||||||
|
aria-label={
|
||||||
|
accessoriesOpen
|
||||||
|
? "Hide terminal controls"
|
||||||
|
: "Show terminal controls"
|
||||||
|
}
|
||||||
|
aria-expanded={accessoriesOpen}
|
||||||
|
onClick={() => setAccessoriesOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<Keyboard className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Terminal controls</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={terminalSession.connection === "idle"}
|
||||||
|
aria-label="Clear terminal"
|
||||||
|
onClick={terminal.terminal.clear}
|
||||||
|
>
|
||||||
|
<Eraser className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Clear terminal</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{accessoriesOpen ? (
|
||||||
|
<TerminalAccessoryBar
|
||||||
|
inputReady={terminalInputReady}
|
||||||
|
modifiers={terminalSession.modifiers}
|
||||||
|
fontSize={terminal.fontSize}
|
||||||
|
onRememberInputFocus={() => {
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||||
|
hostRef.current?.contains(document.activeElement),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onToggleModifier={terminalSession.toggleModifier}
|
||||||
|
onWriteKey={terminalSession.writeAccessoryKey}
|
||||||
|
onScrollPages={terminal.terminal.scrollPages}
|
||||||
|
onCopy={() => void terminal.terminal.copy()}
|
||||||
|
onPaste={() =>
|
||||||
|
void terminal.terminal.paste(terminalSession.writeTerminalData)
|
||||||
|
}
|
||||||
|
onToggleKeyboard={toggleTerminalKeyboard}
|
||||||
|
onChangeFontSize={terminal.terminal.changeFontSize}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="terminal-surface"
|
||||||
|
ref={hostRef}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.pointerType !== "touch") {
|
||||||
|
terminal.terminal.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalTouchRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
const touch = terminalTouchRef.current;
|
||||||
|
if (!touch || touch.pointerId !== event.pointerId || touch.moved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
touch.moved =
|
||||||
|
Math.hypot(
|
||||||
|
event.clientX - touch.startX,
|
||||||
|
event.clientY - touch.startY,
|
||||||
|
) > TERMINAL_TOUCH_SLOP_PX;
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
const touch = terminalTouchRef.current;
|
||||||
|
terminalTouchRef.current = null;
|
||||||
|
if (
|
||||||
|
touch?.pointerId === event.pointerId &&
|
||||||
|
!touch.moved &&
|
||||||
|
event.pointerType === "touch"
|
||||||
|
) {
|
||||||
|
terminal.terminal.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
terminalTouchRef.current = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ export function TokensPage() {
|
|||||||
return (
|
return (
|
||||||
<section className="grid gap-4 xl:grid-cols-2">
|
<section className="grid gap-4 xl:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Key className="size-5 text-primary" />
|
<Key className="size-5 text-primary" />
|
||||||
@@ -219,9 +219,13 @@ export function TokensPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{useCustom && (
|
{useCustom && (
|
||||||
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="token-custom-ttl"
|
||||||
|
className="mt-2 grid gap-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>TTL (seconds)</span>
|
<span>TTL (seconds)</span>
|
||||||
<Input
|
<Input
|
||||||
|
id="token-custom-ttl"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={customTtl}
|
value={customTtl}
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ export function WakeToolsPage({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="wake-agent"
|
||||||
|
className="grid gap-1.5 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
agents={agents}
|
agents={agents}
|
||||||
@@ -111,14 +114,17 @@ export function WakeToolsPage({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<form onSubmit={handleWake} className="grid gap-3">
|
<form onSubmit={handleWake} className="grid gap-3">
|
||||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
<label
|
||||||
|
htmlFor="wake-target"
|
||||||
|
className="grid gap-1.5 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<span>Target</span>
|
<span>Target</span>
|
||||||
<Input
|
<Input
|
||||||
|
id="wake-target"
|
||||||
value={target}
|
value={target}
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
@@ -134,7 +140,7 @@ export function WakeToolsPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between gap-2 gap-y-0">
|
||||||
<CardTitle>Recent Wakes</CardTitle>
|
<CardTitle>Recent Wakes</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -147,10 +153,10 @@ export function WakeToolsPage({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{recentWakes.length > 0 ? (
|
{recentWakes.length > 0 ? (
|
||||||
<div className="grid gap-0">
|
<div className="grid gap-0">
|
||||||
{recentWakes.map((event, idx) => (
|
{recentWakes.map((event, i) => (
|
||||||
<div
|
<div
|
||||||
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||||
key={`${event.ts}-${idx}`}
|
key={`${event.ts}-${event.target}-${event.agentId}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import type { DeviceRow } from "@/pages/devices/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
device: DeviceRow | null;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function summarizeAll(values: string[]): string {
|
|
||||||
if (!values.length) return "-";
|
|
||||||
return values.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeviceDetailsDialog({ device, open, onOpenChange }: Props) {
|
|
||||||
if (!device) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl p-0">
|
|
||||||
<div className="grid gap-4 p-4 text-sm">
|
|
||||||
<DialogHeader className="pr-8">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<DialogTitle className="truncate">{device.name}</DialogTitle>
|
|
||||||
<DialogDescription>Device details</DialogDescription>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline">{device.presence}</Badge>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 font-medium">IP addresses</p>
|
|
||||||
<p className="text-muted-foreground">{summarizeAll(device.ips)}</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 font-medium">MAC addresses</p>
|
|
||||||
<p className="text-muted-foreground">{summarizeAll(device.macs)}</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 font-medium">Interfaces</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{summarizeAll(device.interfaces)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { DeviceRow, SortDir } from "@/pages/devices/types";
|
|
||||||
import {
|
|
||||||
chooseWakeTarget,
|
|
||||||
nextSort,
|
|
||||||
summarize,
|
|
||||||
type SortKey,
|
|
||||||
} from "@/pages/devices/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
rows: DeviceRow[];
|
|
||||||
selectedIds: string[];
|
|
||||||
sort: { key: SortKey; dir: SortDir };
|
|
||||||
allVisibleSelected: boolean;
|
|
||||||
wakeBusyId: string;
|
|
||||||
selectedAgentId: string;
|
|
||||||
bulkWakeBusy: boolean;
|
|
||||||
onToggleAllVisible: (checked: boolean) => void;
|
|
||||||
onToggleRow: (id: string, checked: boolean) => void;
|
|
||||||
onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
|
|
||||||
onWakeDevice: (device: DeviceRow) => void;
|
|
||||||
onCopyValue: (label: string, value: string) => void;
|
|
||||||
onOpenDetails: (device: DeviceRow) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function sortArrow(active: boolean, dir: SortDir) {
|
|
||||||
if (!active) return "";
|
|
||||||
return dir === "asc" ? "▲" : "▼";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeviceInventoryTable({
|
|
||||||
rows,
|
|
||||||
selectedIds,
|
|
||||||
sort,
|
|
||||||
allVisibleSelected,
|
|
||||||
wakeBusyId,
|
|
||||||
selectedAgentId,
|
|
||||||
bulkWakeBusy,
|
|
||||||
onToggleAllVisible,
|
|
||||||
onToggleRow,
|
|
||||||
onSortChange,
|
|
||||||
onWakeDevice,
|
|
||||||
onCopyValue,
|
|
||||||
onOpenDetails,
|
|
||||||
}: Props) {
|
|
||||||
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
|
|
||||||
if (!(target instanceof Element)) return false;
|
|
||||||
return Boolean(target.closest("button, input, a, [role='menuitem']"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="device-list grid gap-2">
|
|
||||||
<div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
|
||||||
<span className="device-cell device-select">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allVisibleSelected}
|
|
||||||
onChange={(e) => onToggleAllVisible(e.target.checked)}
|
|
||||||
disabled={!rows.length}
|
|
||||||
aria-label="Select all visible devices"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="sortable-col device-cell"
|
|
||||||
onClick={() => onSortChange(nextSort(sort, "name"))}
|
|
||||||
>
|
|
||||||
Name {sortArrow(sort.key === "name", sort.dir)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="sortable-col device-cell"
|
|
||||||
onClick={() => onSortChange(nextSort(sort, "ip"))}
|
|
||||||
>
|
|
||||||
IP {sortArrow(sort.key === "ip", sort.dir)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="sortable-col device-cell"
|
|
||||||
onClick={() => onSortChange(nextSort(sort, "mac"))}
|
|
||||||
>
|
|
||||||
MAC {sortArrow(sort.key === "mac", sort.dir)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="sortable-col device-cell"
|
|
||||||
onClick={() => onSortChange(nextSort(sort, "presence"))}
|
|
||||||
>
|
|
||||||
Presence {sortArrow(sort.key === "presence", sort.dir)}
|
|
||||||
</span>
|
|
||||||
<span className="device-cell device-action">Actions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
|
||||||
className="device-row cursor-pointer rounded-md border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent/30"
|
|
||||||
key={row.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (shouldIgnoreRowClick(event.target)) return;
|
|
||||||
onOpenDetails(row);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (shouldIgnoreRowClick(event.target)) return;
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
onOpenDetails(row);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="device-cell device-select" data-label="Pick">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedIds.includes(row.id)}
|
|
||||||
onChange={(e) => onToggleRow(row.id, e.target.checked)}
|
|
||||||
aria-label={`Select ${row.name}`}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="device-cell" data-label="Name" title={row.name}>
|
|
||||||
{row.name}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="device-cell text-muted-foreground"
|
|
||||||
data-label="IP"
|
|
||||||
title={row.ips.join(", ") || "-"}
|
|
||||||
>
|
|
||||||
{summarize(row.ips)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="device-cell text-muted-foreground"
|
|
||||||
data-label="MAC"
|
|
||||||
title={row.macs.join(", ") || "-"}
|
|
||||||
>
|
|
||||||
{summarize(row.macs)}
|
|
||||||
</span>
|
|
||||||
<span className="device-cell" data-label="Presence">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`presence-badge--${row.presence}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`presence-dot presence-dot--${row.presence}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{row.presence.replace("_", " ")}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
<span className="device-cell device-action" data-label="">
|
|
||||||
<Button
|
|
||||||
onClick={() => onWakeDevice(row)}
|
|
||||||
disabled={
|
|
||||||
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="device-btn"
|
|
||||||
>
|
|
||||||
{wakeBusyId === row.id ? "Waking..." : "Wake"}
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline", size: "sm" }),
|
|
||||||
"device-copy-trigger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-32">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCopyValue("target", chooseWakeTarget(row))}
|
|
||||||
>
|
|
||||||
Copy target
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => onCopyValue("name", row.name)}>
|
|
||||||
Copy name
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCopyValue("ip", row.ips[0] || "")}
|
|
||||||
>
|
|
||||||
Copy IP
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCopyValue("mac", row.macs[0] || "")}
|
|
||||||
>
|
|
||||||
Copy MAC
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!rows.length && (
|
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
|
||||||
No devices found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import type { WakeEvent } from "@/pages/devices/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
events: WakeEvent[];
|
|
||||||
onClear: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WakeHistoryCard({ events, onClear }: Props) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
|
||||||
<CardTitle>Recent Wake Actions</CardTitle>
|
|
||||||
<Button variant="outline" size="sm" onClick={onClear}>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{events.map((event, idx) => (
|
|
||||||
<div
|
|
||||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
|
||||||
key={`${event.ts}-${event.target}-${idx}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong>{event.target}</strong>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{new Date(event.ts).toLocaleString()} on {event.agentId}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{event.detail}
|
|
||||||
</div>
|
|
||||||
{event.requestId && (
|
|
||||||
<Link
|
|
||||||
className="linkish text-xs text-muted-foreground"
|
|
||||||
to={`/audit?q=${encodeURIComponent(event.requestId)}`}
|
|
||||||
>
|
|
||||||
View related audit ({event.requestId})
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={event.outcome === "ok" ? "secondary" : "destructive"}
|
|
||||||
>
|
|
||||||
{event.outcome}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!events.length && (
|
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
|
||||||
No wake actions yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
.quick-wake {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
gap: 0.6rem;
|
|
||||||
align-items: end;
|
|
||||||
margin-top: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-wake label {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presence-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin-top: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.45rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-visible {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linkish {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-list {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns:
|
|
||||||
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
|
|
||||||
minmax(100px, 0.85fr) minmax(170px, max-content);
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 0.4rem;
|
|
||||||
row-gap: 0.55rem;
|
|
||||||
min-width: 860px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-header {
|
|
||||||
font-weight: 600;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-cell {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortable-col {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-action {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-btn {
|
|
||||||
min-width: 4.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-copy-trigger {
|
|
||||||
min-width: 6.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-select {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.device-header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row {
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
grid-template-areas:
|
|
||||||
"select action"
|
|
||||||
"name name"
|
|
||||||
"presence presence"
|
|
||||||
"ip ip"
|
|
||||||
"mac mac";
|
|
||||||
align-items: start;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell {
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-overflow: clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label]::before {
|
|
||||||
content: attr(data-label);
|
|
||||||
display: block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
margin-bottom: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label="Name"] {
|
|
||||||
grid-area: name;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label="Pick"] {
|
|
||||||
grid-area: select;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label="Pick"]::before {
|
|
||||||
display: none;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label="Presence"] {
|
|
||||||
grid-area: presence;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label="IP"] {
|
|
||||||
grid-area: ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-cell[data-label="MAC"] {
|
|
||||||
grid-area: mac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-action {
|
|
||||||
grid-area: action;
|
|
||||||
justify-self: end;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-wake {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export type DeviceRow = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
isUnnamed: boolean;
|
|
||||||
ips: string[];
|
|
||||||
macs: string[];
|
|
||||||
interfaces: string[];
|
|
||||||
presence: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WakeEvent = {
|
|
||||||
ts: number;
|
|
||||||
target: string;
|
|
||||||
agentId: string;
|
|
||||||
outcome: string;
|
|
||||||
requestId: string;
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SortDir = "asc" | "desc";
|
|
||||||
export type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import type {
|
|
||||||
PresenceFilter,
|
|
||||||
SortDir,
|
|
||||||
DeviceRow,
|
|
||||||
WakeEvent,
|
|
||||||
} from "@/pages/devices/types";
|
|
||||||
|
|
||||||
export const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
|
|
||||||
|
|
||||||
export const PRESENCE_FILTERS: PresenceFilter[] = [
|
|
||||||
"all",
|
|
||||||
"online",
|
|
||||||
"likely_online",
|
|
||||||
"unknown",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const sorters = {
|
|
||||||
name: (a: DeviceRow, b: DeviceRow) => a.name.localeCompare(b.name),
|
|
||||||
ip: (a: DeviceRow, b: DeviceRow) =>
|
|
||||||
(a.ips[0] || "").localeCompare(b.ips[0] || ""),
|
|
||||||
mac: (a: DeviceRow, b: DeviceRow) =>
|
|
||||||
(a.macs[0] || "").localeCompare(b.macs[0] || ""),
|
|
||||||
presence: (a: DeviceRow, b: DeviceRow) =>
|
|
||||||
a.presence.localeCompare(b.presence),
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SortKey = keyof typeof sorters;
|
|
||||||
|
|
||||||
export function parseInventoryRows(payload: unknown): DeviceRow[] {
|
|
||||||
if (!payload || typeof payload !== "object") return [];
|
|
||||||
const obj = payload as Record<string, unknown>;
|
|
||||||
if (obj.kind !== "inventory") return [];
|
|
||||||
if (!obj.devices || !Array.isArray(obj.devices)) return [];
|
|
||||||
|
|
||||||
return obj.devices
|
|
||||||
.map((raw, idx) => {
|
|
||||||
if (!raw || typeof raw !== "object") return null;
|
|
||||||
const device = raw as Record<string, unknown>;
|
|
||||||
const names = Array.isArray(device.names)
|
|
||||||
? (device.names.filter((v) => typeof v === "string") as string[])
|
|
||||||
: [];
|
|
||||||
const ips = Array.isArray(device.ips)
|
|
||||||
? (device.ips.filter((v) => typeof v === "string") as string[])
|
|
||||||
: [];
|
|
||||||
const macs = Array.isArray(device.macs)
|
|
||||||
? (device.macs.filter((v) => typeof v === "string") as string[])
|
|
||||||
: [];
|
|
||||||
const interfaces = Array.isArray(device.interfaces)
|
|
||||||
? (device.interfaces.filter((v) => typeof v === "string") as string[])
|
|
||||||
: [];
|
|
||||||
const presence =
|
|
||||||
typeof device.presence === "string" ? device.presence : "unknown";
|
|
||||||
const isUnnamed = names.length === 0;
|
|
||||||
|
|
||||||
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: names[0] || "(unnamed)",
|
|
||||||
isUnnamed,
|
|
||||||
ips,
|
|
||||||
macs,
|
|
||||||
interfaces,
|
|
||||||
presence,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((v): v is DeviceRow => Boolean(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWakeSummary(response: unknown): {
|
|
||||||
outcome: string;
|
|
||||||
requestId: string;
|
|
||||||
detail: string;
|
|
||||||
} {
|
|
||||||
if (!response || typeof response !== "object") {
|
|
||||||
return { outcome: "error", requestId: "", detail: "invalid wake response" };
|
|
||||||
}
|
|
||||||
const envelope = response as Record<string, unknown>;
|
|
||||||
const requestId =
|
|
||||||
typeof envelope.request_id === "string" ? envelope.request_id : "";
|
|
||||||
const status =
|
|
||||||
typeof envelope.status === "string" ? envelope.status : "error";
|
|
||||||
|
|
||||||
if (status !== "ok") {
|
|
||||||
const error = envelope.error as Record<string, unknown> | undefined;
|
|
||||||
const detail =
|
|
||||||
typeof error?.message === "string" ? error.message : "wake failed";
|
|
||||||
return { outcome: "error", requestId, detail };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = envelope.result as Record<string, unknown> | undefined;
|
|
||||||
if (result?.kind !== "wake") {
|
|
||||||
return { outcome: "ok", requestId, detail: "wake dispatched" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = Array.isArray(result.result) ? result.result : [];
|
|
||||||
const targetOutcomes = entries
|
|
||||||
.map((row) => {
|
|
||||||
if (!row || typeof row !== "object") return "";
|
|
||||||
const rowObj = row as Record<string, unknown>;
|
|
||||||
const statusRaw = rowObj.status;
|
|
||||||
const status =
|
|
||||||
typeof statusRaw === "string"
|
|
||||||
? statusRaw
|
|
||||||
: typeof (statusRaw as Record<string, unknown> | undefined)?.kind ===
|
|
||||||
"string"
|
|
||||||
? String((statusRaw as Record<string, unknown>).kind)
|
|
||||||
: "unknown";
|
|
||||||
const ip = typeof rowObj.ip === "string" ? rowObj.ip : "?";
|
|
||||||
const mac = typeof rowObj.mac === "string" ? rowObj.mac : "?";
|
|
||||||
return `${status}(${ip}/${mac})`;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const detail = targetOutcomes.length
|
|
||||||
? targetOutcomes.join(", ")
|
|
||||||
: "wake dispatched";
|
|
||||||
const failed = targetOutcomes.some(
|
|
||||||
(s) =>
|
|
||||||
s.startsWith("incomplete") ||
|
|
||||||
s.startsWith("nonexistent_address") ||
|
|
||||||
s.startsWith("wrong_size"),
|
|
||||||
);
|
|
||||||
return { outcome: failed ? "error" : "ok", requestId, detail };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chooseWakeTarget(device: DeviceRow): string {
|
|
||||||
return device.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarize(values: string[]): string {
|
|
||||||
if (!values.length) return "-";
|
|
||||||
if (values.length === 1) return values[0];
|
|
||||||
return `${values[0]} (+${values.length - 1})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadHistory(): WakeEvent[] {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
|
|
||||||
if (!raw) return [];
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
if (!Array.isArray(parsed)) return [];
|
|
||||||
return parsed.filter((row): row is WakeEvent => {
|
|
||||||
if (!row || typeof row !== "object") return false;
|
|
||||||
const event = row as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof event.ts === "number" &&
|
|
||||||
typeof event.target === "string" &&
|
|
||||||
typeof event.agentId === "string" &&
|
|
||||||
typeof event.outcome === "string" &&
|
|
||||||
typeof event.requestId === "string" &&
|
|
||||||
typeof event.detail === "string"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveHistory(history: WakeEvent[]) {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
WAKE_HISTORY_KEY,
|
|
||||||
JSON.stringify(history.slice(0, 20)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextSort(
|
|
||||||
current: { key: SortKey; dir: SortDir },
|
|
||||||
key: SortKey,
|
|
||||||
): { key: SortKey; dir: SortDir } {
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
dir: current.key === key && current.dir === "asc" ? "desc" : "asc",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Zap } from "lucide-react";
|
import { Copy, Minus, Plus, Zap } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
attachDeviceIdentifier,
|
attachDeviceIdentifier,
|
||||||
createKnownDevice,
|
createKnownDevice,
|
||||||
|
detachDeviceIdentifier,
|
||||||
mergeKnownDevice,
|
mergeKnownDevice,
|
||||||
type FleetDevice,
|
type FleetDevice,
|
||||||
|
type FleetDeviceEndpoint,
|
||||||
type KnownDevice,
|
type KnownDevice,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -26,7 +30,13 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
||||||
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
|
import {
|
||||||
|
agentLabel,
|
||||||
|
formatSeen,
|
||||||
|
identifiersFor,
|
||||||
|
presenceRank,
|
||||||
|
routeLabel,
|
||||||
|
} from "@/pages/fleet/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
device: FleetDevice | null;
|
device: FleetDevice | null;
|
||||||
@@ -49,75 +59,138 @@ export function FleetDeviceDetailsDialog({
|
|||||||
onCopy,
|
onCopy,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
||||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState(
|
||||||
|
device?.recommended_route?.route_id ?? "",
|
||||||
|
);
|
||||||
|
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||||
|
const [addValue, setAddValue] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||||
setDisplayName(device?.display_name ?? "");
|
|
||||||
setTargetDeviceId("");
|
// Resolve the full KnownDevice object if this fleet row is known
|
||||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
const fullKnown = knownDevices.find(
|
||||||
setError("");
|
(kd) => kd.device_id === device?.known_device?.device_id,
|
||||||
}, [device]);
|
);
|
||||||
|
|
||||||
|
// 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;
|
if (!device) return null;
|
||||||
|
|
||||||
const currentDevice = device;
|
const currentDevice = device;
|
||||||
const identifiers = identifiersFor(currentDevice);
|
|
||||||
const otherKnownDevices = knownDevices.filter(
|
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("");
|
setError("");
|
||||||
|
setActionBusy(true);
|
||||||
try {
|
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,
|
display_name: displayName.trim() || currentDevice.display_name,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
identifiers,
|
identifiers: rowIdentifiers,
|
||||||
|
});
|
||||||
|
toast.success(`Remembered as "${result.display_name}"`);
|
||||||
});
|
});
|
||||||
onChanged();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachToExisting() {
|
async function handleAttachToExisting() {
|
||||||
if (!targetDeviceId) return;
|
if (!targetDeviceId) return;
|
||||||
setError("");
|
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||||
try {
|
await wrap(async () => {
|
||||||
for (const identifier of identifiers) {
|
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
|
||||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
if (ids.length === 0) return;
|
||||||
}
|
|
||||||
onChanged();
|
const results = await Promise.allSettled(
|
||||||
} catch (err) {
|
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
|
||||||
setError(String(err));
|
);
|
||||||
}
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeIntoExisting() {
|
toast.success(
|
||||||
|
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
|
||||||
|
);
|
||||||
|
return { changed };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMerge() {
|
||||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||||
setError("");
|
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||||
try {
|
await wrap(async () => {
|
||||||
await mergeKnownDevice(
|
await mergeKnownDevice(
|
||||||
targetDeviceId,
|
targetDeviceId,
|
||||||
currentDevice.known_device.device_id,
|
currentDevice.known_device!.device_id,
|
||||||
);
|
);
|
||||||
onChanged();
|
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
|
||||||
onOpenChange(false);
|
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 =
|
const selectedRoute =
|
||||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
device.route_candidates.find((r) => r.route_id === routeId) ??
|
||||||
device.recommended_route;
|
device.recommended_route;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -127,7 +200,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{device.known_device
|
{device.known_device
|
||||||
? "Remembered fleet device"
|
? "Remembered fleet device"
|
||||||
: "Observed fleet device"}
|
: "Unidentified fleet device"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<PresenceBadge presence={device.presence} />
|
<PresenceBadge presence={device.presence} />
|
||||||
@@ -150,8 +223,8 @@ export function FleetDeviceDetailsDialog({
|
|||||||
/>
|
/>
|
||||||
<DetailBlock
|
<DetailBlock
|
||||||
label="Agents"
|
label="Agents"
|
||||||
values={device.agents.map((agent) =>
|
values={device.agents.map((a) =>
|
||||||
agentLabel(agent.agent_id, agent.nickname),
|
agentLabel(a.agent_id, a.nickname),
|
||||||
)}
|
)}
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
/>
|
/>
|
||||||
@@ -159,6 +232,43 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Endpoints</h3>
|
||||||
|
<Badge variant="outline">{device.endpoints.length}</Badge>
|
||||||
|
</div>
|
||||||
|
{device.endpoints.length ? (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<div className="hidden grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground sm:grid">
|
||||||
|
<span>Source</span>
|
||||||
|
<span>Target</span>
|
||||||
|
<span className="text-right">Last seen</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{[...device.endpoints]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const p = presenceRank(b.presence) - presenceRank(a.presence);
|
||||||
|
if (p !== 0) return p;
|
||||||
|
return (b.last_seen_unix ?? 0) - (a.last_seen_unix ?? 0);
|
||||||
|
})
|
||||||
|
.map((endpoint, index) => (
|
||||||
|
<EndpointRow
|
||||||
|
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
|
||||||
|
endpoint={endpoint}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||||
|
No endpoint evidence reported.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Wake route ── */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-medium">Wake route</h3>
|
<h3 className="text-sm font-medium">Wake route</h3>
|
||||||
@@ -171,10 +281,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
{busy ? "Waking" : "Wake"}
|
{busy ? "Waking" : "Wake"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? "")}>
|
||||||
value={routeId}
|
|
||||||
onValueChange={(value) => setRouteId(value ?? "")}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>
|
<span>
|
||||||
{selectedRoute
|
{selectedRoute
|
||||||
@@ -194,33 +301,222 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Identity section ── */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<h3 className="text-sm font-medium">Remember / merge</h3>
|
<h3 className="text-sm font-medium">Identity</h3>
|
||||||
{!device.known_device && (
|
|
||||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
{/* ── Known device: show metadata + identifiers ── */}
|
||||||
<Input
|
{fullKnown && (
|
||||||
value={displayName}
|
<div className="grid gap-3">
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
placeholder="Device name"
|
<Badge variant="secondary">known</Badge>
|
||||||
/>
|
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
|
||||||
<Button
|
<span className="font-medium">{fullKnown.display_name}</span>
|
||||||
onClick={() => void createRemembered()}
|
{fullKnown.notes && (
|
||||||
disabled={!identifiers.length}
|
<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"
|
||||||
>
|
>
|
||||||
Remember device
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No identifiers attached
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
</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
|
<Select
|
||||||
value={targetDeviceId}
|
value={targetDeviceId}
|
||||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>
|
<span>
|
||||||
{targetDeviceId
|
{targetDeviceId
|
||||||
? knownDevices.find(
|
? knownDevices.find(
|
||||||
(known) => known.device_id === targetDeviceId,
|
(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
|
)?.display_name
|
||||||
: "Select known device"}
|
: "Select known device"}
|
||||||
</span>
|
</span>
|
||||||
@@ -229,33 +525,26 @@ export function FleetDeviceDetailsDialog({
|
|||||||
className="max-w-[min(92vw,30rem)]"
|
className="max-w-[min(92vw,30rem)]"
|
||||||
alignItemWithTrigger={false}
|
alignItemWithTrigger={false}
|
||||||
>
|
>
|
||||||
{otherKnownDevices.map((known) => (
|
{knownDevices.map((kd) => (
|
||||||
<SelectItem key={known.device_id} value={known.device_id}>
|
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||||
{known.display_name}
|
{kd.display_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void attachToExisting()}
|
onClick={() => void handleAttachToExisting()}
|
||||||
disabled={!targetDeviceId || !identifiers.length}
|
disabled={
|
||||||
|
actionBusy || !targetDeviceId || !rowIdentifiers.length
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Attach IDs
|
Attach IDs
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void mergeIntoExisting()}
|
|
||||||
disabled={!targetDeviceId || !device.known_device}
|
|
||||||
>
|
|
||||||
Merge
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
Proposed identifiers:{" "}
|
</div>
|
||||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
)}
|
||||||
"-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -267,3 +556,52 @@ export function FleetDeviceDetailsDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EndpointRow({
|
||||||
|
endpoint,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
endpoint: FleetDeviceEndpoint;
|
||||||
|
onCopy: (label: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
const target = [endpoint.mac, endpoint.ip].filter(Boolean).join(" / ");
|
||||||
|
const location = [endpoint.interface, endpoint.hostname]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 rounded-md border bg-card px-3 py-2.5 text-sm sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] sm:items-center sm:gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
<PresenceBadge presence={endpoint.presence} />
|
||||||
|
<Badge variant="secondary">{endpoint.source.replace("_", " ")}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{agentLabel(endpoint.agent_id, endpoint.nickname)}
|
||||||
|
{endpoint.connected ? " (connected)" : " (offline)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-mono text-xs">{target || "-"}</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{location || "No interface or hostname"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="whitespace-nowrap text-right text-xs text-muted-foreground">
|
||||||
|
{formatSeen(endpoint.last_seen_unix)}
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{target && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onCopy("Endpoint", target)}
|
||||||
|
aria-label={`Copy endpoint ${target}`}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ export const presenceFilters = [
|
|||||||
"all",
|
"all",
|
||||||
"online",
|
"online",
|
||||||
"likely_online",
|
"likely_online",
|
||||||
"unknown",
|
|
||||||
"offline",
|
"offline",
|
||||||
|
"unknown",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||||
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
|
|||||||
export function presenceRank(presence: string): number {
|
export function presenceRank(presence: string): number {
|
||||||
if (presence === "online") return 4;
|
if (presence === "online") return 4;
|
||||||
if (presence === "likely_online") return 3;
|
if (presence === "likely_online") return 3;
|
||||||
if (presence === "unknown") return 2;
|
if (presence === "offline") return 2;
|
||||||
if (presence === "offline") return 1;
|
if (presence === "unknown") return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function routeLabel(route: FleetWakeRoute): string {
|
export function routeLabel(route: FleetWakeRoute): string {
|
||||||
const status = route.connected ? "connected" : "offline";
|
const status = route.connected ? "connected" : "agent offline";
|
||||||
const target = route.mac
|
const target = route.mac
|
||||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||||
: (route.ip ?? "-");
|
: (route.ip ?? "-");
|
||||||
|
|||||||
@@ -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-[44rem] 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-[44rem] 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user