Compare commits
36
Commits
v0.2.1
...
63e7e4246b
No files matched your search
@@ -16,7 +16,14 @@ cw = "c --target=x86_64-pc-windows-msvc"
|
|||||||
bw = "b --target=x86_64-pc-windows-msvc"
|
bw = "b --target=x86_64-pc-windows-msvc"
|
||||||
tw = "t --target=x86_64-pc-windows-msvc"
|
tw = "t --target=x86_64-pc-windows-msvc"
|
||||||
|
|
||||||
|
|
||||||
|
check-all = "check --workspace --all-features --all-targets"
|
||||||
|
clippy-all = "clippy --workspace --all-features --all-targets"
|
||||||
|
test-all = "test --workspace --all-features --all-targets"
|
||||||
|
fmt-all = "fmt --all"
|
||||||
|
|
||||||
# idk below
|
# idk below
|
||||||
|
ldar = "r -qrp"
|
||||||
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
||||||
t = "test -- --nocapture --test-threads=1"
|
t = "test -- --nocapture --test-threads=1"
|
||||||
tdebug = "test -- --nocapture --test-threads=1 --show-output"
|
tdebug = "test -- --nocapture --test-threads=1 --show-output"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
SQLX_OFFLINE=true
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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/`.
|
||||||
@@ -7,10 +7,6 @@ on:
|
|||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
|
||||||
# When using cargo-zigbuild for *-gnu targets, link against this glibc baseline (wider VPS compatibility).
|
|
||||||
WAKEY_CC_GLIBC_VERSION: "2.28"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -23,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: armv7-unknown-linux-musleabihf,x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
|
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -35,14 +31,8 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: ui/pnpm-lock.yaml
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Ensure toolchain and targets
|
- name: Install cross
|
||||||
shell: pwsh
|
run: cargo install cross --locked
|
||||||
run: |
|
|
||||||
rustc -V
|
|
||||||
cargo -V
|
|
||||||
rustup target add armv7-unknown-linux-musleabihf
|
|
||||||
rustup target add x86_64-unknown-linux-gnu
|
|
||||||
rustup target add aarch64-unknown-linux-gnu
|
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -53,10 +43,9 @@ jobs:
|
|||||||
target
|
target
|
||||||
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Build (armv7-musl)
|
- name: Build (armv7-musl with cross)
|
||||||
shell: pwsh
|
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||||
|
|
||||||
- name: Build UI dist
|
- name: Build UI dist
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -66,29 +55,17 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
- name: Build control-plane (linux-gnu; zig + older glibc when available)
|
- name: Build control-plane (linux-gnu)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
|
|
||||||
GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
|
|
||||||
|
|
||||||
use_zig=false
|
# x86_64 builds natively; aarch64 needs cross
|
||||||
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
|
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
|
||||||
use_zig=true
|
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$use_zig" = true ]; then
|
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
|
||||||
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
|
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
|
||||||
for base in "${GNU_TARGETS[@]}"; do
|
|
||||||
cargo zigbuild --release --target "${base}.${GLIBC_VER}" -p wakey-control-plane
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "zig or cargo zigbuild not available: using plain cargo build (binary needs glibc >= host)"
|
|
||||||
for base in "${GNU_TARGETS[@]}"; do
|
|
||||||
cargo build --release --target "$base" -p wakey-control-plane
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Package rootfs tarball
|
- name: Package rootfs tarball
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ target/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.codex
|
||||||
@@ -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
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "020b33576bfc2d171a051f903d40ccc78c972fa9d5d983a5678f03abbc8b8488"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "07d7f283daba2742e5522abf99074171ed484c03f70138be611e0c4b17e311b8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 12
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0c47aade9d5c71d6c7e2023c6ff66c6ab4b3a0dd2b78af3a600dedc89da508f2"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) as \"count!: i64\" FROM enroll_tokens",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "0ca58d0cbc25c59d2c956222d41c02eb0c76a135f29a56a7570b750f7378738d"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)\n ON CONFLICT(agent_id) DO UPDATE SET nickname = excluded.nickname",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "181992e554afdd8f21bd5df71bfb7880a5a9de28af8cd0c766d8d4aa649b3c90"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n ORDER BY pinned DESC, display_name, device_id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "device_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pinned",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at_unix",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at_unix",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "19b5fc20ffc99589bbaf11b94661f7c0dfcf80748c60f8f3289bb3296114635a"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "21e5986ad3e8efd3393f2bc4929ee79e664cbc2b55948c276d52ca2a6fdc6a48"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO known_devices\n (device_id, display_name, pinned, created_at_unix, updated_at_unix, notes)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "2bff8b8b070f5b5dd946e754c5e15ea405f6c29baa4c2c31347f455227437ae2"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT 1 as \"ok!\"",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "ok!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2c0dd5d9be90cc716da219c75c2d3b75dae8c0902c64ef5baa96c902f23abc5d"
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "event_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "observation_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind!",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "action!",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mac",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ip",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hostname",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ts_unix",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_id",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pinned",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) as \"count!: i64\" FROM enroll_tokens WHERE expires_at_unix <= ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "5083fd5878c57f4d54e8b24ad0cef2771f7cac36afb7c370c65137fc0784f093"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agents WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "51cb6ec47368803af60114a39dd9e70ba9d31946bc39980c9e85dbc4e842a2c8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "expires_at_unix",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "540f57604aa987b97930bb0bcfe440094d97b769fd758fd5cffc32f6d5716bb4"
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "60c6631ace995d7ca109d1bee1d878c823edd40f15f8571b42e00680a120ad74"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM active_alerts",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "72afd48584f822116318c623f7dd42ddf4c82b337d8a3d52ed5973d52e08c163"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO device_identifiers\n (identifier_key, device_id, kind, value, created_at_unix)\n VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 5
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7dc7edbbe5539f89783cbccb0475e676cb6d369b676b0c5aaedf99854ffbd760"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT token as \"token!\", expires_at_unix FROM enroll_tokens ORDER BY expires_at_unix, token",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "token!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "expires_at_unix",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7f6c5c108953cd4eda8f4e1d4f4db6b3b88270d3d7087b2948362d0ea94ad246"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_meta WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8bf8470846bd228d2ea9b243f70f946f002915b9b1983bce818abc0ae3dd97f0"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM enroll_tokens WHERE token = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8f3499a2f461f738711b9942a9c9d6254c1f7ed0db54db8b3d65e10fa8dadfc9"
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,\n last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "mac",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ip",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hostname",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "first_seen_unix",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_seen_unix",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_action!",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "92775bf2e3b9f772d108bb8d0cd1aebbe01357ed70dc2af7b50ee348e668b319"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agents.agent_id as \"agent_id!\", agent_meta.nickname\n FROM agents\n LEFT JOIN agent_meta ON agent_meta.agent_id = agents.agent_id\n ORDER BY agents.agent_id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nickname",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "977adb8bdfb9e031a738055fb81c02c6180ca9c37c90e39b3e4efb9168bd1944"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) as \"count!: i64\" FROM meta WHERE key = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9f5d340530083d040794bed2a45b01522f57dc4132184dab0eb32d1b6811992d"
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n WHERE device_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "device_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pinned",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at_unix",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at_unix",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9fe9f41d6716305ffebc764a72a3aea11e4ba348fae664aa6ba4bffbb3a2bf34"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT device_id FROM device_identifiers WHERE identifier_key = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "device_id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a1bca2de4e1328fa066d5a8703ef5b8bda560b234b9f1e75093827efeac10eec"
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT transition_id as \"transition_id!\", ts_unix, alert_id as \"alert_id!\",\n kind as \"kind!\", agent_id, from_status, to_status as \"to_status!\",\n message as \"message!\", metadata_json as \"metadata_json!\"\n FROM alert_transitions\n ORDER BY transition_key DESC\n LIMIT ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "transition_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ts_unix",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "alert_id!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind!",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agent_id",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "from_status",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "to_status!",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message!",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_json!",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a34accb5d8ef1fc90e1029b808956c85fa7cb00c0e879f27bbb99b5a76b5e151"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_id as \"agent_id!\" FROM agents ORDER BY agent_id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a5baac2d2f6dc38946ff15f6cba46ac085780e6521ba23a605a78b4b92a62073"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT alert_id as \"alert_id!\", kind as \"kind!\", severity as \"severity!\",\n status as \"status!\", agent_id, message as \"message!\",\n value, threshold, last_seen_unix, metadata_json as \"metadata_json!\"\n FROM active_alerts",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "alert_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "severity!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status!",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agent_id",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message!",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "threshold",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_seen_unix",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_json!",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b031d0fa0f9d0640555fc78b400b7bb951a3f013715fb5be51030c2be63e864d"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM known_devices WHERE device_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b1c6150ef7bd67ce78f8934fc5d451b36f5bed834d39bdde01356e331c5e5ddb"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT value FROM meta WHERE key = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Blob"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b805f8dada38af1c79a7b3bbc063fd949bf81757705ace7173658de9f185868b"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "bee62978a2ce59095f9bc679258b5747e074a55d52c43b982ea019c79aff1479"
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT transition_id as \"transition_id!\", ts_unix, alert_id as \"alert_id!\",\n kind as \"kind!\", agent_id, from_status, to_status as \"to_status!\",\n message as \"message!\", metadata_json as \"metadata_json!\"\n FROM alert_transitions\n WHERE ts_unix >= ?1\n ORDER BY transition_key DESC\n LIMIT ?2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "transition_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ts_unix",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "alert_id!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind!",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agent_id",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "from_status",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "to_status!",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message!",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata_json!",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ca117e134d45464a8930d0483ff8670d2798f38718c67f49adce06f74656bf2c"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "d2c2ce1761d22477164b3e5cfde9718877b3b472886ae1dc7e64dc36ad75b2ea"
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT identifier_key as \"identifier_key!\", device_id as \"device_id!\",\n kind as \"kind!\", value as \"value!\", created_at_unix\n FROM device_identifiers\n WHERE device_id = ?1\n ORDER BY kind, value",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "identifier_key!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_id!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value!",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at_unix",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "dd1d6ba80d6380cb07227eac0b90951c93f63dc4c89349cf1920405943e2093a"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT agent_token FROM agents WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "agent_token",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e0def02317412d21994af8d7770a9e717f4be97e022c0f2022983d59391c775b"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM enroll_tokens WHERE expires_at_unix <= ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ee6d28dc501f6bb8cf1249ad03b82e3554f1167d5056f02a0cc823601f480e53"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) as \"count!: i64\" FROM known_devices WHERE device_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f18ab386ab1961e938ae7fec3e752bdd958118aa4ca81cab3421ac5827b04029"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO meta (key, value) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ffed939cfeecca1d46bdf77b67aca2919b34d496e729ad08f87ac9782fb8e382"
|
||||||
|
}
|
||||||
Generated
+764
-12
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.1"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ Example:
|
|||||||
data_dir = "/var/lib/wakey-control-plane"
|
data_dir = "/var/lib/wakey-control-plane"
|
||||||
bind = "0.0.0.0:8080"
|
bind = "0.0.0.0:8080"
|
||||||
public_url = "https://cp.example.com"
|
public_url = "https://cp.example.com"
|
||||||
state_file = "state.db"
|
state_file = "state.sqlite3"
|
||||||
pid_file = "wakey-control-plane.pid"
|
pid_file = "wakey-control-plane.pid"
|
||||||
ui_dist_dir = "/opt/wakey/ui/dist"
|
ui_dist_dir = "/opt/wakey/ui/dist"
|
||||||
command_timeout_ms = 30000
|
command_timeout_ms = 30000
|
||||||
@@ -140,9 +140,14 @@ json_logs = false
|
|||||||
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
|
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
|
||||||
structured logs are emitted.
|
structured logs are emitted.
|
||||||
|
|
||||||
State is persisted in an embedded `sled` database (default
|
State is persisted in an embedded SQLite database (default
|
||||||
`/var/lib/wakey-control-plane/state.db`).
|
`/var/lib/wakey-control-plane/state.sqlite3`).
|
||||||
Relative paths in config are resolved under `data_dir`.
|
Relative paths in config are resolved under `data_dir`.
|
||||||
|
Legacy sled state can be imported explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /var/lib/wakey-control-plane/state.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
||||||
Expired tokens are rejected on enroll and can be garbage-collected periodically
|
Expired tokens are rejected on enroll and can be garbage-collected periodically
|
||||||
|
|||||||
@@ -25,3 +25,11 @@ $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\"
|
||||||
|
}
|
||||||
@@ -68,8 +68,12 @@ try {
|
|||||||
$initDestTmp = "$User@${HostName}:$initTmp"
|
$initDestTmp = "$User@${HostName}:$initTmp"
|
||||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||||
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
||||||
|
$localDhcpHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/dhcp/95-wakey'
|
||||||
|
$localNeighHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/neigh/95-wakey'
|
||||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||||
|
$dhcpHotplugTmp = '/var/tmp/wakey.hotplug.dhcp.tmp'
|
||||||
|
$neighHotplugTmp = '/var/tmp/wakey.hotplug.neigh.tmp'
|
||||||
|
|
||||||
# Push binaries
|
# Push binaries
|
||||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
@@ -79,6 +83,12 @@ try {
|
|||||||
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
||||||
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
}
|
}
|
||||||
|
if (Test-Path $localDhcpHotplug) {
|
||||||
|
Invoke-Scp -Local $localDhcpHotplug -Dest "$User@${HostName}:$dhcpHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
if (Test-Path $localNeighHotplug) {
|
||||||
|
Invoke-Scp -Local $localNeighHotplug -Dest "$User@${HostName}:$neighHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
|
||||||
# Push deploy helper if exists
|
# Push deploy helper if exists
|
||||||
if (Test-Path $localDeploy) {
|
if (Test-Path $localDeploy) {
|
||||||
@@ -103,6 +113,16 @@ if [ -f $initTmp ]; then
|
|||||||
fi
|
fi
|
||||||
"@
|
"@
|
||||||
})
|
})
|
||||||
|
if [ -f $dhcpHotplugTmp ]; then
|
||||||
|
sed -i "s/\r$//" $dhcpHotplugTmp 2>/dev/null || true
|
||||||
|
mkdir -p /etc/hotplug.d/dhcp
|
||||||
|
cp -f $dhcpHotplugTmp /etc/hotplug.d/dhcp/95-wakey
|
||||||
|
fi
|
||||||
|
if [ -f $neighHotplugTmp ]; then
|
||||||
|
sed -i "s/\r$//" $neighHotplugTmp 2>/dev/null || true
|
||||||
|
mkdir -p /etc/hotplug.d/neigh
|
||||||
|
cp -f $neighHotplugTmp /etc/hotplug.d/neigh/95-wakey
|
||||||
|
fi
|
||||||
"@
|
"@
|
||||||
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
||||||
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
||||||
|
|||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
BIN="/root/.bin/wakey-agent"
|
||||||
|
[ -x "$BIN" ] || BIN="$(command -v wakey-agent 2>/dev/null)"
|
||||||
|
[ -n "$BIN" ] || exit 0
|
||||||
|
[ -n "$ACTION" ] || 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
|
||||||
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" || true
|
||||||
|
elif [ -n "$IPADDR" ]; then
|
||||||
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
|
||||||
|
elif [ -n "$HOSTNAME" ]; then
|
||||||
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" || true
|
||||||
|
else
|
||||||
|
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" || true
|
||||||
|
fi
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
BIN="/root/.bin/wakey-agent"
|
||||||
|
[ -x "$BIN" ] || BIN="$(command -v wakey-agent 2>/dev/null)"
|
||||||
|
[ -n "$BIN" ] || 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
|
||||||
|
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
|
||||||
|
elif [ -n "$MACADDR" ]; then
|
||||||
|
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" || true
|
||||||
|
elif [ -n "$IPADDR" ]; then
|
||||||
|
run_wakey_agent observe neigh --action "$ACTION" --ip "$IPADDR" || true
|
||||||
|
else
|
||||||
|
run_wakey_agent observe neigh --action "$ACTION" || true
|
||||||
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,24 @@ Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
|
|||||||
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Copy OpenWrt hotplug hooks.
|
||||||
|
$hotplugSrc = Join-Path $root 'scripts/hotplug/openwrt'
|
||||||
|
if (Test-Path $hotplugSrc) {
|
||||||
|
Get-ChildItem $hotplugSrc -Directory | ForEach-Object {
|
||||||
|
$hotplugTypeDir = Join-Path $staging ("etc/hotplug.d/" + $_.Name)
|
||||||
|
New-Item -ItemType Directory -Force -Path $hotplugTypeDir | Out-Null
|
||||||
|
Get-ChildItem $_.FullName -File | ForEach-Object {
|
||||||
|
$dest = Join-Path $hotplugTypeDir $_.Name
|
||||||
|
$content = Get-Content -Raw -LiteralPath $_.FullName
|
||||||
|
$content = $content -replace "`r`n", "`n"
|
||||||
|
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||||
|
if (Get-Command chmod -ErrorAction SilentlyContinue) {
|
||||||
|
chmod +x $dest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Copy helper scripts intended for /etc/ldlda_help
|
# Copy helper scripts intended for /etc/ldlda_help
|
||||||
$helpSrc = Join-Path $root 'scripts/ldlda_help'
|
$helpSrc = Join-Path $root 'scripts/ldlda_help'
|
||||||
if (Test-Path $helpSrc) {
|
if (Test-Path $helpSrc) {
|
||||||
|
|||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
|
||||||
|
DATABASE_URL="sqlite://$DB"
|
||||||
|
|
||||||
|
if ! cargo sqlx --version >/dev/null 2>&1; then
|
||||||
|
echo "cargo-sqlx is not installed." >&2
|
||||||
|
echo "Install it with: cargo install sqlx-cli --no-default-features --features sqlite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$DB" "$DB-shm" "$DB-wal"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
DATABASE_URL="$DATABASE_URL" cargo sqlx database create
|
||||||
|
DATABASE_URL="$DATABASE_URL" cargo sqlx migrate run --source wakey-control-plane/migrations
|
||||||
|
DATABASE_URL="$DATABASE_URL" cargo sqlx prepare --workspace -- -p wakey-control-plane --all-targets --all-features
|
||||||
|
rm -rf wakey-control-plane/.sqlx
|
||||||
|
mkdir -p wakey-control-plane/.sqlx
|
||||||
|
cp .sqlx/*.json wakey-control-plane/.sqlx/
|
||||||
|
|
||||||
|
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||||
|
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
|
||||||
+2
-1
@@ -42,6 +42,7 @@ if ($versionProvided) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Ensure build ok
|
# Ensure build ok
|
||||||
|
$env:SQLX_OFFLINE = 'true'
|
||||||
cargo fmt
|
cargo fmt
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
@@ -85,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
|
||||||
|
|||||||
+71
-12
@@ -25,7 +25,7 @@ pub struct Cli {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Show merged device inventory rows.
|
/// Show merged device inventory rows.
|
||||||
#[command(visible_alias = "status")]
|
#[command(visible_alias = "status", alias = "inv")]
|
||||||
Inventory(InventoryArgs),
|
Inventory(InventoryArgs),
|
||||||
/// Show DHCP leases, optionally enriched with current neighbor state.
|
/// Show DHCP leases, optionally enriched with current neighbor state.
|
||||||
Leases(LeasesArgs),
|
Leases(LeasesArgs),
|
||||||
@@ -33,15 +33,20 @@ pub enum Command {
|
|||||||
Wake(WakeArgs),
|
Wake(WakeArgs),
|
||||||
/// Show condensed network interface summaries.
|
/// Show condensed network interface summaries.
|
||||||
Devs(DevsArgs),
|
Devs(DevsArgs),
|
||||||
|
/// Record local hotplug observations.
|
||||||
|
Observe(ObserveArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct LeasesArgs {
|
pub struct LeasesArgs {
|
||||||
/// Include best-known current neighbor state for each lease IP.
|
/// Legacy: include neighbor state on lease rows. Prefer `wakey inventory`.
|
||||||
#[arg(long)]
|
#[arg(
|
||||||
|
long,
|
||||||
|
help = "Deprecated: include neighbor state on lease rows. Prefer `wakey inventory`."
|
||||||
|
)]
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +114,49 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ObserveArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: ObserveCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum ObserveCommand {
|
||||||
|
/// Observe a dnsmasq DHCP lease event.
|
||||||
|
Dhcp(ObserveDhcpArgs),
|
||||||
|
/// Observe a neighbor-table event.
|
||||||
|
Neigh(ObserveNeighArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ObserveDhcpArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub action: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub mac: macaddr::MacAddr,
|
||||||
|
#[arg(long)]
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ObserveNeighArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub action: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub mac: Option<macaddr::MacAddr>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init_tracing(verbose: u8) {
|
pub fn init_tracing(verbose: u8) {
|
||||||
let filter = EnvFilter::try_from_default_env()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
|
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
|
||||||
@@ -163,10 +204,11 @@ pub async fn run(cli: Cli) -> Result<()> {
|
|||||||
json = args.json,
|
json = args.json,
|
||||||
"dispatching leases command"
|
"dispatching leases command"
|
||||||
);
|
);
|
||||||
let leases = wakey::get_leases(wakey_core::LeaseQuery {
|
let leases = if args.include_state {
|
||||||
include_state: args.include_state,
|
wakey::get_leases_with_neighbor_state().await?
|
||||||
})
|
} else {
|
||||||
.await?;
|
wakey::get_leases().await?
|
||||||
|
};
|
||||||
if args.json {
|
if args.json {
|
||||||
println!("{}", serde_json::to_string_pretty(&leases)?);
|
println!("{}", serde_json::to_string_pretty(&leases)?);
|
||||||
} else {
|
} else {
|
||||||
@@ -206,6 +248,23 @@ pub async fn run(cli: Cli) -> Result<()> {
|
|||||||
println!("{}", table::render_devs_table(&devs));
|
println!("{}", table::render_devs_table(&devs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Command::Observe(args) => match args.command {
|
||||||
|
ObserveCommand::Dhcp(args) => {
|
||||||
|
let changed = wakey::observe_dhcp_event(
|
||||||
|
&args.action,
|
||||||
|
args.mac,
|
||||||
|
args.ip,
|
||||||
|
args.hostname.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!("observed=dhcp changed={changed}");
|
||||||
|
}
|
||||||
|
ObserveCommand::Neigh(args) => {
|
||||||
|
let changed =
|
||||||
|
wakey::observe_neighbor_event(&args.action, args.mac, args.ip).await?;
|
||||||
|
println!("observed=neigh changed={changed}");
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+4
-1
@@ -3,12 +3,15 @@
|
|||||||
|
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
pub use wakey_linux;
|
||||||
|
|
||||||
pub use service::{
|
pub use service::{
|
||||||
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
|
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
|
||||||
inventory, leases_without_state, merge_devices, resolve_devices, resolve_query,
|
get_leases_with_neighbor_state, inventory, leases_without_state, local_observation_to_fact,
|
||||||
|
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};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
+163
-15
@@ -1,7 +1,8 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use tracing::{debug, warn};
|
||||||
use wakey_core::{
|
use wakey_core::{
|
||||||
Device, DeviceInventory, DhcpLease, DhcpLeaseWithState, InventoryQuery, NeighborEntry,
|
Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
|
||||||
Presence, Query,
|
NeighborEntry, Presence, Query,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::service::leases::get_leases;
|
use crate::service::leases::get_leases;
|
||||||
@@ -20,13 +21,26 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
|||||||
/// directly from raw Linux source rows.
|
/// directly from raw Linux source rows.
|
||||||
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(wakey_core::LeaseQuery {
|
let leases = get_leases().await?;
|
||||||
include_state: false,
|
let observations = match wakey_linux::dhcp::list_local_observations().await {
|
||||||
})
|
Ok(observations) => observations
|
||||||
.await?;
|
.into_iter()
|
||||||
Ok(DeviceInventory {
|
.filter_map(local_observation_to_fact)
|
||||||
devices: merge_devices(neighbors, leases, &query),
|
.collect::<Vec<_>>(),
|
||||||
})
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed reading local hook observations for inventory");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug!(
|
||||||
|
neighbors = neighbors.len(),
|
||||||
|
leases = leases.len(),
|
||||||
|
observations = observations.len(),
|
||||||
|
"building device inventory"
|
||||||
|
);
|
||||||
|
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
|
||||||
|
debug!(devices = devices.len(), "merged device inventory");
|
||||||
|
Ok(DeviceInventory { devices })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge raw neighbor entries and DHCP leases into device aggregates.
|
/// Merge raw neighbor entries and DHCP leases into device aggregates.
|
||||||
@@ -37,26 +51,53 @@ pub fn merge_devices(
|
|||||||
neighbors: Vec<NeighborEntry>,
|
neighbors: Vec<NeighborEntry>,
|
||||||
leases: Vec<DhcpLeaseWithState>,
|
leases: Vec<DhcpLeaseWithState>,
|
||||||
query: &InventoryQuery,
|
query: &InventoryQuery,
|
||||||
|
) -> Vec<Device> {
|
||||||
|
merge_devices_with_observations(neighbors, leases, Vec::new(), query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
|
||||||
|
/// aggregates.
|
||||||
|
pub fn merge_devices_with_observations(
|
||||||
|
neighbors: Vec<NeighborEntry>,
|
||||||
|
leases: Vec<DhcpLeaseWithState>,
|
||||||
|
observations: Vec<DeviceObservationFact>,
|
||||||
|
query: &InventoryQuery,
|
||||||
) -> Vec<Device> {
|
) -> Vec<Device> {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
|
type DeviceParts = (
|
||||||
|
Vec<NeighborEntry>,
|
||||||
|
Vec<DhcpLease>,
|
||||||
|
Vec<DeviceObservationFact>,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut by_key: BTreeMap<String, DeviceParts> = BTreeMap::new();
|
||||||
|
|
||||||
for row in neighbors {
|
for row in neighbors {
|
||||||
let key = row
|
let key = row
|
||||||
.mac
|
.mac
|
||||||
.map(|m| m.to_string())
|
.map(|m| m.to_string())
|
||||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
||||||
by_mac.entry(key).or_default().0.push(row);
|
by_key.entry(key).or_default().0.push(row);
|
||||||
}
|
}
|
||||||
for lease in leases {
|
for lease in leases {
|
||||||
let key = lease.lease_line.mac.to_string();
|
let key = lease.lease_line.mac.to_string();
|
||||||
by_mac.entry(key).or_default().1.push(lease.lease_line);
|
by_key.entry(key).or_default().1.push(lease.lease_line);
|
||||||
|
}
|
||||||
|
for observation in observations {
|
||||||
|
let key = observation
|
||||||
|
.mac
|
||||||
|
.map(|mac| mac.to_string())
|
||||||
|
.or_else(|| observation.ip.map(|ip| format!("ip:{ip}")))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
by_key.entry(key).or_default().2.push(observation);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut devices: Vec<Device> = by_mac
|
let mut devices: Vec<Device> = by_key
|
||||||
.into_values()
|
.into_values()
|
||||||
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
|
.map(|(neighbors, leases, observations)| {
|
||||||
|
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
@@ -90,6 +131,42 @@ pub fn merge_devices(
|
|||||||
devices
|
devices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn local_observation_to_fact(
|
||||||
|
observation: wakey_linux::dhcp::LocalDeviceObservation,
|
||||||
|
) -> Option<DeviceObservationFact> {
|
||||||
|
let mac = match observation.mac.as_deref() {
|
||||||
|
Some(raw) => match raw.parse() {
|
||||||
|
Ok(mac) => Some(mac),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
mac = raw,
|
||||||
|
error = %err,
|
||||||
|
"ignoring invalid observed mac in inventory"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if mac.is_none() && observation.ip.is_none() {
|
||||||
|
warn!(
|
||||||
|
kind = %observation.kind,
|
||||||
|
action = %observation.action,
|
||||||
|
"ignoring hook observation without mac or ip"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DeviceObservationFact {
|
||||||
|
kind: observation.kind,
|
||||||
|
action: observation.action,
|
||||||
|
mac,
|
||||||
|
ip: observation.ip,
|
||||||
|
hostname: observation.hostname,
|
||||||
|
first_seen_unix: Some(observation.first_seen_unix),
|
||||||
|
last_seen_unix: Some(observation.last_seen_unix),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fn presence_rank(presence: Presence) -> u8 {
|
const fn presence_rank(presence: Presence) -> u8 {
|
||||||
match presence {
|
match presence {
|
||||||
Presence::Online => 3,
|
Presence::Online => 3,
|
||||||
@@ -103,7 +180,7 @@ const fn presence_rank(presence: Presence) -> u8 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use wakey_core::{DhcpLease, InventoryQueryBuilder, NeighborState};
|
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
|
||||||
|
|
||||||
fn sample_neighbors() -> Vec<NeighborEntry> {
|
fn sample_neighbors() -> Vec<NeighborEntry> {
|
||||||
vec![NeighborEntry {
|
vec![NeighborEntry {
|
||||||
@@ -154,4 +231,75 @@ mod tests {
|
|||||||
let out = merge_devices(sample_neighbors(), sample_leases(), &query);
|
let out = merge_devices(sample_neighbors(), sample_leases(), &query);
|
||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_uses_ip_observed_id_when_mac_is_absent() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let out = merge_devices(
|
||||||
|
vec![NeighborEntry {
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
|
||||||
|
dev: Some("br-lan".to_string()),
|
||||||
|
mac: None,
|
||||||
|
state: NeighborState::Stale,
|
||||||
|
}],
|
||||||
|
Vec::new(),
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
out[0].id,
|
||||||
|
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_preserves_hook_observation_facts() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let out = merge_devices_with_observations(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec![DeviceObservationFact {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||||
|
hostname: Some("lda".into()),
|
||||||
|
first_seen_unix: Some(10),
|
||||||
|
last_seen_unix: Some(20),
|
||||||
|
}],
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].names, vec!["lda".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
out[0].id,
|
||||||
|
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
||||||
|
);
|
||||||
|
assert_eq!(out[0].observations.len(), 1);
|
||||||
|
assert_eq!(out[0].presence, Presence::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_marks_remove_only_observation_offline() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let out = merge_devices_with_observations(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec![DeviceObservationFact {
|
||||||
|
kind: "neigh".into(),
|
||||||
|
action: "remove".into(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||||
|
hostname: None,
|
||||||
|
first_seen_unix: Some(10),
|
||||||
|
last_seen_unix: Some(20),
|
||||||
|
}],
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].presence, Presence::Offline);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-8
@@ -1,16 +1,22 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||||
|
|
||||||
/// Read DHCP leases and optionally enrich them with current neighbor-state data.
|
/// Read DHCP leases from dnsmasq plus remembered hook names.
|
||||||
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
|
pub async fn get_leases() -> Result<Vec<DhcpLeaseWithState>> {
|
||||||
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||||
.await
|
.await
|
||||||
.context("failed to read DHCP leases")?;
|
.context("failed to read DHCP leases")?;
|
||||||
if query.include_state {
|
Ok(leases_without_state(leases))
|
||||||
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
}
|
||||||
} else {
|
|
||||||
Ok(leases_without_state(leases))
|
/// Read DHCP leases and enrich them with current neighbor state.
|
||||||
}
|
///
|
||||||
|
/// Prefer inventory for device status; this exists for the legacy leases view.
|
||||||
|
pub async fn get_leases_with_neighbor_state() -> Result<Vec<DhcpLeaseWithState>> {
|
||||||
|
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||||
|
.await
|
||||||
|
.context("failed to read DHCP leases")?;
|
||||||
|
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
||||||
|
|||||||
+5
-2
@@ -5,8 +5,11 @@ pub mod query;
|
|||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|
||||||
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
|
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
|
||||||
pub use inventory::{inventory, merge_devices, resolve_devices};
|
pub use inventory::{
|
||||||
pub use leases::{get_leases, leases_without_state};
|
inventory, local_observation_to_fact, merge_devices, merge_devices_with_observations,
|
||||||
|
resolve_devices,
|
||||||
|
};
|
||||||
|
pub use leases::{get_leases, get_leases_with_neighbor_state, leases_without_state};
|
||||||
pub use query::{resolve_query, resolve_selector};
|
pub use query::{resolve_query, resolve_selector};
|
||||||
pub use wake::{
|
pub use wake::{
|
||||||
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||||
|
|||||||
@@ -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
-4
@@ -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";
|
||||||
@@ -21,6 +23,8 @@ import { CommandsPage } from "@/pages/CommandsPage";
|
|||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { DevicesPage } from "@/pages/DevicesPage";
|
import { DevicesPage } from "@/pages/DevicesPage";
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||||
|
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -32,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([
|
||||||
@@ -64,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,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
|
||||||
@@ -139,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={
|
||||||
@@ -146,6 +166,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
@@ -194,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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+253
-1
@@ -72,6 +72,113 @@ export type SetAgentNicknameResponse = {
|
|||||||
updated: boolean;
|
updated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeviceIdentifier = {
|
||||||
|
identifier_key: string;
|
||||||
|
device_id: string;
|
||||||
|
kind: string;
|
||||||
|
value: string;
|
||||||
|
created_at_unix: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KnownDevice = {
|
||||||
|
device_id: string;
|
||||||
|
display_name: string;
|
||||||
|
pinned: boolean;
|
||||||
|
created_at_unix: number;
|
||||||
|
updated_at_unix: number;
|
||||||
|
notes: string | null;
|
||||||
|
identifiers: DeviceIdentifier[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KnownDeviceSummary = {
|
||||||
|
device_id: string;
|
||||||
|
display_name: string;
|
||||||
|
pinned: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentDeviceObservation = {
|
||||||
|
observation_key: string;
|
||||||
|
agent_id: string;
|
||||||
|
kind: string;
|
||||||
|
mac: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
first_seen_unix: number;
|
||||||
|
last_seen_unix: number;
|
||||||
|
last_action: string;
|
||||||
|
known_device: KnownDeviceSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentDeviceObservationEvent = {
|
||||||
|
event_id: string;
|
||||||
|
observation_key: string;
|
||||||
|
agent_id: string;
|
||||||
|
kind: string;
|
||||||
|
action: string;
|
||||||
|
mac: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
ts_unix: number;
|
||||||
|
known_device: KnownDeviceSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FleetDeviceAgent = {
|
||||||
|
agent_id: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
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,
|
||||||
@@ -154,6 +261,151 @@ export function setAgentNickname(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
||||||
|
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?: {
|
||||||
|
agentId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<AgentDeviceObservation[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||||
|
params.set("limit", String(opts?.limit ?? 500));
|
||||||
|
return request<AgentDeviceObservation[]>(
|
||||||
|
`/api/v1/control/observations?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchObservationHistory(opts?: {
|
||||||
|
agentId?: string;
|
||||||
|
kind?: string;
|
||||||
|
mac?: string;
|
||||||
|
ip?: string;
|
||||||
|
observationKey?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<AgentDeviceObservationEvent[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||||
|
if (opts?.kind) params.set("kind", opts.kind);
|
||||||
|
if (opts?.mac) params.set("mac", opts.mac);
|
||||||
|
if (opts?.ip) params.set("ip", opts.ip);
|
||||||
|
if (opts?.observationKey) params.set("observation_key", opts.observationKey);
|
||||||
|
params.set("limit", String(opts?.limit ?? 500));
|
||||||
|
return request<AgentDeviceObservationEvent[]>(
|
||||||
|
`/api/v1/control/observations/history?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKnownDevice(input: {
|
||||||
|
display_name: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
identifiers?: { kind: string; value: string }[];
|
||||||
|
}): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>("/api/v1/control/devices", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
display_name: input.display_name,
|
||||||
|
pinned: input.pinned ?? true,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
identifiers: input.identifiers ?? [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachObservationIdentifier(
|
||||||
|
deviceId: string,
|
||||||
|
observationKey: string,
|
||||||
|
): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>(
|
||||||
|
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/from-observation`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ observation_key: observationKey }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachDeviceIdentifier(
|
||||||
|
deviceId: string,
|
||||||
|
identifier: { kind: string; value: string },
|
||||||
|
): 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,
|
||||||
@@ -177,7 +429,7 @@ function buildCommandPayload(
|
|||||||
return { command: { kind: "devs", dev: null, up_only: false } };
|
return { command: { kind: "devs", dev: null, up_only: false } };
|
||||||
}
|
}
|
||||||
if (kind === "leases") {
|
if (kind === "leases") {
|
||||||
return { command: { kind: "leases", include_state: true } };
|
return { command: { kind: "leases", include_state: false } };
|
||||||
}
|
}
|
||||||
if (kind === "inventory") {
|
if (kind === "inventory") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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
-71
@@ -1,95 +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"],
|
|
||||||
["/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
||||||
+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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user