Compare commits
20
Commits
v0.2.2
..
b850c218ec
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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/`.
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Completion Checklist
|
||||||
|
|
||||||
|
Before handing back code changes, run the smallest checks covering the changed area:
|
||||||
|
- Rust formatting: `cargo fmt`.
|
||||||
|
- For control-plane SQLx/state/API work: `cargo test -p wakey-control-plane` and `SQLX_OFFLINE=true cargo test -p wakey-control-plane`.
|
||||||
|
- For SQLx query macro changes: run `scripts/prepare_sqlx_sqlite.sh` and include the `.sqlx` metadata changes.
|
||||||
|
- For wakey-linux/core inventory/observation work: `cargo test -p wakey-linux -p wakey-core`.
|
||||||
|
- For agent session/config/protocol work: `cargo test -p wakey-agent`.
|
||||||
|
- For UI work: `pnpm --dir ui typecheck` and `pnpm --dir ui build`; use `pnpm --dir ui format` when touching TS/TSX/CSS formatting.
|
||||||
|
- Run relevant `cargo clippy -p <crate> --all-targets --all-features` when behavior or API shape changes.
|
||||||
|
|
||||||
|
Always check `git status --short` and mention unrelated dirty files or generated metadata in the final summary.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Wakey Project Overview
|
||||||
|
|
||||||
|
Wakey is a Wake-on-LAN and LAN observability workspace for Linux/OpenWrt routers. It includes:
|
||||||
|
- `wakey-core`: shared model and parsing types for devices, neighbors, DHCP, interfaces, wake results.
|
||||||
|
- `wakey-linux`: Linux/OpenWrt adapter layer for DHCP lease files, neighbor lookup, interface data, WoL sending, and local observation storage.
|
||||||
|
- root `wakey` crate: service layer and operator CLI for inventory, leases, devs, wake, and observe.
|
||||||
|
- `wakey-agent`: outbound router daemon. Enrolls with control plane, maintains authenticated websocket, executes relayed commands, syncs observations.
|
||||||
|
- `wakey-control-plane`: Axum control plane, SQLx SQLite state, agent registry, command relay, known devices, observations, audit/alerts, fleet APIs, static UI hosting.
|
||||||
|
- `ipjs`: typed wrappers around Linux `ip -j` data.
|
||||||
|
- `ui`: React/Vite operator UI using shadcn/base-ui style components.
|
||||||
|
|
||||||
|
Current persistence direction: control plane uses SQLx SQLite with migrations in `wakey-control-plane/migrations` and offline query metadata in `.sqlx` plus `wakey-control-plane/.sqlx`. Legacy sled import exists as explicit command.
|
||||||
|
|
||||||
|
Current product direction: fleet-first device UI and backend. Known device identity is manual. Observations/inventory provide suggestions/current state but should not automatically promote or delete durable identifiers.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Style And Conventions
|
||||||
|
|
||||||
|
Rust:
|
||||||
|
- Edition 2024.
|
||||||
|
- Prefer explicit, small service/API functions over large abstractions.
|
||||||
|
- Use `anyhow::Context` for fallible operations in application layers.
|
||||||
|
- Use `tracing` for logs.
|
||||||
|
- Shared domain types live in `wakey-core`; Linux/OpenWrt I/O in `wakey-linux`; root crate owns service/CLI behavior; agent/control-plane should reuse service/domain types where possible.
|
||||||
|
- SQLx query macros are used in control-plane state. Regenerate `.sqlx` metadata after query string changes.
|
||||||
|
- Device identity is manual: observations and inventory may suggest, but should not silently pin, merge, promote, or delete known identifiers.
|
||||||
|
- Hotplug observation actions should stay event-shaped (`add`, `update`, `remove`). Do not store NUD state as the action. NUD belongs to live neighbor/inventory data.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- React + Vite + TypeScript.
|
||||||
|
- Use existing component/style conventions; shadcn/base-ui/lucide are available.
|
||||||
|
- Main operator UI is fleet-first: common path is find device, inspect current IP/MAC/agent, wake, copy, remember/merge.
|
||||||
|
- Admin/debug pages stay available but should not dominate the primary nav.
|
||||||
|
|
||||||
|
Editing:
|
||||||
|
- Keep changes scoped.
|
||||||
|
- Preserve user edits in dirty worktrees.
|
||||||
|
- Use `apply_patch` for manual file edits.
|
||||||
|
- Default to ASCII unless files already use non-ASCII for a reason.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Suggested Commands
|
||||||
|
|
||||||
|
Rust formatting and checks:
|
||||||
|
- `cargo fmt`
|
||||||
|
- `cargo test -p wakey-control-plane`
|
||||||
|
- `SQLX_OFFLINE=true cargo test -p wakey-control-plane`
|
||||||
|
- `cargo test -p wakey-linux -p wakey-core`
|
||||||
|
- `cargo test -p wakey-agent` (may need elevated permissions if tests bind local sockets)
|
||||||
|
- `cargo clippy -p wakey-control-plane --all-targets --all-features`
|
||||||
|
- `cargo clippy -p wakey-linux -p wakey-core --all-targets --all-features`
|
||||||
|
|
||||||
|
SQLx metadata after query macro changes:
|
||||||
|
- `scripts/prepare_sqlx_sqlite.sh`
|
||||||
|
|
||||||
|
UI checks:
|
||||||
|
- `pnpm --dir ui typecheck`
|
||||||
|
- `pnpm --dir ui build`
|
||||||
|
- `pnpm --dir ui format`
|
||||||
|
|
||||||
|
Local run examples:
|
||||||
|
- `cargo run -- inventory <query>`
|
||||||
|
- `cargo run -- leases`
|
||||||
|
- `cargo run -- devs`
|
||||||
|
- `cargo run -- wake --mac aa:bb:cc:dd:ee:ff`
|
||||||
|
- `cargo run -p wakey-agent -- serve --config /etc/wakey-agent/config.toml`
|
||||||
|
- `cargo run -p wakey-control-plane -- serve --config-file /etc/wakey-control-plane/config.toml`
|
||||||
|
|
||||||
|
Useful shell tools:
|
||||||
|
- Prefer `rg` and `rg --files` for search.
|
||||||
|
- Use `git status --short`, `git diff --stat`, and focused `git diff -- <path>` before edits.
|
||||||
|
- Avoid destructive git commands unless explicitly requested.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "wakey"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# haxe java julia kotlin lua
|
||||||
|
# markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- rust
|
||||||
|
- typescript
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||||
|
# No documentation on options means no options are available.
|
||||||
|
ls_specific_settings: {}
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
#
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project based on the project name or path.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
|
||||||
|
# for example by saying that the information retrieved from a memory file is no longer correct
|
||||||
|
# or no longer relevant for the project.
|
||||||
|
# * `edit_memory`: Replaces content matching a regular expression in a memory.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_file`: Finds files in the given relative paths
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
|
||||||
|
# * `find_symbol`: Performs a global (or local) search using the language server backend.
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
|
||||||
|
# for clients that do not read the initial instructions when the MCP server is connected.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
|
||||||
|
# is relevant to the current task. You can infer whether the information
|
||||||
|
# is relevant from the memory file name.
|
||||||
|
# You should not read the same memory file multiple times in the same conversation.
|
||||||
|
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
|
||||||
|
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
|
||||||
|
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
|
||||||
|
# For JB, we use a separate tool.
|
||||||
|
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
|
||||||
|
# * `safe_delete_symbol`:
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||||
|
# The memory name should be meaningful.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 8
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
|
||||||
}
|
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fact_json!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "12e443ec0991c042cf527498b567bbbb6d02885f72eb3fa426c9493368f8a9e7"
|
||||||
|
}
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "observation_key!",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agent_id!",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "kind!",
|
|
||||||
"ordinal": 2,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac",
|
|
||||||
"ordinal": 3,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ip",
|
|
||||||
"ordinal": 4,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "hostname",
|
|
||||||
"ordinal": 5,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "first_seen_unix",
|
|
||||||
"ordinal": 6,
|
|
||||||
"type_info": "Integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "last_seen_unix",
|
|
||||||
"ordinal": 7,
|
|
||||||
"type_info": "Integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "last_action!",
|
|
||||||
"ordinal": 8,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "device_id",
|
|
||||||
"ordinal": 9,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "display_name",
|
|
||||||
"ordinal": 10,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pinned",
|
|
||||||
"ordinal": 11,
|
|
||||||
"type_info": "Integer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 2
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
|
||||||
|
}
|
||||||
-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 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
@@ -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"
|
|
||||||
}
|
|
||||||
+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": "UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7247cc488921de2313a602697590502771a231bde7d03cd9ccac55a70971da45"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "72aadefca9851a8becdd2dcce54b646aff095b64cef9837f5e8e4ea8dde71d02"
|
||||||
|
}
|
||||||
-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.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.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": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 10
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips\n WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ip!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c423e005d85659547302f506380ad217cc31329262a1c0254ef532653b3a37bb"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 12
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n ORDER BY agent_id, device_key",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_key",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "presence",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "first_seen_unix",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_seen_unix",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "cd8585b9431d223962c376a2d463f0ca8128e7759c8694631f41db0cbd8eb221"
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hostname!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "dba637bf03d53bf3394ead0f01466f9a72fafc54f9ce239d2e5ac7f76b63b168"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 10
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts\n WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fact_json!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e6be4d9782f66f98ebe4d4b6ec6e93ea9efe8e4c7f1147e1ff68d41a311d57f9"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 10
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fa09ff199445b7bf689ef4426cbd20d89040230ffff977cc1b4c73b499b1b33f"
|
||||||
|
}
|
||||||
Generated
+7
-6
@@ -3677,7 +3677,6 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3701,7 +3700,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3722,7 +3721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.1.2"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3746,12 +3745,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.1.2"
|
version = "0.3.0"
|
||||||
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",
|
||||||
@@ -3770,11 +3770,12 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wakey-agent",
|
"wakey-agent",
|
||||||
|
"wakey-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3785,7 +3786,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
@@ -32,6 +32,22 @@ 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"]
|
||||||
|
|
||||||
|
[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"
|
||||||
registry = "gitea"
|
registry = "gitea"
|
||||||
|
|||||||
+9
-1
@@ -24,4 +24,12 @@ $files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda
|
|||||||
$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\"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$files = "dhcp/95-wakey", "neigh/95-wakey"
|
||||||
|
$remote = $files.ForEach({ "/etc/hotplug.d/$_" })
|
||||||
|
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
|
||||||
|
|
||||||
|
$remote | ForEach-Object {
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$hotplugDir\"
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Report: Device State Rewrite
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The control plane now stores **complete per-agent Device snapshots** instead of flat observation rows. The agent sends `Vec<Device>` (merged from DHCP, neighbors, hooks). The control plane upserts per agent, deletes stale, fleet view merges across agents at read time.
|
||||||
|
|
||||||
|
## Architecture (before → after)
|
||||||
|
|
||||||
|
**Before**: Agent → raw hook observations → control plane flattens into rows → re-merges at fleet read → presence recomputed from flat actions
|
||||||
|
|
||||||
|
**After**: Agent → inventory → `Vec<Device>` with correct presence → control plane stores as-is → fleet merge at read time from typed rows
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
Old tables dropped: `agent_device_observations`, `agent_device_observation_events`, `agent_observation_snapshots`, `agent_observation_snapshot_keys`
|
||||||
|
|
||||||
|
New tables:
|
||||||
|
- `agent_devices` — per-agent device row (keyed by `DeviceId` serialization)
|
||||||
|
- `agent_device_macs` — MACs per device, indexed for cross-agent merge
|
||||||
|
- `agent_device_ips` — IPs per device, indexed for cross-agent merge
|
||||||
|
- `agent_device_hostnames` — hostnames per device
|
||||||
|
- `agent_device_facts` — debug/source material (JSON, not identity)
|
||||||
|
|
||||||
|
## Concrete types throughout
|
||||||
|
|
||||||
|
- `FleetDevice.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`, `presence`: `Presence`
|
||||||
|
- `FleetWakeRoute.mac`: `Option<MacAddr>`, `ip`: `Option<IpAddr>`
|
||||||
|
- `AgentDeviceWithChildren.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`
|
||||||
|
- Row types (`AgentDeviceMacRow`, `AgentDeviceIpRow`) stay `String` from SQLite, convert via `TryFrom` impls
|
||||||
|
- `AgentDeviceRow` has `.presence()`, `.first_seen()`, `.last_seen()` methods — no string matching in business code
|
||||||
|
- `Presence` from wakey-core: `Ord`, `PartialOrd`, `From<&str>`, `.as_str()`
|
||||||
|
|
||||||
|
## Rust features used
|
||||||
|
|
||||||
|
- `TryFrom<&AgentDeviceMacRow> for MacAddr`, `TryFrom<&AgentDeviceIpRow> for IpAddr`
|
||||||
|
- `From<&str> for Presence`
|
||||||
|
- `#[allow(dead_code)]` where appropriate (test-only methods, debug fields)
|
||||||
|
- Workspace dependencies for shared crates (`macaddr`, `serde`, `tokio`, etc.)
|
||||||
|
- Shared `TestStore` with `Drop` guard for clean test teardown
|
||||||
|
|
||||||
|
## What was removed
|
||||||
|
|
||||||
|
- `AgentObservation` struct (replaced by `Device`)
|
||||||
|
- `AgentDeviceObservation`, `AgentDeviceObservationView`, `AgentDeviceObservationEvent`, `AgentDeviceObservationInput` types
|
||||||
|
- `inventory_result_to_observations()` — the flatten-then-rebuild function
|
||||||
|
- `upload_agent_observations`, `list_agent_observations`, `list_agent_observation_history` API endpoints
|
||||||
|
- `gc_stale_observations()` — no longer needed
|
||||||
|
- `SyncObservations` CLI command from agent
|
||||||
|
- `prune_removed_observations_from_path` calls from agent session
|
||||||
|
- Observations page from UI
|
||||||
|
|
||||||
|
## What stayed
|
||||||
|
|
||||||
|
- Agent observation store (`/tmp/wakey_observations.json`) — agent-local memory, not forwarded
|
||||||
|
- Hotplug hooks — still update agent-local store, feed next inventory
|
||||||
|
- Known devices + identifiers — manual lifecycle, never auto-deleted
|
||||||
|
- Audit events, alerts — unchanged
|
||||||
|
- `observation_retention` config field — `#[allow(dead_code)]`, kept for config file compat
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo fmt — clean
|
||||||
|
cargo clippy-all — clean (0 warnings)
|
||||||
|
cargo test-all — 89 tests pass, 6 ignored (on-device only)
|
||||||
|
pnpm typecheck — clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remaining
|
||||||
|
|
||||||
|
- UI cleanup: remove observations page nav entry, observation API types from `api.ts`
|
||||||
|
- Known device identifier UI controls (API exists, UI needs affordance)
|
||||||
|
- The `observation_retention` config field could be removed in a future cleanup
|
||||||
|
- `list_agent_device_rows_for_agent` is `#[allow(dead_code)]` — used in tests, could be `#[cfg(test)]`
|
||||||
Regular → Executable
+19
-4
@@ -6,12 +6,27 @@ BIN="/root/.bin/wakey-agent"
|
|||||||
[ -n "$ACTION" ] || exit 0
|
[ -n "$ACTION" ] || exit 0
|
||||||
[ -n "$MACADDR" ] || exit 0
|
[ -n "$MACADDR" ] || exit 0
|
||||||
|
|
||||||
|
run_wakey_agent() {
|
||||||
|
if [ -n "$WAKEY_AGENT_CONFIG" ]; then
|
||||||
|
set -- --config "$WAKEY_AGENT_CONFIG" "$@"
|
||||||
|
fi
|
||||||
|
if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
|
||||||
|
set -- -v "$@"
|
||||||
|
fi
|
||||||
|
# [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
|
||||||
|
if command -v logger >/dev/null 2>&1; then
|
||||||
|
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
|
||||||
|
else
|
||||||
|
"$BIN" "$@" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
|
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
|
||||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" || true
|
||||||
elif [ -n "$IPADDR" ]; then
|
elif [ -n "$IPADDR" ]; then
|
||||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
|
||||||
elif [ -n "$HOSTNAME" ]; then
|
elif [ -n "$HOSTNAME" ]; then
|
||||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" || true
|
||||||
else
|
else
|
||||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" || true
|
||||||
fi
|
fi
|
||||||
Regular → Executable
+19
-4
@@ -5,12 +5,27 @@ BIN="/root/.bin/wakey-agent"
|
|||||||
[ -n "$BIN" ] || exit 0
|
[ -n "$BIN" ] || exit 0
|
||||||
[ -n "$ACTION" ] || exit 0
|
[ -n "$ACTION" ] || exit 0
|
||||||
|
|
||||||
|
run_wakey_agent() {
|
||||||
|
if [ -n "$WAKEY_AGENT_CONFIG" ]; then
|
||||||
|
set -- --config "$WAKEY_AGENT_CONFIG" "$@"
|
||||||
|
fi
|
||||||
|
if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
|
||||||
|
set -- -v "$@"
|
||||||
|
fi
|
||||||
|
# [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
|
||||||
|
if command -v logger >/dev/null 2>&1; then
|
||||||
|
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
|
||||||
|
else
|
||||||
|
"$BIN" "$@" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
|
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
|
||||||
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
|
||||||
elif [ -n "$MACADDR" ]; then
|
elif [ -n "$MACADDR" ]; then
|
||||||
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" || true
|
||||||
elif [ -n "$IPADDR" ]; then
|
elif [ -n "$IPADDR" ]; then
|
||||||
"$BIN" observe neigh --action "$ACTION" --ip "$IPADDR" >/dev/null 2>&1 || true
|
run_wakey_agent observe neigh --action "$ACTION" --ip "$IPADDR" || true
|
||||||
else
|
else
|
||||||
"$BIN" observe neigh --action "$ACTION" >/dev/null 2>&1 || true
|
run_wakey_agent observe neigh --action "$ACTION" || true
|
||||||
fi
|
fi
|
||||||
@@ -104,8 +104,10 @@ main() {
|
|||||||
# Ensure execute bits on staged files we know should be executable
|
# Ensure execute bits on staged files we know should be executable
|
||||||
for f in \
|
for f in \
|
||||||
"$STAGING/etc/init.d/"* \
|
"$STAGING/etc/init.d/"* \
|
||||||
|
"$STAGING/etc/hotplug.d/"*/* \
|
||||||
"$STAGING/etc/ldlda_help/"*.sh \
|
"$STAGING/etc/ldlda_help/"*.sh \
|
||||||
"$STAGING/root/.bin/wakey" \
|
"$STAGING/root/.bin/wakey" \
|
||||||
|
"$STAGING/root/.bin/wakey-agent" \
|
||||||
"$STAGING/root/.bin/kill_wakey.sh" \
|
"$STAGING/root/.bin/kill_wakey.sh" \
|
||||||
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do
|
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do
|
||||||
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
|
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
|
||||||
@@ -114,11 +116,14 @@ main() {
|
|||||||
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
|
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
|
||||||
for f in \
|
for f in \
|
||||||
"$STAGING/etc/init.d/"* \
|
"$STAGING/etc/init.d/"* \
|
||||||
|
"$STAGING/etc/hotplug.d/"*/* \
|
||||||
"$STAGING/etc/ldlda_help/"*.sh \
|
"$STAGING/etc/ldlda_help/"*.sh \
|
||||||
"$STAGING/root/.bin/"*.sh; do
|
"$STAGING/root/.bin/"*.sh; do
|
||||||
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
|
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
chown -R root:root "$STAGING" 2>/dev/null || true
|
||||||
|
|
||||||
# Copy staged tree into /
|
# Copy staged tree into /
|
||||||
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
|
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ if (Test-Path $hotplugSrc) {
|
|||||||
$content = Get-Content -Raw -LiteralPath $_.FullName
|
$content = Get-Content -Raw -LiteralPath $_.FullName
|
||||||
$content = $content -replace "`r`n", "`n"
|
$content = $content -replace "`r`n", "`n"
|
||||||
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||||
|
if (Get-Command chmod -ErrorAction SilentlyContinue) {
|
||||||
|
chmod +x $dest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ if ($Publish) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($Tag -and $Version) {
|
if ($Tag -and $Version) {
|
||||||
git tag -f "v$Version"
|
git tag -f -a "v$Version" -m "Release $Version"
|
||||||
git push -f origin "v$Version"
|
git push -f origin "v$Version"
|
||||||
Write-Host "Pushed tag: v$Version"
|
Write-Host "Pushed tag: v$Version"
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,30 @@
|
|||||||
# then packaging the same bundle format and installing it.
|
# then packaging the same bundle format and installing it.
|
||||||
#
|
#
|
||||||
# Simplest use:
|
# Simplest use:
|
||||||
# sudo -E ./scripts/update_wakey_cc_from_repo.sh
|
# ./scripts/update_wakey_cc_from_repo.sh
|
||||||
#
|
#
|
||||||
# Env options:
|
# Env options:
|
||||||
|
# WAKEY_CC_REPO_DIR Existing checkout to update/build instead of cloning
|
||||||
# WAKEY_CC_REPO_URL Git URL (default: https://git.ldlda.com/lda/wakey.git)
|
# WAKEY_CC_REPO_URL Git URL (default: https://git.ldlda.com/lda/wakey.git)
|
||||||
# WAKEY_CC_REF Git ref/branch/tag (default: main)
|
# WAKEY_CC_REF Git ref/branch/tag (default: main)
|
||||||
# WAKEY_CC_TARGET Rust target triple (default from uname)
|
# WAKEY_CC_TARGET Rust target triple (default from uname)
|
||||||
# WAKEY_CC_ROOT Install root (default: current directory)
|
# WAKEY_CC_ROOT Install root (default: current directory)
|
||||||
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
|
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
|
||||||
|
# WAKEY_CC_USER Owner for installed files/temp dirs (default: current user)
|
||||||
# WAKEY_CC_VERSION Bundle version label (default: v0.0.0-local-<short_sha>)
|
# WAKEY_CC_VERSION Bundle version label (default: v0.0.0-local-<short_sha>)
|
||||||
# WAKEY_CC_NO_RESTART If set, skip systemd restart
|
# WAKEY_CC_NO_RESTART If set, skip systemd restart
|
||||||
# WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging
|
# WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging
|
||||||
# SUDO Privilege escalation command/path (default: sudo when needed)
|
# SUDO Privilege escalation command/path for install steps (default: sudo)
|
||||||
#
|
#
|
||||||
# Requires: git, cargo, tar, pnpm, systemctl (optional for restart)
|
# Requires: git, cargo, tar, pnpm, systemctl (optional for restart)
|
||||||
|
|
||||||
|
# Common existing-checkout use:
|
||||||
|
: <<'USE CASE'
|
||||||
|
WAKEY_CC_REPO_DIR="$HOME/dev/wakey" \
|
||||||
|
WAKEY_CC_USER=lda \
|
||||||
|
WAKEY_CC_KEEP_TMP=1 \
|
||||||
|
scripts/update_wakey_cc_from_repo.sh
|
||||||
|
USE CASE
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; }
|
log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; }
|
||||||
@@ -64,14 +73,83 @@ sudo_cmd() {
|
|||||||
fail 'root privileges are required for install/restart steps; set SUDO or run as root'
|
fail 'root privileges are required for install/restart steps; set SUDO or run as root'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
priv() {
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
$SUDO_BIN "$@"
|
||||||
|
else
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_label() {
|
||||||
|
path=$1
|
||||||
|
if command -v restorecon >/dev/null 2>&1; then
|
||||||
|
priv restorecon "$path" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
chown_if_requested() {
|
||||||
|
[ -n "${OWNER:-}" ] || return 0
|
||||||
|
priv chown -R "$OWNER:$OWNER" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
chmod_user_rw_if_requested() {
|
||||||
|
[ -n "${OWNER:-}" ] || return 0
|
||||||
|
priv chmod -R u+rwX "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_existing_repo() {
|
||||||
|
repo_dir=$1
|
||||||
|
ref=$2
|
||||||
|
|
||||||
|
[ -d "$repo_dir/.git" ] || fail "WAKEY_CC_REPO_DIR is not a git checkout: $repo_dir"
|
||||||
|
|
||||||
|
log "using existing checkout $repo_dir (ref: $ref)"
|
||||||
|
git -C "$repo_dir" fetch --prune origin "$ref" || fail 'git fetch failed'
|
||||||
|
git -C "$repo_dir" checkout "$ref" || fail "git checkout failed: $ref"
|
||||||
|
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
install_executable_force() {
|
||||||
|
src=$1
|
||||||
|
dst=$2
|
||||||
|
dir=$(dirname "$dst")
|
||||||
|
tmp="$dst.new.$$"
|
||||||
|
old="$dst.old.$$"
|
||||||
|
|
||||||
|
priv mkdir -p "$dir"
|
||||||
|
priv rm -f "$tmp"
|
||||||
|
priv cp -f "$src" "$tmp"
|
||||||
|
priv chmod 0755 "$tmp"
|
||||||
|
chown_if_requested "$tmp"
|
||||||
|
if [ -e "$dst" ]; then
|
||||||
|
priv mv -f "$dst" "$old" || true
|
||||||
|
fi
|
||||||
|
priv mv -f "$tmp" "$dst"
|
||||||
|
priv rm -f "$old"
|
||||||
|
restore_label "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
REPO_DIR="${WAKEY_CC_REPO_DIR:-}"
|
||||||
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
|
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
|
||||||
REF="${WAKEY_CC_REF:-main}"
|
REF="${WAKEY_CC_REF:-main}"
|
||||||
TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
|
TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
|
||||||
ROOT="${WAKEY_CC_ROOT:-$PWD}"
|
ROOT="${WAKEY_CC_ROOT:-$PWD}"
|
||||||
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
|
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
|
||||||
|
if [ -n "${WAKEY_CC_USER:-}" ]; then
|
||||||
|
OWNER="$WAKEY_CC_USER"
|
||||||
|
elif [ "$(id -u)" -eq 0 ]; then
|
||||||
|
OWNER=
|
||||||
|
else
|
||||||
|
OWNER=$(id -un)
|
||||||
|
fi
|
||||||
SUDO_BIN=$(sudo_cmd)
|
SUDO_BIN=$(sudo_cmd)
|
||||||
|
|
||||||
|
if [ -n "$OWNER" ] && ! id "$OWNER" >/dev/null 2>&1; then
|
||||||
|
fail "WAKEY_CC_USER does not exist: $OWNER"
|
||||||
|
fi
|
||||||
|
|
||||||
require_cmd git
|
require_cmd git
|
||||||
require_cmd cargo
|
require_cmd cargo
|
||||||
require_cmd tar
|
require_cmd tar
|
||||||
@@ -80,19 +158,34 @@ main() {
|
|||||||
TMPBASE="${TMPDIR:-/tmp}"
|
TMPBASE="${TMPDIR:-/tmp}"
|
||||||
WORKDIR="$TMPBASE/wakey-cc-src.$$"
|
WORKDIR="$TMPBASE/wakey-cc-src.$$"
|
||||||
STAGING="$TMPBASE/wakey-cc-stage.$$"
|
STAGING="$TMPBASE/wakey-cc-stage.$$"
|
||||||
|
CLONED_WORKDIR=1
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
|
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
|
||||||
rm -rf "$WORKDIR" "$STAGING"
|
[ "$CLONED_WORKDIR" = "0" ] || rm -rf "$WORKDIR"
|
||||||
|
rm -rf "$STAGING"
|
||||||
else
|
else
|
||||||
log "keeping temp dir: $WORKDIR"
|
if [ "$CLONED_WORKDIR" != "0" ]; then
|
||||||
|
chown_if_requested "$WORKDIR"
|
||||||
|
chmod_user_rw_if_requested "$WORKDIR"
|
||||||
|
log "keeping temp dir: $WORKDIR"
|
||||||
|
fi
|
||||||
|
chown_if_requested "$STAGING"
|
||||||
|
chmod_user_rw_if_requested "$STAGING"
|
||||||
|
log "keeping temp dir: $STAGING"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
log "cloning $REPO_URL (ref: $REF)"
|
if [ -n "$REPO_DIR" ]; then
|
||||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
WORKDIR=$REPO_DIR
|
||||||
fail 'git clone failed'
|
CLONED_WORKDIR=0
|
||||||
|
update_existing_repo "$WORKDIR" "$REF"
|
||||||
|
else
|
||||||
|
log "cloning $REPO_URL (ref: $REF)"
|
||||||
|
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||||
|
fail 'git clone failed'
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$WORKDIR"
|
cd "$WORKDIR"
|
||||||
|
|
||||||
@@ -124,26 +217,29 @@ main() {
|
|||||||
[ -f "$STAGING/bin/wakey-control-plane" ] || fail 'bundle missing bin/wakey-control-plane'
|
[ -f "$STAGING/bin/wakey-control-plane" ] || fail 'bundle missing bin/wakey-control-plane'
|
||||||
[ -f "$STAGING/ui/dist/index.html" ] || fail 'bundle missing ui/dist/index.html'
|
[ -f "$STAGING/ui/dist/index.html" ] || fail 'bundle missing ui/dist/index.html'
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
priv mkdir -p "$ROOT"
|
||||||
$SUDO_BIN mkdir -p "$ROOT"
|
install_executable_force \
|
||||||
$SUDO_BIN cp -a "$STAGING/." "$ROOT/"
|
"$STAGING/bin/wakey-control-plane" \
|
||||||
else
|
"$ROOT/bin/wakey-control-plane"
|
||||||
mkdir -p "$ROOT"
|
priv cp -a "$STAGING/ui" "$ROOT/"
|
||||||
cp -a "$STAGING/." "$ROOT/"
|
priv cp -a "$STAGING/scripts" "$ROOT/"
|
||||||
fi
|
priv cp -a "$STAGING/deploy" "$ROOT/"
|
||||||
|
chown_if_requested \
|
||||||
|
"$ROOT/bin/wakey-control-plane" \
|
||||||
|
"$ROOT/ui" \
|
||||||
|
"$ROOT/scripts" \
|
||||||
|
"$ROOT/deploy"
|
||||||
|
chmod_user_rw_if_requested \
|
||||||
|
"$ROOT/ui" \
|
||||||
|
"$ROOT/scripts" \
|
||||||
|
"$ROOT/deploy"
|
||||||
|
|
||||||
if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then
|
if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
|
if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
|
||||||
log "restarting $SERVICE"
|
log "restarting $SERVICE"
|
||||||
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
priv systemctl daemon-reload
|
||||||
$SUDO_BIN systemctl daemon-reload
|
priv systemctl restart "$SERVICE"
|
||||||
$SUDO_BIN systemctl restart "$SERVICE"
|
priv systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||||
$SUDO_BIN systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
|
||||||
else
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl restart "$SERVICE"
|
|
||||||
systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log "service $SERVICE not installed; skipped restart"
|
log "service $SERVICE not installed; skipped restart"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+5
-5
@@ -46,7 +46,7 @@ pub struct LeasesArgs {
|
|||||||
)]
|
)]
|
||||||
pub include_state: bool,
|
pub include_state: bool,
|
||||||
/// Print machine-readable JSON instead of a table.
|
/// Print machine-readable JSON instead of a table.
|
||||||
#[arg(long)]
|
#[arg(long, short)]
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ pub struct WakeArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub ip: Option<IpAddr>,
|
pub ip: Option<IpAddr>,
|
||||||
/// Print machine-readable JSON instead of a table.
|
/// Print machine-readable JSON instead of a table.
|
||||||
#[arg(long)]
|
#[arg(short, long)]
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ pub struct InventoryArgs {
|
|||||||
#[arg(long = "mac")]
|
#[arg(long = "mac")]
|
||||||
pub macs: Vec<macaddr::MacAddr>,
|
pub macs: Vec<macaddr::MacAddr>,
|
||||||
/// Print machine-readable JSON instead of a table.
|
/// Print machine-readable JSON instead of a table.
|
||||||
#[arg(long)]
|
#[arg(short, long)]
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +114,10 @@ pub struct DevsArgs {
|
|||||||
/// Optional interface name to show.
|
/// Optional interface name to show.
|
||||||
pub dev: Option<String>,
|
pub dev: Option<String>,
|
||||||
/// Show only interfaces whose operstate is `up`.
|
/// Show only interfaces whose operstate is `up`.
|
||||||
#[arg(long)]
|
#[arg(short, long)]
|
||||||
pub up: bool,
|
pub up: bool,
|
||||||
/// Print machine-readable JSON instead of a table.
|
/// Print machine-readable JSON instead of a table.
|
||||||
#[arg(long)]
|
#[arg(short, long)]
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@ 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,
|
||||||
|
};
|
||||||
|
|
||||||
#[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)
|
||||||
@@ -132,7 +132,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() {
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
"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",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-compiler-runtime": "^1.0.0",
|
"react-compiler-runtime": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
|
|||||||
Generated
+55
@@ -25,6 +25,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0([email protected])
|
version: 1.8.0([email protected])
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.4.6
|
||||||
|
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -40,6 +43,9 @@ importers:
|
|||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0(@types/[email protected])([email protected])
|
version: 4.2.0(@types/[email protected])([email protected])
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7([email protected]([email protected]))([email protected])
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -843,6 +849,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -851,6 +858,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -859,6 +867,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -867,6 +876,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -875,6 +885,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -883,6 +894,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -891,6 +903,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -899,6 +912,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -907,6 +921,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -915,6 +930,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -923,6 +939,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -931,6 +948,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -939,6 +957,7 @@ packages:
|
|||||||
}
|
}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@rollup/[email protected]":
|
"@rollup/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1060,6 +1079,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1069,6 +1089,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1078,6 +1099,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -1087,6 +1109,7 @@ packages:
|
|||||||
engines: { node: ">= 20" }
|
engines: { node: ">= 20" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
"@tailwindcss/[email protected]":
|
"@tailwindcss/[email protected]":
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2365,6 +2388,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2374,6 +2398,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2383,6 +2408,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2392,6 +2418,7 @@ packages:
|
|||||||
engines: { node: ">= 12.0.0" }
|
engines: { node: ">= 12.0.0" }
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
@@ -2581,6 +2608,15 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.6" }
|
engines: { node: ">= 0.6" }
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -3086,6 +3122,15 @@ packages:
|
|||||||
integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==,
|
integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -4862,6 +4907,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -5221,6 +5271,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
+27
-6
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Agent,
|
type Agent,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
revokeAgent,
|
revokeAgent,
|
||||||
setAgentNickname,
|
setAgentNickname,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
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";
|
||||||
@@ -20,8 +22,9 @@ import { AuditPage } from "@/pages/AuditPage";
|
|||||||
import { CommandsPage } from "@/pages/CommandsPage";
|
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 { ObservationsPage } from "@/pages/ObservationsPage";
|
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||||
|
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -33,11 +36,9 @@ export function App() {
|
|||||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||||
|
|
||||||
const [state, setState] = useState<LoadState>("idle");
|
const [state, setState] = useState<LoadState>("idle");
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
setState("loading");
|
setState("loading");
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -65,13 +66,16 @@ export function App() {
|
|||||||
setState("ready");
|
setState("ready");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState("error");
|
setState("error");
|
||||||
setError(String(err));
|
toast.error("Failed to load data", { description: String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRevokeAgent(agentId: string): Promise<boolean> {
|
async function onRevokeAgent(agentId: string): Promise<boolean> {
|
||||||
const result = await revokeAgent(agentId);
|
const result = await revokeAgent(agentId);
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
if (result.revoked) {
|
||||||
|
toast.success(`Revoked ${agentId}`);
|
||||||
|
}
|
||||||
return result.revoked;
|
return result.revoked;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,11 @@ export function App() {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await setAgentNickname(agentId, nickname);
|
const result = await setAgentNickname(agentId, nickname);
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
if (result.updated) {
|
||||||
|
toast.success(`Nickname updated for ${agentId}`, {
|
||||||
|
description: nickname || "(cleared)",
|
||||||
|
});
|
||||||
|
}
|
||||||
return result.updated;
|
return result.updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +135,6 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <pre className="error">{error}</pre>}
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
<Route
|
<Route
|
||||||
@@ -140,6 +148,17 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="wake"
|
||||||
|
element={
|
||||||
|
<WakeToolsPage
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelectAgent={setSelectedAgentId}
|
||||||
|
onAfterWake={loadAll}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="dashboard"
|
path="dashboard"
|
||||||
element={
|
element={
|
||||||
@@ -147,12 +166,12 @@ export function App() {
|
|||||||
agents={agents}
|
agents={agents}
|
||||||
alerts={alerts}
|
alerts={alerts}
|
||||||
transitions={history}
|
transitions={history}
|
||||||
|
audit={audit}
|
||||||
loading={state === "loading"}
|
loading={state === "loading"}
|
||||||
onRefresh={loadAll}
|
onRefresh={loadAll}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="observations" element={<ObservationsPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="agents"
|
path="agents"
|
||||||
element={
|
element={
|
||||||
@@ -196,9 +215,11 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="tokens" element={<TokensPage />} />
|
<Route path="tokens" element={<TokensPage />} />
|
||||||
|
<Route path="observations" element={<ObservationsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<Toaster richColors position="bottom-right" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+136
@@ -122,6 +122,63 @@ export type AgentDeviceObservationEvent = {
|
|||||||
known_device: KnownDeviceSummary | null;
|
known_device: KnownDeviceSummary | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FleetDeviceAgent = {
|
||||||
|
agent_id: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
last_seen_unix: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FleetWakeRoute = {
|
||||||
|
route_id: string;
|
||||||
|
agent_id: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
mac: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
source: string;
|
||||||
|
last_seen_unix: number;
|
||||||
|
wakeable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FleetDevice = {
|
||||||
|
device_key: string;
|
||||||
|
display_name: string;
|
||||||
|
known_device: KnownDeviceSummary | null;
|
||||||
|
pinned: boolean;
|
||||||
|
ips: string[];
|
||||||
|
macs: string[];
|
||||||
|
hostnames: string[];
|
||||||
|
agents: FleetDeviceAgent[];
|
||||||
|
sources: string[];
|
||||||
|
first_seen_unix: number | null;
|
||||||
|
last_seen_unix: number | null;
|
||||||
|
presence: string;
|
||||||
|
route_candidates: FleetWakeRoute[];
|
||||||
|
recommended_route: FleetWakeRoute | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RefreshFleetDevicesResponse = {
|
||||||
|
total_accepted: number;
|
||||||
|
agents: {
|
||||||
|
agent_id: string;
|
||||||
|
status: string;
|
||||||
|
accepted: number;
|
||||||
|
error: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WakeFleetDeviceResponse = {
|
||||||
|
route: FleetWakeRoute;
|
||||||
|
command: {
|
||||||
|
request_id: string;
|
||||||
|
status: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: string; message: string; retryable?: boolean | null };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
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,
|
||||||
@@ -208,6 +265,59 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
|||||||
return request<KnownDevice[]>("/api/v1/control/devices");
|
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchFleetDevices(opts?: {
|
||||||
|
query?: string;
|
||||||
|
presence?: string;
|
||||||
|
known?: string;
|
||||||
|
agentId?: string;
|
||||||
|
visibility?: "operator" | "all";
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<FleetDevice[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.query) params.set("query", opts.query);
|
||||||
|
if (opts?.presence && opts.presence !== "all") {
|
||||||
|
params.set("presence", opts.presence);
|
||||||
|
}
|
||||||
|
if (opts?.known && opts.known !== "all") params.set("known", opts.known);
|
||||||
|
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||||
|
if (opts?.visibility) params.set("visibility", opts.visibility);
|
||||||
|
params.set("limit", String(opts?.limit ?? 500));
|
||||||
|
return request<FleetDevice[]>(
|
||||||
|
`/api/v1/control/fleet/devices?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshFleetDevices(input?: {
|
||||||
|
agentIds?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<RefreshFleetDevicesResponse> {
|
||||||
|
return request<RefreshFleetDevicesResponse>(
|
||||||
|
"/api/v1/control/fleet/devices/refresh",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_ids: input?.agentIds ?? [],
|
||||||
|
timeout_ms: input?.timeoutMs ?? null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wakeFleetDevice(input: {
|
||||||
|
deviceKey: string;
|
||||||
|
routeId?: string | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<WakeFleetDeviceResponse> {
|
||||||
|
return request<WakeFleetDeviceResponse>("/api/v1/control/fleet/wake", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
device_key: input.deviceKey,
|
||||||
|
route_id: input.routeId ?? null,
|
||||||
|
timeout_ms: input.timeoutMs ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchObservations(opts?: {
|
export function fetchObservations(opts?: {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -270,6 +380,32 @@ export function attachObservationIdentifier(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function attachDeviceIdentifier(
|
||||||
|
deviceId: string,
|
||||||
|
identifier: { kind: string; value: string },
|
||||||
|
): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>(
|
||||||
|
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(identifier),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeKnownDevice(
|
||||||
|
targetDeviceId: string,
|
||||||
|
sourceDeviceId: string,
|
||||||
|
): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>(
|
||||||
|
`/api/v1/control/devices/${encodeURIComponent(targetDeviceId)}/merge`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ source_device_id: sourceDeviceId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function runCommand(
|
export function runCommand(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
kind: CommandKind,
|
kind: CommandKind,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Agent } from "@/api";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
value: string;
|
||||||
|
onChange: (agentId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayAgentLabel(agent: Agent): string {
|
||||||
|
const nickname = agent.nickname?.trim();
|
||||||
|
return nickname ? nickname : agent.agent_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentSelector({
|
||||||
|
agents,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const selected = agents.find((a) => a.agent_id === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) onChange(v);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={className ?? "w-full min-w-0"}>
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-2 text-start">
|
||||||
|
{selected ? (
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${selected.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{value
|
||||||
|
? selected
|
||||||
|
? displayAgentLabel(selected)
|
||||||
|
: value
|
||||||
|
: "Select agent"}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{selected.connected ? "connected" : "offline"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,30rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<SelectItem
|
||||||
|
key={agent.agent_id}
|
||||||
|
value={agent.agent_id}
|
||||||
|
className="pe-10"
|
||||||
|
disabled={!agent.connected}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{displayAgentLabel(agent)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{agent.connected ? "connected" : "offline"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
OctagonXIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pe-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{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]:-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.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
+222
-72
@@ -1,96 +1,246 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Monitor,
|
||||||
|
Zap,
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
Key,
|
||||||
|
Eye,
|
||||||
|
Terminal,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const navItems = [
|
const SIDEBAR_KEY = "wakey-sidebar-collapsed";
|
||||||
["/", "Devices"],
|
|
||||||
["/observations", "Observed"],
|
|
||||||
["/commands", "Wake Tools"],
|
|
||||||
["/dashboard", "Fleet Health"],
|
|
||||||
["/agents", "Agents"],
|
|
||||||
["/audit", "Audit"],
|
|
||||||
["/alerts", "Alerts"],
|
|
||||||
["/tokens", "Tokens"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type NavSection = {
|
||||||
|
label: string;
|
||||||
|
items: readonly {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
end?: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
const THEME_KEY = "wakey-ui-theme";
|
const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
label: "Operations",
|
||||||
|
items: [
|
||||||
|
{ to: "/", label: "Devices", icon: Monitor, end: true },
|
||||||
|
{ to: "/wake", label: "Wake Tools", icon: Zap },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Monitoring",
|
||||||
|
items: [
|
||||||
|
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||||
|
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||||
|
{ to: "/observations", label: "Observations", icon: Eye },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Admin",
|
||||||
|
items: [
|
||||||
|
{ to: "/agents", label: "Agents", icon: Bot },
|
||||||
|
{ to: "/audit", label: "Audit", icon: FileText },
|
||||||
|
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||||
|
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function resolveInitialTheme(): Theme {
|
function usePageTitle() {
|
||||||
const stored = window.localStorage.getItem(THEME_KEY);
|
const { pathname } = useLocation();
|
||||||
if (stored === "light" || stored === "dark") return stored;
|
useEffect(() => {
|
||||||
|
const flat = navSections.flatMap((s) => s.items);
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
const match = flat.find((item) =>
|
||||||
? "dark"
|
item.end
|
||||||
: "light";
|
? pathname === "/ui" || pathname === "/ui/" || pathname === "/"
|
||||||
|
: pathname.startsWith(item.to) && item.to !== "/",
|
||||||
|
);
|
||||||
|
document.title = match ? `${match.label} — Wakey` : "Wakey Operator UI";
|
||||||
|
}, [pathname]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [theme, setTheme] = useState<Theme>(() => resolveInitialTheme());
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
return window.localStorage.getItem(SIDEBAR_KEY) === "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
usePageTitle();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
|
||||||
root.classList.toggle("dark", theme === "dark");
|
}, [collapsed]);
|
||||||
window.localStorage.setItem(THEME_KEY, theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl p-4">
|
<div className="app-shell">
|
||||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<aside
|
||||||
<div>
|
className={[
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
"sidebar",
|
||||||
Wakey Operator UI
|
collapsed ? "sidebar--collapsed" : "",
|
||||||
</h1>
|
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out",
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
"md:sticky md:top-0 md:z-30 md:shadow-none",
|
||||||
Find devices fast and wake them reliably
|
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
||||||
</p>
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
data-collapsed={collapsed}
|
||||||
|
>
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<div className="sidebar-logo">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="sidebar-brand-text">
|
||||||
|
<span className="text-sm font-semibold tracking-tight">
|
||||||
|
Wakey
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.65rem] leading-none text-muted-foreground">
|
||||||
|
Operator UI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{navSections.map((section) => (
|
||||||
|
<div key={section.label} className="sidebar-section">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="sidebar-section-label">{section.label}</div>
|
||||||
|
)}
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const link = (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"sidebar-link",
|
||||||
|
isActive ? "sidebar-link--active" : "",
|
||||||
|
collapsed ? "sidebar-link--collapsed" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="sidebar-link-icon" />
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="sidebar-link-label">{item.label}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.to}>
|
||||||
|
<TooltipTrigger>{link}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="sidebar-footer-btn"
|
||||||
|
aria-label={isDark ? "Switch to light" : "Switch to dark"}
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<Sun className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Moon className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="sidebar-link-label">
|
||||||
|
{isDark ? "Light mode" : "Dark mode"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{isDark ? "Switch to light" : "Switch to dark"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="sidebar-footer-btn"
|
||||||
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<PanelLeft className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<PanelLeftClose className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="sidebar-link-label">Collapse</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{collapsed ? "Expand" : "Collapse"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile backdrop */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile floating button when sidebar is collapsed/hidden */}
|
||||||
|
{collapsed && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="fixed right-3 top-3 z-50 size-9 rounded-full p-0 sm:static sm:size-auto sm:rounded-md sm:px-3 sm:py-2"
|
className="fixed bottom-3 left-3 z-40 md:hidden"
|
||||||
aria-label={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
aria-label="Open menu"
|
||||||
title={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
onClick={() => setCollapsed(false)}
|
||||||
onClick={() =>
|
|
||||||
setTheme((current) => (current === "dark" ? "light" : "dark"))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
<PanelLeft className="size-4" aria-hidden />
|
||||||
<Sun className="size-4" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Moon className="size-4" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{theme === "dark" ? "Switch to light" : "Switch to dark"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
)}
|
||||||
|
|
||||||
<nav className="mb-3 flex flex-wrap gap-2">
|
<main className="app-main @container">
|
||||||
{navItems.map(([to, label]) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={to === "/"}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
[
|
|
||||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
|
||||||
isActive
|
|
||||||
? "border-primary/60 bg-primary/10 text-foreground"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
|
||||||
].join(" ")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-1
@@ -1,13 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename="/ui">
|
<BrowserRouter basename="/ui">
|
||||||
<App />
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
storageKey="wakey-ui-theme"
|
||||||
|
enableSystem
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
+73
-70
@@ -2,7 +2,11 @@ import type { Agent } from "@/api";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Bot, Inbox } from "lucide-react";
|
||||||
|
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@@ -23,29 +27,17 @@ export function AgentsPage({
|
|||||||
onSetAgentNickname,
|
onSetAgentNickname,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
function displayLabel(agent: Agent): string {
|
|
||||||
const nickname = agent.nickname?.trim();
|
|
||||||
return nickname ? nickname : agent.agent_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRevoke(agentId: string) {
|
async function onRevoke(agentId: string) {
|
||||||
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
|
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const revoked = await onRevokeAgent(agentId);
|
await onRevokeAgent(agentId);
|
||||||
setStatus(
|
|
||||||
revoked ? `Revoked ${agentId}` : `${agentId} was already absent`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Revoke failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -54,17 +46,10 @@ export function AgentsPage({
|
|||||||
async function onSaveNickname(agent: Agent) {
|
async function onSaveNickname(agent: Agent) {
|
||||||
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
|
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const updated = await onSetAgentNickname(agent.agent_id, draft);
|
await onSetAgentNickname(agent.agent_id, draft);
|
||||||
setStatus(
|
|
||||||
updated
|
|
||||||
? `Updated nickname for ${agent.agent_id}`
|
|
||||||
: `${agent.agent_id} not found`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to update nickname", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -73,29 +58,61 @@ export function AgentsPage({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Agents</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bot className="size-5 text-primary" />
|
||||||
|
Agents
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
className={`rounded-lg border px-4 py-3 transition-colors ${
|
||||||
|
selectedAgentId === agent.agent_id
|
||||||
|
? "border-primary/40 bg-primary/5"
|
||||||
|
: "bg-card"
|
||||||
|
}`}
|
||||||
key={agent.agent_id}
|
key={agent.agent_id}
|
||||||
>
|
>
|
||||||
<Button
|
<div className="flex items-start justify-between gap-3">
|
||||||
variant={
|
<button
|
||||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
type="button"
|
||||||
}
|
className="flex min-w-0 flex-1 flex-col items-start gap-1 text-left"
|
||||||
className="flex h-auto flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
|
onClick={() => onSelectAgent(agent.agent_id)}
|
||||||
onClick={() => onSelectAgent(agent.agent_id)}
|
disabled={busy}
|
||||||
disabled={busy}
|
>
|
||||||
>
|
<span className="flex items-center gap-2">
|
||||||
<span className="min-w-0 truncate">{displayLabel(agent)}</span>
|
<span
|
||||||
<span className="text-xs text-muted-foreground">
|
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||||
{agent.connected ? "connected" : "offline"}
|
aria-hidden
|
||||||
</span>
|
/>
|
||||||
</Button>
|
<span className="text-sm font-medium">
|
||||||
<div className="grid min-w-52 gap-2">
|
{displayAgentLabel(agent)}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={agent.connected ? "secondary" : "outline"}
|
||||||
|
className="text-[0.6rem] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{agent.connected ? "connected" : "offline"}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.65rem] text-muted-foreground/70 font-mono truncate max-w-full">
|
||||||
|
{agent.agent_id}
|
||||||
|
</code>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => void onRevoke(agent.agent_id)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
|
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -106,45 +123,31 @@ export function AgentsPage({
|
|||||||
}
|
}
|
||||||
placeholder="nickname (optional)"
|
placeholder="nickname (optional)"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => void onSaveNickname(agent)}
|
||||||
onClick={() => void onSaveNickname(agent)}
|
disabled={busy}
|
||||||
disabled={busy}
|
>
|
||||||
>
|
Save
|
||||||
Save Name
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => void onRevoke(agent.agent_id)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!agents.length && (
|
{!agents.length && (
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
No agents enrolled
|
<Inbox className="size-10 text-muted-foreground/30" />
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
No agents enrolled
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
Issue a token and enroll an agent to get started
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
|
||||||
{status}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Terminal } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AgentSelector } from "@/components/AgentSelector";
|
||||||
import { runCommand, type Agent, type CommandKind } from "@/api";
|
import { runCommand, type Agent, type CommandKind } from "@/api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -26,11 +35,6 @@ export function CommandsPage({
|
|||||||
onSelectAgent,
|
onSelectAgent,
|
||||||
onAfterCommand,
|
onAfterCommand,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
function displayAgentLabel(agent: Agent): string {
|
|
||||||
const nickname = agent.nickname?.trim();
|
|
||||||
return nickname ? nickname : agent.agent_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [kind, setKind] = useState<CommandKind>("devs");
|
const [kind, setKind] = useState<CommandKind>("devs");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [output, setOutput] = useState("Select agent and run a command");
|
const [output, setOutput] = useState("Select agent and run a command");
|
||||||
@@ -44,9 +48,11 @@ export function CommandsPage({
|
|||||||
try {
|
try {
|
||||||
const out = await runCommand(selectedAgentId, kind, query.trim());
|
const out = await runCommand(selectedAgentId, kind, query.trim());
|
||||||
setOutput(JSON.stringify(out, null, 2));
|
setOutput(JSON.stringify(out, null, 2));
|
||||||
|
toast.success(`Command "${kind}" completed`);
|
||||||
if (onAfterCommand) await onAfterCommand();
|
if (onAfterCommand) await onAfterCommand();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOutput(String(err));
|
setOutput(String(err));
|
||||||
|
toast.error("Command failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
@@ -55,44 +61,23 @@ export function CommandsPage({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Command Runner</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="size-5 text-primary" />
|
||||||
|
Command Runner
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Debug tool — send raw commands to agents and inspect JSON responses
|
||||||
|
</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 className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<Select
|
<AgentSelector
|
||||||
|
agents={agents}
|
||||||
value={selectedAgentId}
|
value={selectedAgentId}
|
||||||
onValueChange={(value) => {
|
onChange={onSelectAgent}
|
||||||
if (value) onSelectAgent(value);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Select agent" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{agents.map((agent) => (
|
|
||||||
<SelectItem
|
|
||||||
key={agent.agent_id}
|
|
||||||
value={agent.agent_id}
|
|
||||||
disabled={!agent.connected}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{displayAgentLabel(agent)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{agent.connected ? "connected" : "offline"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
|||||||
+253
-20
@@ -1,4 +1,15 @@
|
|||||||
import type { Agent, Alert, AlertTransition } from "@/api";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Activity, Bell, Bot, Key, RefreshCw, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Agent,
|
||||||
|
type Alert,
|
||||||
|
type AlertTransition,
|
||||||
|
type AuditEvent,
|
||||||
|
type EnrollTokenStatus,
|
||||||
|
fetchEnrollTokens,
|
||||||
|
} from "@/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -7,66 +18,288 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
alerts: Alert[];
|
alerts: Alert[];
|
||||||
transitions: AlertTransition[];
|
transitions: AlertTransition[];
|
||||||
|
audit: AuditEvent[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function relativeTime(unixMs: number): string {
|
||||||
|
const diff = Date.now() - unixMs;
|
||||||
|
if (diff < 60_000) return "just now";
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||||
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityColor(severity: string): string {
|
||||||
|
switch (severity.toLowerCase()) {
|
||||||
|
case "critical":
|
||||||
|
return "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||||
|
case "info":
|
||||||
|
return "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIcon(eventType: string): string {
|
||||||
|
if (eventType.includes("wake")) return "⚡";
|
||||||
|
if (eventType.includes("enroll")) return "🔑";
|
||||||
|
if (eventType.includes("command")) return "▶";
|
||||||
|
if (eventType.includes("connect")) return "🔗";
|
||||||
|
if (eventType.includes("disconnect")) return "⛓️💥";
|
||||||
|
return "•";
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage({
|
export function DashboardPage({
|
||||||
agents,
|
agents,
|
||||||
alerts,
|
alerts,
|
||||||
transitions,
|
transitions,
|
||||||
|
audit,
|
||||||
loading,
|
loading,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const connected = agents.filter((a) => a.connected).length;
|
const connected = agents.filter((a) => a.connected).length;
|
||||||
|
const total = agents.length;
|
||||||
|
|
||||||
|
const severityBuckets = alerts.reduce<Record<string, number>>((acc, a) => {
|
||||||
|
acc[a.severity] = (acc[a.severity] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchEnrollTokens()
|
||||||
|
.then(setTokens)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
const activeTokens = tokens.filter((t) => !t.expired).length;
|
||||||
|
|
||||||
|
const recentAudit = audit.slice(0, 8);
|
||||||
|
|
||||||
|
if (loading && !agents.length) {
|
||||||
|
return (
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="mt-2 h-8 w-12" />
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
<Card className="sm:col-span-2 lg:col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Fleet Status */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardDescription>Agents</CardDescription>
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<CardTitle className="text-2xl">{agents.length}</CardTitle>
|
<Bot className="size-3.5" />
|
||||||
|
Fleet Status
|
||||||
|
</CardDescription>
|
||||||
|
<Link
|
||||||
|
to="/agents"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">
|
||||||
|
{connected}
|
||||||
|
<span className="text-base font-normal text-muted-foreground">
|
||||||
|
/{total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{connected === total
|
||||||
|
? "All agents connected"
|
||||||
|
: `${total - connected} offline`}
|
||||||
|
</p>
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="mt-2 flex h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-emerald-500 transition-all"
|
||||||
|
style={{ width: `${(connected / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Alerts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardDescription>Connected</CardDescription>
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<CardTitle className="text-2xl">{connected}</CardTitle>
|
<Bell className="size-3.5" />
|
||||||
|
Active Alerts
|
||||||
|
</CardDescription>
|
||||||
|
<Link
|
||||||
|
to="/alerts"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
|
||||||
|
{Object.keys(severityBuckets).length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(severityBuckets)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([sev, count]) => (
|
||||||
|
<Badge
|
||||||
|
key={sev}
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.65rem] ${severityColor(sev)}`}
|
||||||
|
>
|
||||||
|
{count} {sev}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">All clear</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Transitions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardDescription>Active Alerts</CardDescription>
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<CardTitle className="text-2xl">{alerts.length}</CardTitle>
|
<Activity className="size-3.5" />
|
||||||
|
Transitions
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">
|
||||||
|
{transitions.length}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Recent state changes
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Token Health */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardDescription>Transitions</CardDescription>
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
<CardTitle className="text-2xl">{transitions.length}</CardTitle>
|
<Key className="size-3.5" />
|
||||||
|
Enroll Tokens
|
||||||
|
</CardDescription>
|
||||||
|
<Link
|
||||||
|
to="/tokens"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{activeTokens}</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{activeTokens === 1 ? "active token" : "active tokens"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 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 space-y-0">
|
||||||
<CardTitle>Overview</CardTitle>
|
<div>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Latest audit events across the fleet
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
{loading ? "Refreshing..." : "Refresh"}
|
<RefreshCw
|
||||||
|
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
{recentAudit.length > 0 ? (
|
||||||
Use tabs to run commands, inspect audits, and monitor alerts in real
|
<div className="grid gap-0">
|
||||||
time.
|
{recentAudit.map((event, i) => (
|
||||||
</p>
|
<div
|
||||||
|
key={event.event_id}
|
||||||
|
className={`flex items-start gap-3 py-3 ${
|
||||||
|
i < recentAudit.length - 1
|
||||||
|
? "border-b border-border/50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-sm"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{eventIcon(event.event_type)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{event.event_type.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
event.outcome === "success"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
className="text-[0.6rem] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{event.outcome}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{event.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
|
{relativeTime(event.ts_unix * 1000)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Activity className="size-8 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No recent activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+355
-457
@@ -1,9 +1,25 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { runCommand, type Agent } from "@/api";
|
import {
|
||||||
|
fetchFleetDevices,
|
||||||
|
fetchKnownDevices,
|
||||||
|
refreshFleetDevices,
|
||||||
|
wakeFleetDevice,
|
||||||
|
type Agent,
|
||||||
|
type FleetDevice,
|
||||||
|
type KnownDevice,
|
||||||
|
} from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,26 +27,24 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { DeviceDetailsDialog } from "@/pages/devices/DeviceDetailsDialog";
|
|
||||||
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
|
|
||||||
import type {
|
|
||||||
DeviceRow,
|
|
||||||
PresenceFilter,
|
|
||||||
SortDir,
|
|
||||||
WakeEvent,
|
|
||||||
} from "@/pages/devices/types";
|
|
||||||
import {
|
import {
|
||||||
chooseWakeTarget,
|
FilterSelect,
|
||||||
loadHistory,
|
MobileLabel,
|
||||||
parseInventoryRows,
|
PresenceBadge,
|
||||||
parseWakeSummary,
|
} from "@/pages/fleet/FleetComponents";
|
||||||
PRESENCE_FILTERS,
|
import { FleetDeviceDetailsDialog } from "@/pages/fleet/FleetDeviceDetailsDialog";
|
||||||
saveHistory,
|
import {
|
||||||
sorters,
|
agentLabel,
|
||||||
|
formatSeen,
|
||||||
|
knownFilters,
|
||||||
|
presenceFilters,
|
||||||
|
presenceRank,
|
||||||
|
summarize,
|
||||||
|
type KnownFilter,
|
||||||
|
type SortDir,
|
||||||
type SortKey,
|
type SortKey,
|
||||||
} from "@/pages/devices/utils";
|
} from "@/pages/fleet/utils";
|
||||||
import "@/pages/devices/devices.css";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@@ -39,501 +53,385 @@ type Props = {
|
|||||||
onAfterWake?: () => Promise<void>;
|
onAfterWake?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DevicesPage({
|
export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||||
agents,
|
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||||
selectedAgentId,
|
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||||
onSelectAgent,
|
|
||||||
onAfterWake,
|
|
||||||
}: Props) {
|
|
||||||
function displayAgentLabel(agent: Agent): string {
|
|
||||||
const nickname = agent.nickname?.trim();
|
|
||||||
return nickname ? nickname : agent.agent_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedAgent = agents.find(
|
|
||||||
(agent) => agent.agent_id === selectedAgentId,
|
|
||||||
);
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Parse sort state from URL
|
|
||||||
function getSortFromUrl(): { key: SortKey; dir: SortDir } {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const key = params.get("sort") as SortKey;
|
|
||||||
const dir = params.get("dir") as SortDir;
|
|
||||||
if (key && dir && key in sorters && (dir === "asc" || dir === "desc")) {
|
|
||||||
return { key, dir };
|
|
||||||
}
|
|
||||||
return { key: "name", dir: "asc" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPresenceFromUrl(): PresenceFilter {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const raw = params.get("presence");
|
|
||||||
return PRESENCE_FILTERS.includes(raw as PresenceFilter)
|
|
||||||
? (raw as PresenceFilter)
|
|
||||||
: "all";
|
|
||||||
}
|
|
||||||
|
|
||||||
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(
|
|
||||||
getSortFromUrl(),
|
|
||||||
);
|
|
||||||
const [presenceFilter, setPresenceFilter] =
|
|
||||||
useState<PresenceFilter>(getPresenceFromUrl());
|
|
||||||
|
|
||||||
// Keep view state in sync with URL
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
params.set("sort", sort.key);
|
|
||||||
params.set("dir", sort.dir);
|
|
||||||
if (presenceFilter === "all") {
|
|
||||||
params.delete("presence");
|
|
||||||
} else {
|
|
||||||
params.set("presence", presenceFilter);
|
|
||||||
}
|
|
||||||
const nextSearch = params.toString();
|
|
||||||
if (nextSearch !== location.search.slice(1)) {
|
|
||||||
navigate({ search: nextSearch }, { replace: true });
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [sort.key, sort.dir, presenceFilter, location.search]);
|
|
||||||
|
|
||||||
// Update state if URL changes (e.g., back/forward nav)
|
|
||||||
useEffect(() => {
|
|
||||||
setSort(getSortFromUrl());
|
|
||||||
setPresenceFilter(getPresenceFromUrl());
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [location.search]);
|
|
||||||
|
|
||||||
const [rows, setRows] = useState<DeviceRow[]>([]);
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [presence, setPresence] = useState("all");
|
||||||
|
const [known, setKnown] = useState<KnownFilter>("all");
|
||||||
|
const [agentId, setAgentId] = useState("all");
|
||||||
|
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({
|
||||||
|
key: "last_seen",
|
||||||
|
dir: "desc",
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [wakeBusyId, setWakeBusyId] = useState("");
|
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||||
const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
|
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||||
const [quickWake, setQuickWake] = useState("");
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
||||||
const [detailsDevice, setDetailsDevice] = useState<DeviceRow | null>(null);
|
|
||||||
const [copyStatus, setCopyStatus] = useState("");
|
|
||||||
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
|
||||||
function appendWakeEvent(event: WakeEvent) {
|
|
||||||
setRecentWakes((prev) => {
|
|
||||||
const next = [event, ...prev].slice(0, 20);
|
|
||||||
saveHistory(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadFleet() {
|
||||||
setRecentWakes(loadHistory());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedAgentId) {
|
|
||||||
const firstConnected = agents.find((agent) => agent.connected);
|
|
||||||
if (firstConnected) onSelectAgent(firstConnected.agent_id);
|
|
||||||
}
|
|
||||||
}, [selectedAgentId, agents, onSelectAgent]);
|
|
||||||
|
|
||||||
async function loadInventory() {
|
|
||||||
if (!selectedAgentId) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const response = await runCommand(selectedAgentId, "inventory", "");
|
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||||
if (!response || typeof response !== "object") {
|
fetchFleetDevices({
|
||||||
setRows([]);
|
query,
|
||||||
} else {
|
presence,
|
||||||
const envelope = response as Record<string, unknown>;
|
known,
|
||||||
setRows(parseInventoryRows(envelope.result));
|
agentId: agentId === "all" ? "" : agentId,
|
||||||
}
|
limit: 500,
|
||||||
|
}),
|
||||||
|
fetchKnownDevices(),
|
||||||
|
]);
|
||||||
|
setDevices(nextDevices);
|
||||||
|
setKnownDevices(nextKnownDevices);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to load fleet", { description: String(err) });
|
||||||
setRows([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wakeDevice(device: DeviceRow) {
|
async function refreshAllConnected() {
|
||||||
const target = chooseWakeTarget(device);
|
setRefreshing(true);
|
||||||
await wakeTarget(target, device.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wakeTarget(
|
|
||||||
target: string,
|
|
||||||
busyId: string,
|
|
||||||
opts: { refresh: boolean; notify: boolean } = {
|
|
||||||
refresh: true,
|
|
||||||
notify: true,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!target || !selectedAgentId) return;
|
|
||||||
setWakeBusyId(busyId);
|
|
||||||
try {
|
try {
|
||||||
const response = await runCommand(selectedAgentId, "wake", target);
|
const result = await refreshFleetDevices();
|
||||||
const summary = parseWakeSummary(response);
|
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||||
appendWakeEvent({
|
const msg = failed.length
|
||||||
ts: Date.now(),
|
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||||
target,
|
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
||||||
agentId: selectedAgentId,
|
toast.success(msg);
|
||||||
outcome: summary.outcome,
|
await loadFleet();
|
||||||
requestId: summary.requestId,
|
|
||||||
detail: summary.detail,
|
|
||||||
});
|
|
||||||
if (opts.refresh) await loadInventory();
|
|
||||||
if (opts.notify && onAfterWake) await onAfterWake();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendWakeEvent({
|
toast.error("Fleet refresh failed", { description: String(err) });
|
||||||
ts: Date.now(),
|
|
||||||
target,
|
|
||||||
agentId: selectedAgentId,
|
|
||||||
outcome: "error",
|
|
||||||
requestId: "",
|
|
||||||
detail: String(err),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setWakeBusyId("");
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wakeSelectedDevices() {
|
async function wakeDevice(device: FleetDevice, routeId?: string | null) {
|
||||||
if (!selectedAgentId || bulkWakeBusy) return;
|
if (!device.recommended_route && !routeId) return;
|
||||||
const selectedRows = filtered.filter((row) => selectedIds.includes(row.id));
|
setWakeBusyKey(device.device_key);
|
||||||
if (!selectedRows.length) return;
|
|
||||||
|
|
||||||
setBulkWakeBusy(true);
|
|
||||||
try {
|
try {
|
||||||
for (const row of selectedRows) {
|
const result = await wakeFleetDevice({
|
||||||
const target = chooseWakeTarget(row);
|
deviceKey: device.device_key,
|
||||||
if (!target) continue;
|
routeId,
|
||||||
await wakeTarget(target, `bulk:${row.id}`, {
|
});
|
||||||
refresh: false,
|
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
||||||
notify: false,
|
toast.success(msg);
|
||||||
});
|
|
||||||
}
|
|
||||||
await loadInventory();
|
|
||||||
if (onAfterWake) await onAfterWake();
|
if (onAfterWake) await onAfterWake();
|
||||||
|
await loadFleet();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Wake failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBulkWakeBusy(false);
|
setWakeBusyKey("");
|
||||||
setWakeBusyId("");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyValue(label: string, value: string) {
|
async function copyValue(label: string, value: string) {
|
||||||
const trimmed = value.trim();
|
const text = value.trim();
|
||||||
if (!trimmed) return;
|
if (!text) return;
|
||||||
|
|
||||||
async function copyWithFallback(text: string) {
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute("readonly", "");
|
|
||||||
textarea.style.position = "fixed";
|
|
||||||
textarea.style.top = "0";
|
|
||||||
textarea.style.left = "0";
|
|
||||||
textarea.style.opacity = "0";
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
const ok = document.execCommand("copy");
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
if (!ok) throw new Error("fallback-copy-failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
await navigator.clipboard.writeText(text);
|
||||||
await navigator.clipboard.writeText(trimmed);
|
toast.success(`Copied ${label}`);
|
||||||
} else {
|
|
||||||
await copyWithFallback(trimmed);
|
|
||||||
}
|
|
||||||
setCopyStatus(`Copied ${label}`);
|
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
const area = document.createElement("textarea");
|
||||||
await copyWithFallback(trimmed);
|
area.value = text;
|
||||||
setCopyStatus(`Copied ${label}`);
|
area.style.position = "fixed";
|
||||||
} catch {
|
area.style.opacity = "0";
|
||||||
setCopyStatus(`Copy failed for ${label}`);
|
document.body.appendChild(area);
|
||||||
}
|
area.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(area);
|
||||||
|
toast.success(`Copied ${label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadInventory();
|
void loadFleet();
|
||||||
}, [selectedAgentId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [presence, known, agentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIds((prev) =>
|
const timer = window.setTimeout(() => void loadFleet(), 220);
|
||||||
prev.filter((id) => rows.some((row) => row.id === id)),
|
|
||||||
);
|
|
||||||
}, [rows]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!copyStatus) return;
|
|
||||||
const timer = window.setTimeout(() => setCopyStatus(""), 1800);
|
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [copyStatus]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const sortedDevices = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const rows = [...devices];
|
||||||
let result = rows;
|
rows.sort((a, b) => {
|
||||||
if (presenceFilter !== "all") {
|
const dir = sort.dir === "asc" ? 1 : -1;
|
||||||
result = result.filter((row) => row.presence === presenceFilter);
|
if (sort.key === "presence") {
|
||||||
}
|
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
|
||||||
if (q) {
|
}
|
||||||
result = result.filter(
|
if (sort.key === "last_seen") {
|
||||||
(row) =>
|
return dir * ((a.last_seen_unix ?? 0) - (b.last_seen_unix ?? 0));
|
||||||
row.name.toLowerCase().includes(q) ||
|
}
|
||||||
row.presence.toLowerCase().includes(q) ||
|
if (sort.key === "agents")
|
||||||
row.ips.some((v) => v.toLowerCase().includes(q)) ||
|
return dir * (a.agents.length - b.agents.length);
|
||||||
row.macs.some((v) => v.toLowerCase().includes(q)) ||
|
if (sort.key === "ip") {
|
||||||
row.interfaces.some((v) => v.toLowerCase().includes(q)),
|
return dir * (a.ips[0] ?? "").localeCompare(b.ips[0] ?? "");
|
||||||
);
|
}
|
||||||
}
|
if (sort.key === "mac") {
|
||||||
// Sort
|
return dir * (a.macs[0] ?? "").localeCompare(b.macs[0] ?? "");
|
||||||
if (sort.key === "name") {
|
}
|
||||||
result = [...result].sort((a, b) => {
|
return dir * a.display_name.localeCompare(b.display_name);
|
||||||
if (a.isUnnamed !== b.isUnnamed) return a.isUnnamed ? 1 : -1;
|
|
||||||
const cmp = a.name.localeCompare(b.name);
|
|
||||||
return sort.dir === "desc" ? -cmp : cmp;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const sorter = sorters[sort.key];
|
|
||||||
result = [...result].sort(sorter);
|
|
||||||
if (sort.dir === "desc") result.reverse();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [rows, query, sort, presenceFilter]);
|
|
||||||
|
|
||||||
const selectedVisibleCount = filtered.filter((row) =>
|
|
||||||
selectedIds.includes(row.id),
|
|
||||||
).length;
|
|
||||||
const allVisibleSelected =
|
|
||||||
filtered.length > 0 && selectedVisibleCount === filtered.length;
|
|
||||||
|
|
||||||
function toggleRowSelection(id: string, checked: boolean) {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
if (checked) return prev.includes(id) ? prev : [...prev, id];
|
|
||||||
return prev.filter((v) => v !== id);
|
|
||||||
});
|
});
|
||||||
}
|
return rows;
|
||||||
|
}, [devices, sort]);
|
||||||
|
|
||||||
function toggleAllVisible(checked: boolean) {
|
const connectedCount = agents.filter((agent) => agent.connected).length;
|
||||||
if (!checked) {
|
|
||||||
setSelectedIds((prev) =>
|
|
||||||
prev.filter((id) => !filtered.some((row) => row.id === id)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
for (const row of filtered) next.add(row.id);
|
|
||||||
return Array.from(next);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(20rem,1fr)]">
|
<section className="grid gap-3">
|
||||||
<Card className="min-w-0">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
<CardHeader className="gap-3">
|
||||||
<CardTitle>Devices</CardTitle>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Button
|
<div>
|
||||||
variant="outline"
|
<CardTitle>Devices</CardTitle>
|
||||||
size="sm"
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
onClick={() => void loadInventory()}
|
{devices.length} fleet rows from stored observations and known
|
||||||
disabled={loading || !selectedAgentId}
|
devices
|
||||||
>
|
</p>
|
||||||
{loading ? "Refreshing..." : "Refresh"}
|
</div>
|
||||||
</Button>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadFleet()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
{loading ? "Refreshing" : "Refresh view"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void refreshAllConnected()}
|
||||||
|
disabled={refreshing || connectedCount === 0}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
{refreshing
|
||||||
|
? "Collecting"
|
||||||
|
: `Collect fleet (${connectedCount})`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
<CardContent>
|
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
||||||
<div className="mt-2 grid gap-2 md:grid-cols-3">
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<label className="grid min-w-0 gap-1 text-sm text-muted-foreground">
|
<span>Search</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-8"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="name, ip, mac, agent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<FilterSelect
|
||||||
|
label="Presence"
|
||||||
|
value={presence}
|
||||||
|
values={presenceFilters}
|
||||||
|
onChange={setPresence}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label="Identity"
|
||||||
|
value={known}
|
||||||
|
values={knownFilters}
|
||||||
|
onChange={(value) => setKnown(value as KnownFilter)}
|
||||||
|
/>
|
||||||
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<Select
|
<Select
|
||||||
value={selectedAgentId}
|
value={agentId}
|
||||||
onValueChange={(value: string | null) => {
|
onValueChange={(value) => setAgentId(value ?? "all")}
|
||||||
if (value) onSelectAgent(value);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full min-w-0">
|
<SelectTrigger>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-start">
|
<span>
|
||||||
{selectedAgent ? (
|
{agentId === "all"
|
||||||
<span
|
? "all agents"
|
||||||
className={`size-2 shrink-0 rounded-full ${selectedAgent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
: agentLabel(
|
||||||
aria-hidden
|
agentId,
|
||||||
/>
|
agents.find((agent) => agent.agent_id === agentId)
|
||||||
) : null}
|
?.nickname,
|
||||||
<span className="min-w-0 truncate">
|
)}
|
||||||
{selectedAgentId
|
|
||||||
? selectedAgent
|
|
||||||
? displayAgentLabel(selectedAgent)
|
|
||||||
: selectedAgentId
|
|
||||||
: "Select connected agent"}
|
|
||||||
</span>
|
|
||||||
{selectedAgent ? (
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{selectedAgent.connected ? "connected" : "offline"}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
className="max-w-[min(92vw,30rem)]"
|
<SelectItem value="all">all agents</SelectItem>
|
||||||
alignItemWithTrigger={false}
|
|
||||||
>
|
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<SelectItem
|
<SelectItem key={agent.agent_id} value={agent.agent_id}>
|
||||||
key={agent.agent_id}
|
{agentLabel(agent.agent_id, agent.nickname)}
|
||||||
value={agent.agent_id}
|
{agent.connected ? " · connected" : " · offline"}
|
||||||
className="pe-10"
|
|
||||||
disabled={!agent.connected}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{displayAgentLabel(agent)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{agent.connected ? "connected" : "offline"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
<FilterSelect
|
||||||
<label className="grid min-w-0 gap-1 text-sm text-muted-foreground md:col-span-2">
|
label="Sort"
|
||||||
<span>Search</span>
|
value={`${sort.key}:${sort.dir}`}
|
||||||
<Input
|
values={[
|
||||||
className="w-full min-w-0"
|
"last_seen:desc",
|
||||||
value={query}
|
"name:asc",
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
"presence:desc",
|
||||||
placeholder="name, ip, mac, interface"
|
"agents:desc",
|
||||||
/>
|
"ip:asc",
|
||||||
</label>
|
"mac:asc",
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
const [key, dir] = value.split(":") as [SortKey, SortDir];
|
||||||
|
setSort({ key, dir });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="quick-wake">
|
{/* Skeleton loading */}
|
||||||
<label>
|
{loading && !devices.length ? (
|
||||||
Quick Wake (name, IP, or MAC)
|
<div className="grid gap-2">
|
||||||
<Input
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
value={quickWake}
|
<Skeleton key={i} className="h-14 w-full rounded-md" />
|
||||||
onChange={(e) => setQuickWake(e.target.value)}
|
))}
|
||||||
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
|
</div>
|
||||||
/>
|
) : (
|
||||||
</label>
|
<div className="grid gap-2">
|
||||||
<Button
|
<div className="hidden grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm font-medium xl:grid">
|
||||||
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
|
<span>Device</span>
|
||||||
disabled={
|
<span>Presence</span>
|
||||||
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
|
<span>IPs</span>
|
||||||
}
|
<span>MACs</span>
|
||||||
>
|
<span>Agents</span>
|
||||||
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
|
<span>Last seen</span>
|
||||||
</Button>
|
<span className="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
|
{sortedDevices.map((device) => (
|
||||||
<div
|
<FleetDeviceRow
|
||||||
className="presence-filters"
|
key={device.device_key}
|
||||||
role="group"
|
device={device}
|
||||||
aria-label="Filter by presence"
|
busy={wakeBusyKey === device.device_key}
|
||||||
>
|
onWake={() => void wakeDevice(device)}
|
||||||
{PRESENCE_FILTERS.map((value) => (
|
onDetails={() => setDetails(device)}
|
||||||
<Button
|
onCopy={(label, value) => void copyValue(label, value)}
|
||||||
key={value}
|
/>
|
||||||
variant={presenceFilter === value ? "secondary" : "outline"}
|
))}
|
||||||
size="xs"
|
{!sortedDevices.length && (
|
||||||
onClick={() => setPresenceFilter(value)}
|
<div className="flex flex-col items-center justify-center rounded-md border bg-card py-12 text-center">
|
||||||
type="button"
|
<Inbox className="size-10 text-muted-foreground/30" />
|
||||||
>
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
{value === "all" ? "all" : value.replace("_", " ")}
|
No devices match the current filters
|
||||||
</Button>
|
</p>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
<div className="bulk-actions">
|
|
||||||
<label className="select-visible">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allVisibleSelected}
|
|
||||||
onChange={(e) => toggleAllVisible(e.target.checked)}
|
|
||||||
disabled={!filtered.length}
|
|
||||||
/>
|
|
||||||
Select visible
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
onClick={() => void wakeSelectedDevices()}
|
|
||||||
disabled={
|
|
||||||
!selectedVisibleCount || !selectedAgentId || bulkWakeBusy
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{bulkWakeBusy
|
|
||||||
? "Waking selected..."
|
|
||||||
: `Wake selected (${selectedVisibleCount})`}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setSelectedIds([])}
|
|
||||||
disabled={!selectedIds.length}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</Button>
|
|
||||||
{copyStatus && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{copyStatus}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {filtered.length} of {rows.length}
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</pre>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DeviceInventoryTable
|
|
||||||
rows={filtered}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
sort={sort}
|
|
||||||
allVisibleSelected={allVisibleSelected}
|
|
||||||
wakeBusyId={wakeBusyId}
|
|
||||||
selectedAgentId={selectedAgentId}
|
|
||||||
bulkWakeBusy={bulkWakeBusy}
|
|
||||||
onToggleAllVisible={toggleAllVisible}
|
|
||||||
onToggleRow={toggleRowSelection}
|
|
||||||
onSortChange={setSort}
|
|
||||||
onWakeDevice={(device) => void wakeDevice(device)}
|
|
||||||
onCopyValue={(label, value) => void copyValue(label, value)}
|
|
||||||
onOpenDetails={setDetailsDevice}
|
|
||||||
/>
|
|
||||||
<DeviceDetailsDialog
|
|
||||||
device={detailsDevice}
|
|
||||||
open={Boolean(detailsDevice)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setDetailsDevice(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<WakeHistoryCard
|
<FleetDeviceDetailsDialog
|
||||||
events={recentWakes}
|
device={details}
|
||||||
onClear={() => {
|
knownDevices={knownDevices}
|
||||||
setRecentWakes([]);
|
open={Boolean(details)}
|
||||||
saveHistory([]);
|
busy={wakeBusyKey === details?.device_key}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDetails(null);
|
||||||
}}
|
}}
|
||||||
|
onWake={(routeId) => {
|
||||||
|
if (details) void wakeDevice(details, routeId);
|
||||||
|
}}
|
||||||
|
onCopy={(label, value) => void copyValue(label, value)}
|
||||||
|
onChanged={() => void loadFleet()}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FleetDeviceRow({
|
||||||
|
device,
|
||||||
|
busy,
|
||||||
|
onWake,
|
||||||
|
onDetails,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
device: FleetDevice;
|
||||||
|
busy: boolean;
|
||||||
|
onWake: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
onCopy: (label: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
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">
|
||||||
|
<button className="min-w-0 text-left" type="button" onClick={onDetails}>
|
||||||
|
<div className="truncate font-medium">{device.display_name}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{device.known_device ? (
|
||||||
|
<Badge variant="secondary">known</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">unknown</Badge>
|
||||||
|
)}
|
||||||
|
{device.pinned && <Badge variant="outline">pinned</Badge>}
|
||||||
|
{device.sources.map((source) => (
|
||||||
|
<Badge key={source} variant="outline">
|
||||||
|
{source}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<PresenceBadge presence={device.presence} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-muted-foreground xl:truncate">
|
||||||
|
<MobileLabel label="IPs" />
|
||||||
|
{summarize(device.ips)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-muted-foreground xl:truncate">
|
||||||
|
<MobileLabel label="MACs" />
|
||||||
|
{summarize(device.macs)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-muted-foreground xl:truncate">
|
||||||
|
<MobileLabel label="Agents" />
|
||||||
|
{summarize(
|
||||||
|
device.agents.map((agent) =>
|
||||||
|
agentLabel(agent.agent_id, agent.nickname),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<MobileLabel label="Last seen" />
|
||||||
|
{formatSeen(device.last_seen_unix)}
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onWake}
|
||||||
|
disabled={busy || !device.recommended_route}
|
||||||
|
>
|
||||||
|
<Zap className="size-4" aria-hidden />
|
||||||
|
{busy ? "Waking" : "Wake"}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="inline-flex size-9 items-center justify-center rounded-md border bg-background">
|
||||||
|
<MoreHorizontal className="size-4" aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuItem onClick={onDetails}>Details</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCopy("IP", device.ips[0] ?? "")}
|
||||||
|
data-disabled={!device.ips.length}
|
||||||
|
>
|
||||||
|
Copy first IP
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCopy("MAC", device.macs[0] ?? "")}
|
||||||
|
data-disabled={!device.macs.length}
|
||||||
|
>
|
||||||
|
Copy first MAC
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+170
-57
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Key, Copy, Inbox } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type EnrollTokenStatus,
|
type EnrollTokenStatus,
|
||||||
@@ -8,19 +10,36 @@ import {
|
|||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
function formatUnix(unix: number): string {
|
function formatUnix(unix: number): string {
|
||||||
return new Date(unix * 1000).toLocaleString();
|
return new Date(unix * 1000).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TTL_PRESETS = [
|
||||||
|
{ label: "1 hour", seconds: 3600 },
|
||||||
|
{ label: "12 hours", seconds: 43200 },
|
||||||
|
{ label: "1 day", seconds: 86400 },
|
||||||
|
{ label: "7 days", seconds: 604800 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function TokensPage() {
|
export function TokensPage() {
|
||||||
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||||
const [ttlSeconds, setTtlSeconds] = useState("86400");
|
const [ttlSeconds, setTtlSeconds] = useState(86400);
|
||||||
const [status, setStatus] = useState("");
|
const [customTtl, setCustomTtl] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [lastIssued, setLastIssued] = useState<{
|
||||||
|
token: string;
|
||||||
|
expires: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const activeCount = useMemo(
|
const activeCount = useMemo(
|
||||||
() => tokens.filter((t) => !t.expired).length,
|
() => tokens.filter((t) => !t.expired).length,
|
||||||
@@ -29,12 +48,11 @@ export function TokensPage() {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const next = await fetchEnrollTokens();
|
const next = await fetchEnrollTokens();
|
||||||
setTokens(next);
|
setTokens(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to load tokens", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -42,17 +60,23 @@ export function TokensPage() {
|
|||||||
|
|
||||||
async function onIssue() {
|
async function onIssue() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
|
||||||
setStatus("");
|
|
||||||
try {
|
try {
|
||||||
const ttl = Number.parseInt(ttlSeconds, 10);
|
const effectiveTtl = useCustom
|
||||||
const issued = await issueEnrollToken(Number.isFinite(ttl) ? ttl : 86400);
|
? Number.parseInt(customTtl, 10) || 86400
|
||||||
setStatus(
|
: ttlSeconds;
|
||||||
`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`,
|
const issued = await issueEnrollToken(
|
||||||
|
Number.isFinite(effectiveTtl) ? effectiveTtl : 86400,
|
||||||
);
|
);
|
||||||
|
setLastIssued({
|
||||||
|
token: issued.enroll_token,
|
||||||
|
expires: formatUnix(issued.expires_at_unix),
|
||||||
|
});
|
||||||
|
toast.success("Token issued", {
|
||||||
|
description: `Expires ${formatUnix(issued.expires_at_unix)}`,
|
||||||
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to issue token", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -60,30 +84,50 @@ export function TokensPage() {
|
|||||||
|
|
||||||
async function onRevoke(token: string) {
|
async function onRevoke(token: string) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
|
||||||
setStatus("");
|
|
||||||
try {
|
try {
|
||||||
const result = await revokeEnrollToken(token);
|
const result = await revokeEnrollToken(token);
|
||||||
setStatus(
|
if (result.revoked) {
|
||||||
result.revoked ? `Revoked ${token}` : `${token} was already absent`,
|
toast.success(`Revoked ${token}`);
|
||||||
);
|
} else {
|
||||||
|
toast.info(`${token} was already absent`);
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to revoke token", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
} catch {
|
||||||
|
toast.error("Copy failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const controlPlaneUrl =
|
||||||
|
window.location.origin.replace(/:\d+$/, "") || "https://cp.example.com";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3 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 space-y-0">
|
||||||
<CardTitle>Enroll Tokens</CardTitle>
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Key className="size-5 text-primary" />
|
||||||
|
Enroll Tokens
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{activeCount} active, {tokens.length} shown
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -94,17 +138,16 @@ export function TokensPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{activeCount} active, {tokens.length} shown
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{tokens.map((token) => (
|
{tokens.map((token) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||||
key={token.enroll_token}
|
key={token.enroll_token}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<strong>{token.enroll_token}</strong>
|
<strong className="block truncate text-sm">
|
||||||
|
{token.enroll_token}
|
||||||
|
</strong>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
expires: {formatUnix(token.expires_at_unix)}
|
expires: {formatUnix(token.expires_at_unix)}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,43 +168,113 @@ export function TokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!tokens.length && (
|
{!tokens.length && (
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
No tokens found
|
<Inbox className="size-8 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No tokens found
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-4 content-start">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Issue Token</CardTitle>
|
<CardHeader>
|
||||||
</CardHeader>
|
<CardTitle>Issue Token</CardTitle>
|
||||||
<CardContent className="grid gap-3">
|
<CardDescription>
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
Create a new enrollment token for agents
|
||||||
<span>TTL seconds</span>
|
</CardDescription>
|
||||||
<Input
|
</CardHeader>
|
||||||
type="number"
|
<CardContent className="grid gap-4">
|
||||||
min={1}
|
<div>
|
||||||
value={ttlSeconds}
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
onChange={(e) => setTtlSeconds(e.target.value)}
|
Token lifetime
|
||||||
/>
|
</p>
|
||||||
</label>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
{TTL_PRESETS.map((preset) => (
|
||||||
Issue
|
<Button
|
||||||
</Button>
|
key={preset.seconds}
|
||||||
{status && (
|
variant={
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
!useCustom && ttlSeconds === preset.seconds
|
||||||
{status}
|
? "secondary"
|
||||||
</pre>
|
: "outline"
|
||||||
)}
|
}
|
||||||
{error && (
|
size="sm"
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
onClick={() => {
|
||||||
{error}
|
setTtlSeconds(preset.seconds);
|
||||||
</pre>
|
setUseCustom(false);
|
||||||
)}
|
}}
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant={useCustom ? "secondary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setUseCustom(true)}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{useCustom && (
|
||||||
|
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>TTL (seconds)</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={customTtl}
|
||||||
|
onChange={(e) => setCustomTtl(e.target.value)}
|
||||||
|
placeholder="86400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||||
|
Issue Token
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Install command after issuing */}
|
||||||
|
{lastIssued && (
|
||||||
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Agent Installation</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Token: <code className="text-xs">{lastIssued.token}</code>
|
||||||
|
<br />
|
||||||
|
Expires: {lastIssued.expires}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Run this on the target device to enroll:
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="overflow-auto rounded-md border bg-muted/60 p-3 pr-10 font-mono text-xs">
|
||||||
|
{`wakey-agent enroll \\
|
||||||
|
--server-url ${controlPlaneUrl} \\
|
||||||
|
--token ${lastIssued.token}`}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1.5 top-1.5 size-7 p-0"
|
||||||
|
onClick={() =>
|
||||||
|
void copyToClipboard(
|
||||||
|
`wakey-agent enroll --server-url ${controlPlaneUrl} --token ${lastIssued.token}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Zap, Inbox } from "lucide-react";
|
||||||
|
|
||||||
|
import { runCommand, type Agent } from "@/api";
|
||||||
|
import { AgentSelector } from "@/components/AgentSelector";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type WakeEvent = {
|
||||||
|
ts: number;
|
||||||
|
target: string;
|
||||||
|
agentId: string;
|
||||||
|
outcome: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelectAgent: (agentId: string) => void;
|
||||||
|
onAfterWake?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WakeToolsPage({
|
||||||
|
agents,
|
||||||
|
selectedAgentId,
|
||||||
|
onSelectAgent,
|
||||||
|
onAfterWake,
|
||||||
|
}: Props) {
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
||||||
|
|
||||||
|
async function handleWake(e?: React.FormEvent) {
|
||||||
|
e?.preventDefault();
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed || !selectedAgentId || busy) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await runCommand(selectedAgentId, "wake", trimmed);
|
||||||
|
const detail = JSON.stringify(response);
|
||||||
|
const ok = detail.includes('"ok"') || detail.includes('"success"');
|
||||||
|
setRecentWakes((prev) =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ts: Date.now(),
|
||||||
|
target: trimmed,
|
||||||
|
agentId: selectedAgentId,
|
||||||
|
outcome: ok ? "ok" : "fail",
|
||||||
|
detail,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
].slice(0, 20),
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
toast.success(`Woke ${trimmed}`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Wake failed: ${trimmed}`, { description: detail });
|
||||||
|
}
|
||||||
|
if (onAfterWake) await onAfterWake();
|
||||||
|
setTarget("");
|
||||||
|
} catch (err) {
|
||||||
|
setRecentWakes((prev) =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ts: Date.now(),
|
||||||
|
target: trimmed,
|
||||||
|
agentId: selectedAgentId,
|
||||||
|
outcome: "error",
|
||||||
|
detail: String(err),
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
].slice(0, 20),
|
||||||
|
);
|
||||||
|
toast.error("Wake failed", { description: String(err) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,1fr)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
|
Wake Target
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Send a Wake-on-LAN packet by hostname, IP, or MAC address
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<span>Agent</span>
|
||||||
|
<AgentSelector
|
||||||
|
agents={agents}
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={onSelectAgent}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<form onSubmit={handleWake} className="grid gap-3">
|
||||||
|
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<span>Target</span>
|
||||||
|
<Input
|
||||||
|
value={target}
|
||||||
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
|
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedAgentId || !target.trim() || busy}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Zap className="size-4" />
|
||||||
|
{busy ? "Sending..." : "Wake"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle>Recent Wakes</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRecentWakes([])}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentWakes.length > 0 ? (
|
||||||
|
<div className="grid gap-0">
|
||||||
|
{recentWakes.map((event, idx) => (
|
||||||
|
<div
|
||||||
|
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||||
|
key={`${event.ts}-${idx}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{event.target}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
event.outcome === "ok" ? "secondary" : "destructive"
|
||||||
|
}
|
||||||
|
className="text-[0.6rem] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{event.outcome}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.65rem] text-muted-foreground/70">
|
||||||
|
{new Date(event.ts).toLocaleString()} via {event.agentId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Inbox className="size-8 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
No wake actions yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,196 +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">{row.presence}</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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export function PresenceBadge({ presence }: { presence: string }) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={`presence-badge--${presence}`}>
|
||||||
|
<span className={`presence-dot presence-dot--${presence}`} aria-hidden />
|
||||||
|
{presence.replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileLabel({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="mb-1 block text-xs font-medium text-muted-foreground xl:hidden">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
values: readonly string[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>{label}</span>
|
||||||
|
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<span>{value.replace("_", " ")}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
|
{values.map((item) => (
|
||||||
|
<SelectItem key={item} value={item}>
|
||||||
|
{item.replace("_", " ")}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailBlock({
|
||||||
|
label,
|
||||||
|
values,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
values: string[];
|
||||||
|
onCopy: (label: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{values.length ? (
|
||||||
|
values.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 items-center justify-between gap-2 rounded px-1 py-0.5 text-left hover:bg-accent"
|
||||||
|
onClick={() => onCopy(label, value)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{value}</span>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="size-3.5 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachDeviceIdentifier,
|
||||||
|
createKnownDevice,
|
||||||
|
mergeKnownDevice,
|
||||||
|
type FleetDevice,
|
||||||
|
type KnownDevice,
|
||||||
|
} from "@/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
||||||
|
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
device: FleetDevice | null;
|
||||||
|
knownDevices: KnownDevice[];
|
||||||
|
open: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onWake: (routeId?: string | null) => void;
|
||||||
|
onCopy: (label: string, value: string) => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FleetDeviceDetailsDialog({
|
||||||
|
device,
|
||||||
|
knownDevices,
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
onOpenChange,
|
||||||
|
onWake,
|
||||||
|
onCopy,
|
||||||
|
onChanged,
|
||||||
|
}: Props) {
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||||
|
const [routeId, setRouteId] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(device?.display_name ?? "");
|
||||||
|
setTargetDeviceId("");
|
||||||
|
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||||
|
setError("");
|
||||||
|
}, [device]);
|
||||||
|
|
||||||
|
if (!device) return null;
|
||||||
|
|
||||||
|
const currentDevice = device;
|
||||||
|
const identifiers = identifiersFor(currentDevice);
|
||||||
|
const otherKnownDevices = knownDevices.filter(
|
||||||
|
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createRemembered() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await createKnownDevice({
|
||||||
|
display_name: displayName.trim() || currentDevice.display_name,
|
||||||
|
pinned: true,
|
||||||
|
identifiers,
|
||||||
|
});
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachToExisting() {
|
||||||
|
if (!targetDeviceId) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
for (const identifier of identifiers) {
|
||||||
|
await attachDeviceIdentifier(targetDeviceId, identifier);
|
||||||
|
}
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeIntoExisting() {
|
||||||
|
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await mergeKnownDevice(
|
||||||
|
targetDeviceId,
|
||||||
|
currentDevice.known_device.device_id,
|
||||||
|
);
|
||||||
|
onChanged();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRoute =
|
||||||
|
device.route_candidates.find((route) => route.route_id === routeId) ??
|
||||||
|
device.recommended_route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<DialogTitle className="truncate">
|
||||||
|
{device.display_name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{device.known_device
|
||||||
|
? "Remembered fleet device"
|
||||||
|
: "Observed fleet device"}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<PresenceBadge presence={device.presence} />
|
||||||
|
</div>
|
||||||
|
</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="grid gap-3 md:grid-cols-2">
|
||||||
|
<DetailBlock label="IPs" values={device.ips} onCopy={onCopy} />
|
||||||
|
<DetailBlock label="MACs" values={device.macs} onCopy={onCopy} />
|
||||||
|
<DetailBlock
|
||||||
|
label="Hostnames"
|
||||||
|
values={device.hostnames}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
|
<DetailBlock
|
||||||
|
label="Agents"
|
||||||
|
values={device.agents.map((agent) =>
|
||||||
|
agentLabel(agent.agent_id, agent.nickname),
|
||||||
|
)}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Wake route</h3>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onWake(selectedRoute?.route_id ?? null)}
|
||||||
|
disabled={busy || !selectedRoute?.wakeable}
|
||||||
|
>
|
||||||
|
<Zap className="size-4" aria-hidden />
|
||||||
|
{busy ? "Waking" : "Wake"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={routeId}
|
||||||
|
onValueChange={(value) => setRouteId(value ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<span>
|
||||||
|
{selectedRoute
|
||||||
|
? routeLabel(selectedRoute)
|
||||||
|
: "No connected MAC-backed route"}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
|
{device.route_candidates.map((route) => (
|
||||||
|
<SelectItem key={route.route_id} value={route.route_id}>
|
||||||
|
{routeLabel(route)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h3 className="text-sm font-medium">Remember / merge</h3>
|
||||||
|
{!device.known_device && (
|
||||||
|
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<Input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
placeholder="Device name"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => void createRemembered()}
|
||||||
|
disabled={!identifiers.length}
|
||||||
|
>
|
||||||
|
Remember device
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||||
|
<Select
|
||||||
|
value={targetDeviceId}
|
||||||
|
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<span>
|
||||||
|
{targetDeviceId
|
||||||
|
? knownDevices.find(
|
||||||
|
(known) => known.device_id === targetDeviceId,
|
||||||
|
)?.display_name
|
||||||
|
: "Select known device"}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,30rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{otherKnownDevices.map((known) => (
|
||||||
|
<SelectItem key={known.device_id} value={known.device_id}>
|
||||||
|
{known.display_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void attachToExisting()}
|
||||||
|
disabled={!targetDeviceId || !identifiers.length}
|
||||||
|
>
|
||||||
|
Attach IDs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void mergeIntoExisting()}
|
||||||
|
disabled={!targetDeviceId || !device.known_device}
|
||||||
|
>
|
||||||
|
Merge
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Proposed identifiers:{" "}
|
||||||
|
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
||||||
|
"-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { FleetDevice, FleetWakeRoute } from "@/api";
|
||||||
|
|
||||||
|
export type SortKey =
|
||||||
|
| "name"
|
||||||
|
| "presence"
|
||||||
|
| "last_seen"
|
||||||
|
| "agents"
|
||||||
|
| "ip"
|
||||||
|
| "mac";
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
export type KnownFilter = "all" | "known" | "unknown";
|
||||||
|
|
||||||
|
export const presenceFilters = [
|
||||||
|
"all",
|
||||||
|
"online",
|
||||||
|
"likely_online",
|
||||||
|
"unknown",
|
||||||
|
"offline",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||||
|
|
||||||
|
export function summarize(values: string[]): string {
|
||||||
|
if (!values.length) return "-";
|
||||||
|
if (values.length <= 2) return values.join(", ");
|
||||||
|
return `${values[0]}, ${values[1]} (+${values.length - 2})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSeen(value: number | null): string {
|
||||||
|
if (!value) return "-";
|
||||||
|
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 48) return `${hours}h ago`;
|
||||||
|
return new Date(value * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presenceRank(presence: string): number {
|
||||||
|
if (presence === "online") return 4;
|
||||||
|
if (presence === "likely_online") return 3;
|
||||||
|
if (presence === "unknown") return 2;
|
||||||
|
if (presence === "offline") return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifiersFor(
|
||||||
|
device: FleetDevice,
|
||||||
|
): { kind: string; value: string }[] {
|
||||||
|
return [
|
||||||
|
...device.macs.map((value) => ({ kind: "mac", value })),
|
||||||
|
...device.ips.map((value) => ({ kind: "ip", value })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentLabel(agentId: string, nickname?: string | null): string {
|
||||||
|
const trimmed = nickname?.trim();
|
||||||
|
return trimmed ? trimmed : agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeLabel(route: FleetWakeRoute): string {
|
||||||
|
const status = route.connected ? "connected" : "offline";
|
||||||
|
const target = route.mac
|
||||||
|
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||||
|
: (route.ip ?? "-");
|
||||||
|
return `${agentLabel(route.agent_id, route.nickname)} · ${target} · ${route.source} · ${status}`;
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export function ObservationHistoryDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
<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">
|
<div className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
<span>Time</span>
|
<span>Time</span>
|
||||||
<span>State</span>
|
<span>State</span>
|
||||||
<span>Source</span>
|
<span>Source</span>
|
||||||
@@ -80,7 +80,7 @@ export function ObservationHistoryDialog({
|
|||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.event_id}
|
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"
|
className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
{formatSeen(event.ts_unix)}
|
{formatSeen(event.ts_unix)}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function ObservationTable({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 overflow-x-auto">
|
<div className="grid gap-2 overflow-x-auto">
|
||||||
<div className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
<div className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||||
<span>Device</span>
|
<span>Device</span>
|
||||||
<span>Network</span>
|
<span>Network</span>
|
||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
@@ -49,7 +49,7 @@ export function ObservationTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={observation.observation_key}
|
key={observation.observation_key}
|
||||||
className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-medium">
|
<div className="truncate font-medium">
|
||||||
|
|||||||
+272
-66
@@ -8,36 +8,42 @@
|
|||||||
:root {
|
:root {
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--muted: oklch(0.96 0.004 145);
|
--muted: oklch(0.96 0.004 145);
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.002 200);
|
||||||
--foreground: oklch(0.23 0.02 155);
|
--foreground: oklch(0.18 0.02 240);
|
||||||
--card-foreground: oklch(0.23 0.02 155);
|
--card-foreground: oklch(0.18 0.02 240);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.23 0.02 155);
|
--popover-foreground: oklch(0.18 0.02 240);
|
||||||
--primary: oklch(0.56 0.12 156);
|
--primary: oklch(0.55 0.14 155);
|
||||||
--primary-foreground: oklch(0.985 0.003 95);
|
--primary-foreground: oklch(0.985 0.005 155);
|
||||||
--secondary: oklch(0.965 0.01 155);
|
--secondary: oklch(0.96 0.01 155);
|
||||||
--secondary-foreground: oklch(0.3 0.035 156);
|
--secondary-foreground: oklch(0.3 0.035 156);
|
||||||
--muted-foreground: oklch(0.5 0.02 155);
|
--muted-foreground: oklch(0.48 0.02 240);
|
||||||
--accent: oklch(0.945 0.015 156);
|
--accent: oklch(0.945 0.018 155);
|
||||||
--accent-foreground: oklch(0.3 0.035 156);
|
--accent-foreground: oklch(0.3 0.035 156);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.9 0.01 155);
|
--border: oklch(0.91 0.008 240);
|
||||||
--input: oklch(0.9 0.01 155);
|
--input: oklch(0.91 0.008 240);
|
||||||
--ring: oklch(0.62 0.08 156);
|
--ring: oklch(0.55 0.14 155);
|
||||||
--chart-1: oklch(0.62 0.12 156);
|
--chart-1: oklch(0.55 0.14 155);
|
||||||
--chart-2: oklch(0.56 0.11 188);
|
--chart-2: oklch(0.56 0.11 188);
|
||||||
--chart-3: oklch(0.67 0.12 120);
|
--chart-3: oklch(0.67 0.12 120);
|
||||||
--chart-4: oklch(0.72 0.15 85);
|
--chart-4: oklch(0.72 0.15 85);
|
||||||
--chart-5: oklch(0.58 0.18 35);
|
--chart-5: oklch(0.58 0.18 35);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.975 0.005 155);
|
||||||
--sidebar-foreground: oklch(0.23 0.02 155);
|
--sidebar-foreground: oklch(0.18 0.02 240);
|
||||||
--sidebar-primary: oklch(0.56 0.12 156);
|
--sidebar-primary: oklch(0.55 0.14 155);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0.003 95);
|
--sidebar-primary-foreground: oklch(0.985 0.005 155);
|
||||||
--sidebar-accent: oklch(0.945 0.015 156);
|
--sidebar-accent: oklch(0.94 0.02 155);
|
||||||
--sidebar-accent-foreground: oklch(0.3 0.035 156);
|
--sidebar-accent-foreground: oklch(0.25 0.03 155);
|
||||||
--sidebar-border: oklch(0.9 0.01 155);
|
--sidebar-border: oklch(0.92 0.008 240);
|
||||||
--sidebar-ring: oklch(0.62 0.08 156);
|
--sidebar-ring: oklch(0.55 0.14 155);
|
||||||
|
|
||||||
|
/* Semantic presence colors */
|
||||||
|
--presence-online: oklch(0.62 0.17 145);
|
||||||
|
--presence-likely: oklch(0.75 0.15 75);
|
||||||
|
--presence-unknown: oklch(0.65 0.02 240);
|
||||||
|
--presence-offline: oklch(0.62 0.2 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -84,67 +90,267 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--muted: oklch(0.25 0.01 156);
|
--muted: oklch(0.22 0.015 240);
|
||||||
--background: oklch(0.19 0.02 156);
|
--background: oklch(0.14 0.02 250);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.93 0.005 240);
|
||||||
--card: oklch(0.24 0.015 156);
|
--card: oklch(0.18 0.018 245);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.93 0.005 240);
|
||||||
--popover: oklch(0.24 0.015 156);
|
--popover: oklch(0.18 0.018 245);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.93 0.005 240);
|
||||||
--primary: oklch(0.68 0.11 156);
|
--primary: oklch(0.7 0.14 155);
|
||||||
--primary-foreground: oklch(0.17 0.02 156);
|
--primary-foreground: oklch(0.14 0.02 155);
|
||||||
--secondary: oklch(0.28 0.02 156);
|
--secondary: oklch(0.24 0.02 240);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.93 0.005 240);
|
||||||
--muted-foreground: oklch(0.75 0.02 156);
|
--muted-foreground: oklch(0.65 0.015 240);
|
||||||
--accent: oklch(0.3 0.02 156);
|
--accent: oklch(0.24 0.025 245);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.93 0.005 240);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 8%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 10%);
|
||||||
--ring: oklch(0.65 0.08 156);
|
--ring: oklch(0.7 0.14 155);
|
||||||
--chart-1: oklch(0.68 0.11 156);
|
--chart-1: oklch(0.7 0.14 155);
|
||||||
--chart-2: oklch(0.63 0.11 188);
|
--chart-2: oklch(0.63 0.11 188);
|
||||||
--chart-3: oklch(0.73 0.12 120);
|
--chart-3: oklch(0.73 0.12 120);
|
||||||
--chart-4: oklch(0.78 0.14 85);
|
--chart-4: oklch(0.78 0.14 85);
|
||||||
--chart-5: oklch(0.67 0.16 35);
|
--chart-5: oklch(0.67 0.16 35);
|
||||||
--sidebar: oklch(0.24 0.015 156);
|
--sidebar: oklch(0.16 0.02 248);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.93 0.005 240);
|
||||||
--sidebar-primary: oklch(0.68 0.11 156);
|
--sidebar-primary: oklch(0.7 0.14 155);
|
||||||
--sidebar-primary-foreground: oklch(0.17 0.02 156);
|
--sidebar-primary-foreground: oklch(0.14 0.02 155);
|
||||||
--sidebar-accent: oklch(0.3 0.02 156);
|
--sidebar-accent: oklch(0.22 0.025 245);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.93 0.005 240);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.65 0.08 156);
|
--sidebar-ring: oklch(0.7 0.14 155);
|
||||||
|
|
||||||
|
--presence-online: oklch(0.72 0.18 145);
|
||||||
|
--presence-likely: oklch(0.8 0.14 75);
|
||||||
|
--presence-unknown: oklch(0.6 0.015 240);
|
||||||
|
--presence-offline: oklch(0.7 0.2 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Base ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
background-color: var(--background);
|
|
||||||
background-image:
|
|
||||||
radial-gradient(
|
|
||||||
1200px 700px at 15% -10%,
|
|
||||||
color-mix(in oklab, var(--primary) 15%, var(--background)) 0%,
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
900px 600px at 85% 0%,
|
|
||||||
color-mix(in oklab, var(--accent) 12%, var(--background)) 0%,
|
|
||||||
transparent 58%
|
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
color-mix(in oklab, var(--background) 85%, var(--foreground) 15%) 0%,
|
|
||||||
var(--background) 45%
|
|
||||||
);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 100% 100%;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar shell ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 15rem;
|
||||||
|
height: 100dvh;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed {
|
||||||
|
width: 3.5rem;
|
||||||
|
padding: 0.75rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
1200px 700px at 15% -10%,
|
||||||
|
color-mix(in oklab, var(--primary) 8%, var(--background)) 0%,
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
900px 600px at 85% 0%,
|
||||||
|
color-mix(in oklab, var(--accent) 6%, var(--background)) 0%,
|
||||||
|
transparent 58%
|
||||||
|
),
|
||||||
|
var(--background);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.3rem 0.35rem 0.75rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-label {
|
||||||
|
padding: 0.5rem 0.55rem 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--sidebar-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
color: var(--sidebar-accent-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--active {
|
||||||
|
background: color-mix(in oklab, var(--sidebar-primary) 14%, transparent);
|
||||||
|
color: var(--sidebar-primary);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--active .sidebar-link-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
border-top: 1px solid var(--sidebar-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--sidebar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .sidebar-footer-btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot--online {
|
||||||
|
background: var(--presence-online);
|
||||||
|
}
|
||||||
|
.presence-dot--likely_online {
|
||||||
|
background: var(--presence-likely);
|
||||||
|
}
|
||||||
|
.presence-dot--unknown {
|
||||||
|
background: var(--presence-unknown);
|
||||||
|
}
|
||||||
|
.presence-dot--offline {
|
||||||
|
background: var(--presence-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-badge--online {
|
||||||
|
border-color: color-mix(in oklab, var(--presence-online) 40%, transparent);
|
||||||
|
color: var(--presence-online);
|
||||||
|
}
|
||||||
|
.presence-badge--likely_online {
|
||||||
|
border-color: color-mix(in oklab, var(--presence-likely) 40%, transparent);
|
||||||
|
color: var(--presence-likely);
|
||||||
|
}
|
||||||
|
.presence-badge--unknown {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.presence-badge--offline {
|
||||||
|
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
|
||||||
|
color: var(--presence-offline);
|
||||||
|
}
|
||||||
+9
-13
@@ -6,15 +6,15 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function sourcemapEnabled(): boolean {
|
const debugEnabled = (): boolean => {
|
||||||
const explicit = process.env.UI_SOURCEMAP?.toLowerCase();
|
const explicit = process.env.DEBUG?.toLowerCase();
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return explicit === "1" || explicit === "true" || explicit === "yes";
|
return explicit === "1" || explicit === "true" || explicit === "yes";
|
||||||
}
|
}
|
||||||
|
|
||||||
const rustLog = (process.env.RUST_LOG || "").toLowerCase();
|
const rustLog = (process.env.RUST_LOG || "").toLowerCase();
|
||||||
return rustLog.includes("debug") || rustLog.includes("trace");
|
return rustLog.includes("debug") || rustLog.includes("trace");
|
||||||
}
|
};
|
||||||
|
|
||||||
const ReactCompilerConfig = {
|
const ReactCompilerConfig = {
|
||||||
target: "18",
|
target: "18",
|
||||||
@@ -23,15 +23,11 @@ const ReactCompilerConfig = {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(
|
react({
|
||||||
process.env.UI_REACT_COMPILER === "1"
|
babel: {
|
||||||
? {
|
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
|
||||||
babel: {
|
},
|
||||||
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
|
}),
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
base: "/ui/",
|
base: "/ui/",
|
||||||
@@ -41,6 +37,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: sourcemapEnabled(),
|
sourcemap: debugEnabled(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.1.2"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
+4
-14
@@ -32,9 +32,6 @@ pub enum Command {
|
|||||||
InitConfig(InitConfigArgs),
|
InitConfig(InitConfigArgs),
|
||||||
/// Reload a running agent daemon by sending SIGHUP.
|
/// Reload a running agent daemon by sending SIGHUP.
|
||||||
Reload(ReloadArgs),
|
Reload(ReloadArgs),
|
||||||
/// Upload local observations to the control plane once and exit.
|
|
||||||
#[command(visible_alias = "sync")]
|
|
||||||
SyncObservations(SyncObservationsArgs),
|
|
||||||
/// Pass local hotplug observations through to the wakey CLI.
|
/// Pass local hotplug observations through to the wakey CLI.
|
||||||
Observe(ObserveArgs),
|
Observe(ObserveArgs),
|
||||||
}
|
}
|
||||||
@@ -42,7 +39,7 @@ pub enum Command {
|
|||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct ServeArgs {
|
pub struct ServeArgs {
|
||||||
/// Path to the agent config file.
|
/// Path to the agent config file.
|
||||||
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
|
#[arg(long, short = 'c', default_value = config::DEFAULT_CONFIG_PATH)]
|
||||||
pub config: PathBuf,
|
pub config: PathBuf,
|
||||||
|
|
||||||
/// Path to pid file for reload signaling.
|
/// Path to pid file for reload signaling.
|
||||||
@@ -80,11 +77,11 @@ pub struct EnrollArgs {
|
|||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct InitConfigArgs {
|
pub struct InitConfigArgs {
|
||||||
/// Path to the agent config file to write. If omitted, config is printed to stdout.
|
/// Path to the agent config file to write. If omitted, config is printed to stdout.
|
||||||
#[arg(long)]
|
#[arg(long, short, visible_alias = "to", visible_short_alias = 't')]
|
||||||
pub config: Option<PathBuf>,
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
/// Existing agent config to use as a base before applying explicit overrides.
|
/// Existing agent config to use as a base before applying explicit overrides.
|
||||||
#[arg(long)]
|
#[arg(long, short = 'f', visible_alias = "from")]
|
||||||
pub from_config: Option<PathBuf>,
|
pub from_config: Option<PathBuf>,
|
||||||
|
|
||||||
/// Print config to stdout.
|
/// Print config to stdout.
|
||||||
@@ -115,17 +112,10 @@ pub struct ReloadArgs {
|
|||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
pub struct SyncObservationsArgs {
|
|
||||||
/// Path to the agent config file.
|
|
||||||
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
|
|
||||||
pub config: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct ObserveArgs {
|
pub struct ObserveArgs {
|
||||||
/// Path to the agent config file. If present, local path settings are passed through.
|
/// Path to the agent config file. If present, local path settings are passed through.
|
||||||
#[arg(long, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
|
#[arg(long, short, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||||
pub config: PathBuf,
|
pub config: PathBuf,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
|||||||
&config.mac_name_cache_path,
|
&config.mac_name_cache_path,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let observations = match wakey::wakey_linux::dhcp::list_local_observations_from_path(
|
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||||
&config.observation_store_path,
|
&config.observation_store_path,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+17
-9
@@ -101,15 +101,6 @@ async fn main() -> Result<()> {
|
|||||||
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
|
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
|
||||||
serve::reload_daemon(&args.pid_file)?
|
serve::reload_daemon(&args.pid_file)?
|
||||||
}
|
}
|
||||||
Command::SyncObservations(mut args) => {
|
|
||||||
if let Some(config) = global_config {
|
|
||||||
args.config = config.to_path_buf();
|
|
||||||
}
|
|
||||||
::tracing::info!(config = %args.config.display(), "wakey-agent command: sync-observations");
|
|
||||||
let cfg = config::load_config(&args.config)?;
|
|
||||||
let accepted = session::sync_observations_once(&cfg).await?;
|
|
||||||
println!("observations_synced={accepted}");
|
|
||||||
}
|
|
||||||
Command::Observe(mut args) => {
|
Command::Observe(mut args) => {
|
||||||
if let Some(config) = global_config {
|
if let Some(config) = global_config {
|
||||||
args.config = config.to_path_buf();
|
args.config = config.to_path_buf();
|
||||||
@@ -124,12 +115,21 @@ async fn main() -> Result<()> {
|
|||||||
fn observe(args: cli::ObserveArgs) -> Result<()> {
|
fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||||
let mut cmd = std::process::Command::new(resolve_wakey_binary());
|
let mut cmd = std::process::Command::new(resolve_wakey_binary());
|
||||||
cmd.arg("observe");
|
cmd.arg("observe");
|
||||||
|
let config_path = args.config.clone();
|
||||||
if let Ok(config) = config::load_config(&args.config) {
|
if let Ok(config) = config::load_config(&args.config) {
|
||||||
config::apply_local_path_env_to_command(&mut cmd, &config);
|
config::apply_local_path_env_to_command(&mut cmd, &config);
|
||||||
}
|
}
|
||||||
|
|
||||||
match args.command {
|
match args.command {
|
||||||
ObserveCommand::Dhcp(args) => {
|
ObserveCommand::Dhcp(args) => {
|
||||||
|
::tracing::debug!(
|
||||||
|
action = %args.action,
|
||||||
|
mac = %args.mac,
|
||||||
|
ip = ?args.ip,
|
||||||
|
hostname = ?args.hostname,
|
||||||
|
config = %config_path.display(),
|
||||||
|
"forwarding dhcp observation to wakey"
|
||||||
|
);
|
||||||
cmd.arg("dhcp")
|
cmd.arg("dhcp")
|
||||||
.arg("--action")
|
.arg("--action")
|
||||||
.arg(args.action)
|
.arg(args.action)
|
||||||
@@ -143,6 +143,13 @@ fn observe(args: cli::ObserveArgs) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ObserveCommand::Neigh(args) => {
|
ObserveCommand::Neigh(args) => {
|
||||||
|
::tracing::debug!(
|
||||||
|
action = %args.action,
|
||||||
|
mac = ?args.mac,
|
||||||
|
ip = ?args.ip,
|
||||||
|
config = %config_path.display(),
|
||||||
|
"forwarding neighbor observation to wakey"
|
||||||
|
);
|
||||||
cmd.arg("neigh").arg("--action").arg(args.action);
|
cmd.arg("neigh").arg("--action").arg(args.action);
|
||||||
if let Some(mac) = args.mac {
|
if let Some(mac) = args.mac {
|
||||||
cmd.arg("--mac").arg(mac);
|
cmd.arg("--mac").arg(mac);
|
||||||
@@ -154,6 +161,7 @@ fn observe(args: cli::ObserveArgs) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
|
::tracing::debug!(%status, "wakey observe exited");
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
anyhow::bail!("wakey observe exited with {status}");
|
anyhow::bail!("wakey observe exited with {status}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use std::fmt;
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use wakey_core::parse::mac;
|
use wakey_core::parse::mac;
|
||||||
use wakey_core::{
|
use wakey_core::{
|
||||||
DeviceInventory, DhcpLeaseWithState, InterfaceSummary, InventoryQuery, InventoryQueryBuilder,
|
Device, DeviceInventory, DhcpLeaseWithState, InterfaceSummary, InventoryQuery,
|
||||||
WakeResult,
|
InventoryQueryBuilder, WakeResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
@@ -161,6 +161,10 @@ pub enum ClientMessage {
|
|||||||
Heartbeat {
|
Heartbeat {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
},
|
},
|
||||||
|
DeviceSnapshot {
|
||||||
|
agent_id: String,
|
||||||
|
devices: Vec<Device>,
|
||||||
|
},
|
||||||
Result {
|
Result {
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
result: CommandResult,
|
result: CommandResult,
|
||||||
@@ -178,6 +182,7 @@ pub enum ServerMessage {
|
|||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
command: AgentCommand,
|
command: AgentCommand,
|
||||||
},
|
},
|
||||||
|
SyncDeviceSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -215,4 +220,16 @@ mod tests {
|
|||||||
assert!(json.contains("\"type\":\"result\""));
|
assert!(json.contains("\"type\":\"result\""));
|
||||||
assert!(json.contains("\"kind\":\"devs\""));
|
assert!(json.contains("\"kind\":\"devs\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_snapshot_serializes() {
|
||||||
|
let msg = ClientMessage::DeviceSnapshot {
|
||||||
|
agent_id: "agent-a".into(),
|
||||||
|
devices: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&msg).expect("serialize");
|
||||||
|
assert!(json.contains("\"type\":\"device_snapshot\""));
|
||||||
|
assert!(json.contains("\"devices\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+33
-85
@@ -1,6 +1,5 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::Serialize;
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
|
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
|
||||||
@@ -85,13 +84,13 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
||||||
|
|
||||||
let http_client = reqwest::Client::new();
|
|
||||||
let mut heartbeat = interval(Duration::from_secs(30));
|
let mut heartbeat = interval(Duration::from_secs(30));
|
||||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
let mut observation_sync = interval(Duration::from_secs(
|
let mut snapshot_sync = interval(Duration::from_secs(
|
||||||
config.observation_sync_interval_seconds.max(1),
|
config.observation_sync_interval_seconds.max(1),
|
||||||
));
|
));
|
||||||
observation_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
snapshot_sync.reset();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -101,9 +100,9 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
}).await?;
|
}).await?;
|
||||||
debug!(agent_id = %config.agent_id, "heartbeat sent");
|
debug!(agent_id = %config.agent_id, "heartbeat sent");
|
||||||
}
|
}
|
||||||
_ = observation_sync.tick() => {
|
_ = snapshot_sync.tick() => {
|
||||||
if let Err(err) = send_agent_observations(&http_client, config).await {
|
if let Err(err) = send_device_snapshot_ws(&mut sink, config).await {
|
||||||
warn!(agent_id = %config.agent_id, error = %err, "failed to sync local observations");
|
warn!(agent_id = %config.agent_id, error = %err, "failed to sync device snapshot");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maybe_msg = source.next() => {
|
maybe_msg = source.next() => {
|
||||||
@@ -119,7 +118,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
Message::Text(text) => {
|
Message::Text(text) => {
|
||||||
match serde_json::from_str::<ServerMessage>(&text) {
|
match serde_json::from_str::<ServerMessage>(&text) {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
handle_server_message(config, &mut sink, message).await?;
|
handle_server_message(config, &mut sink, &mut snapshot_sync, message).await?;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// Allow the server to introduce extra frame types without
|
// Allow the server to introduce extra frame types without
|
||||||
@@ -146,76 +145,26 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||||
struct UploadAgentObservationsRequest {
|
where
|
||||||
agent_id: String,
|
S: SinkExt<Message> + Unpin,
|
||||||
agent_token: String,
|
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
observations: Vec<AgentObservationRequest>,
|
{
|
||||||
}
|
let query = wakey_core::InventoryQueryBuilder::new().build();
|
||||||
|
let inventory = wakey::inventory(query)
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct AgentObservationRequest {
|
|
||||||
kind: String,
|
|
||||||
action: String,
|
|
||||||
mac: Option<String>,
|
|
||||||
ip: Option<IpAddr>,
|
|
||||||
hostname: Option<String>,
|
|
||||||
first_seen_unix: u64,
|
|
||||||
last_seen_unix: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
send_agent_observations(&client, config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig) -> Result<usize> {
|
|
||||||
let observations =
|
|
||||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
|
||||||
.await
|
|
||||||
.context("failed to read local observations")?;
|
|
||||||
if observations.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = observations_url(&config.server_url)?;
|
|
||||||
let payload = UploadAgentObservationsRequest {
|
|
||||||
agent_id: config.agent_id.clone(),
|
|
||||||
agent_token: config.agent_token.clone(),
|
|
||||||
observations: observations
|
|
||||||
.into_iter()
|
|
||||||
.map(|observation| AgentObservationRequest {
|
|
||||||
kind: observation.kind,
|
|
||||||
action: observation.action,
|
|
||||||
mac: observation.mac,
|
|
||||||
ip: observation.ip,
|
|
||||||
hostname: observation.hostname,
|
|
||||||
first_seen_unix: observation.first_seen_unix,
|
|
||||||
last_seen_unix: observation.last_seen_unix,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(url.clone())
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to call observation endpoint {url}"))?;
|
.context("failed to run inventory for device snapshot")?;
|
||||||
if !response.status().is_success() {
|
let count = inventory.devices.len();
|
||||||
let status = response.status();
|
send_json(
|
||||||
let body = response
|
sink,
|
||||||
.text()
|
&ClientMessage::DeviceSnapshot {
|
||||||
.await
|
agent_id: config.agent_id.clone(),
|
||||||
.unwrap_or_else(|_| "<unreadable error body>".to_string());
|
devices: inventory.devices,
|
||||||
anyhow::bail!("observation upload failed with {status}: {body}");
|
},
|
||||||
}
|
)
|
||||||
debug!(
|
.await?;
|
||||||
agent_id = %config.agent_id,
|
debug!(agent_id = %config.agent_id, devices = count, "sent device snapshot over websocket");
|
||||||
observations = payload.observations.len(),
|
Ok(count)
|
||||||
"synced local observations"
|
|
||||||
);
|
|
||||||
Ok(payload.observations.len())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||||
@@ -226,6 +175,7 @@ pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
|||||||
async fn handle_server_message<S>(
|
async fn handle_server_message<S>(
|
||||||
config: &AgentConfig,
|
config: &AgentConfig,
|
||||||
sink: &mut S,
|
sink: &mut S,
|
||||||
|
snapshot_sync: &mut tokio::time::Interval,
|
||||||
message: ServerMessage,
|
message: ServerMessage,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
@@ -261,6 +211,11 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ServerMessage::SyncDeviceSnapshot => {
|
||||||
|
info!("received device snapshot sync request from control-plane");
|
||||||
|
send_device_snapshot_ws(sink, config).await?;
|
||||||
|
snapshot_sync.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -336,6 +291,7 @@ fn client_message_kind(message: &ClientMessage) -> &'static str {
|
|||||||
ClientMessage::Hello { .. } => "hello",
|
ClientMessage::Hello { .. } => "hello",
|
||||||
ClientMessage::Auth { .. } => "auth",
|
ClientMessage::Auth { .. } => "auth",
|
||||||
ClientMessage::Heartbeat { .. } => "heartbeat",
|
ClientMessage::Heartbeat { .. } => "heartbeat",
|
||||||
|
ClientMessage::DeviceSnapshot { .. } => "device_snapshot",
|
||||||
ClientMessage::Result { .. } => "result",
|
ClientMessage::Result { .. } => "result",
|
||||||
ClientMessage::Error { .. } => "error",
|
ClientMessage::Error { .. } => "error",
|
||||||
}
|
}
|
||||||
@@ -360,14 +316,6 @@ pub fn websocket_url(server_url: &str) -> Result<url::Url> {
|
|||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn observations_url(server_url: &str) -> Result<url::Url> {
|
|
||||||
let mut url = url::Url::parse(server_url).context("invalid server_url")?;
|
|
||||||
url.set_path("/api/v1/agents/observations");
|
|
||||||
url.set_query(None);
|
|
||||||
url.set_fragment(None);
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dns_resolution_diagnostics(ws_url: &url::Url) -> Option<(u64, usize)> {
|
async fn dns_resolution_diagnostics(ws_url: &url::Url) -> Option<(u64, usize)> {
|
||||||
let host = ws_url.host_str()?;
|
let host = ws_url.host_str()?;
|
||||||
if host.parse::<IpAddr>().is_ok() {
|
if host.parse::<IpAddr>().is_ok() {
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 8
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
|
||||||
}
|
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fact_json!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "12e443ec0991c042cf527498b567bbbb6d02885f72eb3fa426c9493368f8a9e7"
|
||||||
|
}
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "observation_key!",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agent_id!",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "kind!",
|
|
||||||
"ordinal": 2,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac",
|
|
||||||
"ordinal": 3,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ip",
|
|
||||||
"ordinal": 4,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "hostname",
|
|
||||||
"ordinal": 5,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "first_seen_unix",
|
|
||||||
"ordinal": 6,
|
|
||||||
"type_info": "Integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "last_seen_unix",
|
|
||||||
"ordinal": 7,
|
|
||||||
"type_info": "Integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "last_action!",
|
|
||||||
"ordinal": 8,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "device_id",
|
|
||||||
"ordinal": 9,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "display_name",
|
|
||||||
"ordinal": 10,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pinned",
|
|
||||||
"ordinal": 11,
|
|
||||||
"type_info": "Integer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 2
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
|
||||||
|
}
|
||||||
-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 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"
|
||||||
|
}
|
||||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user