Compare commits
32
Commits
v0.2.0
..
fe6155da83
No files matched your search
+13
-2
@@ -1,8 +1,8 @@
|
|||||||
[target.armv7-unknown-linux-musleabihf]
|
[target.armv7-unknown-linux-musleabihf]
|
||||||
linker = "rust-lld"
|
linker = "rust-lld"
|
||||||
|
|
||||||
[target.aarch64-unknown-linux-gnu]
|
# [target.aarch64-unknown-linux-gnu]
|
||||||
linker = "aarch64-linux-gnu-gcc"
|
# linker = "aarch64-linux-gnu-gcc"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
# target = "armv7-unknown-linux-musleabihf"
|
# target = "armv7-unknown-linux-musleabihf"
|
||||||
@@ -16,7 +16,18 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
[registries.gitea]
|
||||||
|
index = "sparse+https://git.ldlda.com/api/packages/lda/cargo/" # Sparse index
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
SQLX_OFFLINE=true
|
||||||
@@ -7,6 +7,10 @@ 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"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
# My Machine has arm-musl stuff - or else i have to use cross :(
|
# My Machine has arm-musl stuff - or else i have to use cross :(
|
||||||
@@ -43,17 +47,32 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd ui
|
cd ui
|
||||||
# corepack enable # no no no
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
- name: Build control-plane (linux gnu)
|
- name: Build control-plane (linux-gnu; zig + older glibc when available)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
|
||||||
cargo build --release --target "$target" -p wakey-control-plane
|
GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
|
||||||
done
|
|
||||||
|
use_zig=false
|
||||||
|
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
|
||||||
|
use_zig=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$use_zig" = true ]; then
|
||||||
|
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
|
||||||
|
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
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,3 +2,4 @@ We work towards clean, modular, maintainable design, clear documentation... im n
|
|||||||
|
|
||||||
DRY: spawn subagents.
|
DRY: spawn subagents.
|
||||||
Context: break those files out.
|
Context: break those files out.
|
||||||
|
Test: make functions easy to test.
|
||||||
@@ -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/`.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --workspace --all-features
|
||||||
|
|
||||||
|
# Operator UI (`ui/`): same checks you’d run locally before a control-plane bundle.
|
||||||
|
ui:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ui
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: pnpm install
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: TypeScript (no emit)
|
||||||
|
run: pnpm run typecheck
|
||||||
|
|
||||||
|
- name: Prettier
|
||||||
|
run: pnpm run format:check
|
||||||
|
|
||||||
|
- name: Vite production build
|
||||||
|
run: pnpm run build
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
name: release
|
||||||
|
run-name: Release ${{ github.ref_name }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install cross
|
||||||
|
run: cargo install cross --locked
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build (armv7-musl with cross)
|
||||||
|
run: |
|
||||||
|
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||||
|
|
||||||
|
- name: Build UI dist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
- name: Build control-plane (linux-gnu)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# x86_64 builds natively; aarch64 needs cross
|
||||||
|
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
|
||||||
|
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
|
||||||
|
|
||||||
|
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
|
||||||
|
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
|
||||||
|
|
||||||
|
- name: Package rootfs tarball
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
|
||||||
|
|
||||||
|
- name: Package control-plane bundles
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
|
||||||
|
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||||
|
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-rootfs
|
||||||
|
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||||
|
|
||||||
|
- name: Upload control-plane bundles
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-cc-bundles
|
||||||
|
path: dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||||
|
|
||||||
|
- name: Publish Release to GitHub
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
files: |
|
||||||
|
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||||
|
dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||||
@@ -18,3 +18,5 @@ target/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.codex
|
||||||
+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 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": "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"
|
||||||
|
}
|
||||||
+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.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "event_id!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "observation_key!",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agent_id!",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "kind!",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "action!",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mac",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ip",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hostname",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ts_unix",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "device_id",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pinned",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
|
||||||
|
}
|
||||||
+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
+765
-13
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.0"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,20 @@ service-first project with:
|
|||||||
In practice that means commands like:
|
In practice that means commands like:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wakey status bedroom-pc
|
wakey inventory bedroom-pc
|
||||||
wakey leases --include-state
|
wakey leases --include-state
|
||||||
wakey devs
|
wakey devs
|
||||||
wakey wake bedroom-pc
|
wakey wake bedroom-pc
|
||||||
wakey wake --mac aa:bb:cc:dd:ee:ff
|
wakey wake --mac aa:bb:cc:dd:ee:ff
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(`inventory` is the canonical subcommand; `status` is a visible alias for the same behavior.)
|
||||||
|
|
||||||
|
## Architecture at a glance
|
||||||
|
|
||||||
|
- **On-router (local):** run `wakey` for `inventory`, `leases`, `devs`, and `wake` against live Linux neighbor/DHCP/interface data.
|
||||||
|
- **Fleet (remote):** `wakey-agent` on each router enrolls with `wakey-control-plane`, keeps an authenticated WebSocket, and executes relayed commands using the same service logic as the local CLI.
|
||||||
|
|
||||||
## Workspace layout
|
## Workspace layout
|
||||||
|
|
||||||
- `wakey-core`
|
- `wakey-core`
|
||||||
@@ -54,7 +61,7 @@ Inside the `wakey` crate:
|
|||||||
|
|
||||||
- `src/service`
|
- `src/service`
|
||||||
- the real use-case layer
|
- the real use-case layer
|
||||||
- status, leases, inventory, interfaces, wake, and query resolution
|
- inventory, leases, interfaces, wake, and query resolution
|
||||||
|
|
||||||
The long-term direction is:
|
The long-term direction is:
|
||||||
|
|
||||||
@@ -118,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
|
||||||
@@ -133,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
|
||||||
@@ -214,7 +226,7 @@ agent enrollment and websocket endpoints reachable.
|
|||||||
|
|
||||||
An example Caddy config is provided at:
|
An example Caddy config is provided at:
|
||||||
|
|
||||||
- `deploy/Caddyfile.control-plane.example`
|
- `deploy/control-plane.Caddyfile`
|
||||||
|
|
||||||
Expected exposure model:
|
Expected exposure model:
|
||||||
|
|
||||||
@@ -285,22 +297,24 @@ The update tarball is expected to contain:
|
|||||||
|
|
||||||
`wakey` is usable as a local/operator CLI.
|
`wakey` is usable as a local/operator CLI.
|
||||||
|
|
||||||
### Status
|
### Inventory
|
||||||
|
|
||||||
Show device status rows using a free-form selector:
|
Show merged device inventory rows using a free-form selector:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wakey status bedroom-pc
|
wakey inventory bedroom-pc
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use explicit filters:
|
Or use explicit filters:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wakey status --dev br-lan --nud reachable
|
wakey inventory --dev br-lan --nud reachable
|
||||||
wakey status --mac aa:bb:cc:dd:ee:ff
|
wakey inventory --mac aa:bb:cc:dd:ee:ff
|
||||||
wakey status --json
|
wakey inventory --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `status` subcommand is an alias for `inventory` (same flags and output).
|
||||||
|
|
||||||
### Leases
|
### Leases
|
||||||
|
|
||||||
Show DHCP leases:
|
Show DHCP leases:
|
||||||
@@ -359,6 +373,10 @@ cargo test --no-run
|
|||||||
cargo clippy --all-targets --all-features -- -D warnings
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On GitHub Actions (`.github/workflows/ci.yml`), the same Rust checks run on
|
||||||
|
`ubuntu-latest`, plus a separate **`ui`** job: `pnpm install --frozen-lockfile`
|
||||||
|
in `ui/`, then `typecheck`, `format:check`, and `vite build`.
|
||||||
|
|
||||||
Focused control-plane state tests:
|
Focused control-plane state tests:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
#
|
#
|
||||||
# Replace cp.example.com with your public control-plane domain.
|
# Replace cp.example.com with your public control-plane domain.
|
||||||
|
|
||||||
cp.example.com {
|
wakey.ldlda.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Public agent-facing endpoints.
|
# Public agent-facing endpoints.
|
||||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
||||||
handle @public {
|
handle @public {
|
||||||
reverse_proxy 127.0.0.1:8787
|
reverse_proxy 127.0.0.1:6767
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admin surface requires Cloudflare Access headers.
|
# Admin surface requires Cloudflare Access headers.
|
||||||
@@ -21,7 +21,7 @@ cp.example.com {
|
|||||||
|
|
||||||
handle @admin {
|
handle @admin {
|
||||||
handle @cf_access {
|
handle @cf_access {
|
||||||
reverse_proxy 127.0.0.1:8787
|
reverse_proxy 127.0.0.1:6767
|
||||||
}
|
}
|
||||||
respond "forbidden" 403
|
respond "forbidden" 403
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=wakey
|
User=lda
|
||||||
Group=wakey
|
Group=lda
|
||||||
WorkingDirectory=/opt/wakey
|
WorkingDirectory=/opt/wakey
|
||||||
ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
|
ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lda-ipjs"
|
name = "lda-ipjs"
|
||||||
description = "ip -j show schemas"
|
description = "ip -j show schemas"
|
||||||
version = "0.0.3"
|
version = "0.0.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ pub async fn get_with_handle(
|
|||||||
|
|
||||||
for attr in msg.attributes {
|
for attr in msg.attributes {
|
||||||
match attr {
|
match attr {
|
||||||
AddressAttribute::Address(addr) | AddressAttribute::Local(addr) => {
|
AddressAttribute::Address(addr) | AddressAttribute::Local(addr)
|
||||||
if local.is_none() {
|
if local.is_none() =>
|
||||||
local = addr.to_string().parse().ok();
|
{
|
||||||
}
|
local = addr.to_string().parse().ok();
|
||||||
}
|
}
|
||||||
AddressAttribute::Broadcast(addr) => {
|
AddressAttribute::Broadcast(addr) => {
|
||||||
broadcast = addr.to_string().parse().ok();
|
broadcast = addr.to_string().parse().ok();
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(link.operstate, Some(OperState::Up));
|
assert_eq!(link.operstate, Some(OperState::Up));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
link.address.map(|mac| mac.to_string()).as_deref(),
|
link.address
|
||||||
|
.map(|mac| mac.to_string().to_ascii_lowercase())
|
||||||
|
.as_deref(),
|
||||||
Some("aa:bb:cc:dd:ee:ff")
|
Some("aa:bb:cc:dd:ee:ff")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-4
@@ -45,6 +45,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
|||||||
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
||||||
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
||||||
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
|
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
|
||||||
|
- `update_wakey_cc_from_repo.sh` — Linux VPS source updater; clones repo to temp dir, builds UI + control-plane locally, packages bundle tgz, installs into selected directory, and restarts `wakey-cc.service`.
|
||||||
- `package_wakey_cc_bundle.sh` — Produces `dist/wakey-cc-<version>-<target>.tgz` with `bin/wakey-control-plane`, `ui/dist/*`, updater script, and systemd template.
|
- `package_wakey_cc_bundle.sh` — Produces `dist/wakey-cc-<version>-<target>.tgz` with `bin/wakey-control-plane`, `ui/dist/*`, updater script, and systemd template.
|
||||||
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
|
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
|
||||||
- `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
|
- `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
|
||||||
@@ -61,6 +62,20 @@ WAKEY_CC_VERSION=v0.2.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu \
|
|||||||
sudo -E ./scripts/update_wakey_cc.sh
|
sudo -E ./scripts/update_wakey_cc.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## VPS source updater (control-plane)
|
||||||
|
|
||||||
|
Build-and-install locally on the VPS (avoids glibc mismatch from prebuilt binaries):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x ./scripts/update_wakey_cc_from_repo.sh
|
||||||
|
cd /opt/wakey
|
||||||
|
sudo -E /path/to/repo/scripts/update_wakey_cc_from_repo.sh
|
||||||
|
|
||||||
|
# optional: pin ref/target and skip restart
|
||||||
|
WAKEY_CC_REF=main WAKEY_CC_TARGET=x86_64-unknown-linux-gnu WAKEY_CC_NO_RESTART=1 \
|
||||||
|
sudo -E /path/to/repo/scripts/update_wakey_cc_from_repo.sh
|
||||||
|
```
|
||||||
|
|
||||||
Expected tarball layout:
|
Expected tarball layout:
|
||||||
|
|
||||||
- `bin/wakey-control-plane`
|
- `bin/wakey-control-plane`
|
||||||
@@ -79,7 +94,7 @@ Basic examples:
|
|||||||
```powershell
|
```powershell
|
||||||
./scripts/test_remote.ps1 -Package wakey
|
./scripts/test_remote.ps1 -Package wakey
|
||||||
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
|
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
|
||||||
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Filter status_real_router_default_query_succeeds -Ignored -NoCapture
|
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Filter inventory_real_router_default_query_returns_rows_or_empty_cleanly -Ignored -NoCapture
|
||||||
./scripts/test_remote.ps1 -AllPackages
|
./scripts/test_remote.ps1 -AllPackages
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -113,13 +128,26 @@ Implementation notes:
|
|||||||
- each package run gets a unique remote temp directory for safer concurrent use
|
- each package run gets a unique remote temp directory for safer concurrent use
|
||||||
- packages with no test executables are skipped instead of treated as failures
|
- packages with no test executables are skipped instead of treated as failures
|
||||||
|
|
||||||
|
## Release checklist (tag push)
|
||||||
|
|
||||||
|
1. **Version:** bump crate versions / changelog as you prefer; tag `v*` and push.
|
||||||
|
2. **Runner:** Fedora/WSL self-hosted labels `self-hosted`, `linux`, `fedora`, `wsl`, `release` online (see [`WSL_RUNNER.md`](./WSL_RUNNER.md)).
|
||||||
|
3. **Control-plane bundle:** release job builds `ui/dist`, then `wakey-control-plane` for `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`. With **zig** + **cargo-zigbuild** on the runner, gnu builds use workflow env `WAKEY_CC_GLIBC_VERSION` (default `2.28`) for a predictable glibc floor; without them, binaries match the host glibc.
|
||||||
|
4. **Artifacts:** expect `dist/wakey-rootfs-*-armv7-unknown-linux-musleabihf.tgz` and `dist/wakey-cc-*-unknown-linux-gnu.tgz` attached to the Gitea release.
|
||||||
|
5. **Secret:** `GITEA_TOKEN` must be set for the publish step.
|
||||||
|
|
||||||
## CI (Gitea)
|
## CI (Gitea)
|
||||||
|
|
||||||
- Defined in `.gitea/workflows/release.yml`.
|
- Defined in `.gitea/workflows/release.yml` (tag / manual dispatch): router rootfs + control-plane bundles and Gitea release attach.
|
||||||
- Release job now targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
|
- Release job targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
|
||||||
- Steps: Linux-native build of `wakey` + `wakey-agent` → package rootfs → upload artifact (v3) → publish a release and attach tgz.
|
|
||||||
- Requires `secrets.GITEA_TOKEN` in the repo to publish the release.
|
- Requires `secrets.GITEA_TOKEN` in the repo to publish the release.
|
||||||
|
|
||||||
|
## CI (GitHub)
|
||||||
|
|
||||||
|
- Defined in `.github/workflows/ci.yml` on pushes/PRs to `main` or `master`:
|
||||||
|
- **`rust` job:** `cargo fmt --check`, `clippy -D warnings`, `cargo test --workspace` on `ubuntu-latest`.
|
||||||
|
- **`ui` job:** under `ui/`, `pnpm install --frozen-lockfile`, then `pnpm run typecheck`, `format:check`, and `build` (mirrors what you need before packaging the control-plane bundle). GitHub sets `CI=true` for the runner; pnpm uses lockfile `ui/pnpm-lock.yaml` for cache.
|
||||||
|
|
||||||
## Runner migration
|
## Runner migration
|
||||||
|
|
||||||
- Fedora/WSL is now the intended release-builder path.
|
- Fedora/WSL is now the intended release-builder path.
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$IPADDR" ]; then
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$HOSTNAME" ]; then
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
|
||||||
|
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$MACADDR" ]; then
|
||||||
|
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$IPADDR" ]; then
|
||||||
|
"$BIN" observe neigh --action "$ACTION" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
"$BIN" observe neigh --action "$ACTION" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
@@ -71,6 +71,21 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# 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) {
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ fi
|
|||||||
|
|
||||||
[ -f "$BINARY" ] || { echo "missing binary: $BINARY" >&2; exit 1; }
|
[ -f "$BINARY" ] || { echo "missing binary: $BINARY" >&2; exit 1; }
|
||||||
[ -f "$UI_DIST/index.html" ] || { echo "missing UI dist index: $UI_DIST/index.html" >&2; exit 1; }
|
[ -f "$UI_DIST/index.html" ] || { echo "missing UI dist index: $UI_DIST/index.html" >&2; exit 1; }
|
||||||
[ -f "scripts/update_wakey_cc.sh" ] || { echo "missing updater script" >&2; exit 1; }
|
[ -f "scripts/update_wakey_cc.sh" ] && [ -f "scripts/update_wakey_cc_from_repo.sh" ] || { echo "missing updater scripts" >&2; exit 1; }
|
||||||
[ -f "deploy/systemd/wakey-cc.service" ] || { echo "missing systemd template" >&2; exit 1; }
|
[ -f "deploy/systemd/wakey-cc.service" ] || { echo "missing systemd template" >&2; exit 1; }
|
||||||
|
[ -f "deploy/control-plane.Caddyfile" ] || { echo "missing Caddyfile" >&2; exit 1; }
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
STAGING="$OUT_DIR/wakey-cc-stage.$$"
|
STAGING="$OUT_DIR/wakey-cc-stage.$$"
|
||||||
@@ -75,9 +76,11 @@ mkdir -p "$STAGING/bin" "$STAGING/ui" "$STAGING/scripts" "$STAGING/deploy/system
|
|||||||
cp "$BINARY" "$STAGING/bin/wakey-control-plane"
|
cp "$BINARY" "$STAGING/bin/wakey-control-plane"
|
||||||
cp -a "$UI_DIST" "$STAGING/ui/dist"
|
cp -a "$UI_DIST" "$STAGING/ui/dist"
|
||||||
cp "scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc.sh"
|
cp "scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc.sh"
|
||||||
|
cp "scripts/update_wakey_cc_from_repo.sh" "$STAGING/scripts/update_wakey_cc_from_repo.sh"
|
||||||
cp "deploy/systemd/wakey-cc.service" "$STAGING/deploy/systemd/wakey-cc.service"
|
cp "deploy/systemd/wakey-cc.service" "$STAGING/deploy/systemd/wakey-cc.service"
|
||||||
|
cp "deploy/control-plane.Caddyfile" "$STAGING/deploy/control-plane.Caddyfile"
|
||||||
|
|
||||||
chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh"
|
chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc_from_repo.sh" "$STAGING/deploy/control-plane.Caddyfile"
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$STAGING"
|
cd "$STAGING"
|
||||||
@@ -87,4 +90,4 @@ chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh
|
|||||||
rm -rf "$STAGING"
|
rm -rf "$STAGING"
|
||||||
|
|
||||||
echo "Bundle package: $OUT_DIR/$PKG"
|
echo "Bundle package: $OUT_DIR/$PKG"
|
||||||
echo "Contains: bin/wakey-control-plane, ui/dist, scripts/update_wakey_cc.sh, deploy/systemd/wakey-cc.service"
|
echo "Contains: bin/wakey-control-plane, ui/dist, scripts/update_wakey_cc.sh, scripts/update_wakey_cc_from_repo.sh, deploy/systemd/wakey-cc.service, deploy/control-plane.Caddyfile"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||||
Executable
+155
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Build and install wakey-control-plane from repository source on the target host.
|
||||||
|
# This avoids glibc baseline mismatches from prebuilt artifacts by building locally,
|
||||||
|
# then packaging the same bundle format and installing it.
|
||||||
|
#
|
||||||
|
# Simplest use:
|
||||||
|
# sudo -E ./scripts/update_wakey_cc_from_repo.sh
|
||||||
|
#
|
||||||
|
# Env options:
|
||||||
|
# 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_TARGET Rust target triple (default from uname)
|
||||||
|
# WAKEY_CC_ROOT Install root (default: current directory)
|
||||||
|
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
|
||||||
|
# WAKEY_CC_VERSION Bundle version label (default: v0.0.0-local-<short_sha>)
|
||||||
|
# WAKEY_CC_NO_RESTART If set, skip systemd restart
|
||||||
|
# WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging
|
||||||
|
# SUDO Privilege escalation command/path (default: sudo when needed)
|
||||||
|
#
|
||||||
|
# Requires: git, cargo, tar, pnpm, systemctl (optional for restart)
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; }
|
||||||
|
fail() {
|
||||||
|
printf '[update_wakey_cc_from_repo] ERROR: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
default_target() {
|
||||||
|
arch=$(uname -m)
|
||||||
|
case "$arch" in
|
||||||
|
x86_64)
|
||||||
|
printf '%s' 'x86_64-unknown-linux-gnu'
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
printf '%s' 'aarch64-unknown-linux-gnu'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unsupported arch for default target mapping: $arch (set WAKEY_CC_TARGET explicitly)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
sudo_cmd() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${SUDO:-}" ]; then
|
||||||
|
printf '%s' "$SUDO"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
printf '%s' sudo
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail 'root privileges are required for install/restart steps; set SUDO or run as root'
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
|
||||||
|
REF="${WAKEY_CC_REF:-main}"
|
||||||
|
TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
|
||||||
|
ROOT="${WAKEY_CC_ROOT:-$PWD}"
|
||||||
|
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
|
||||||
|
SUDO_BIN=$(sudo_cmd)
|
||||||
|
|
||||||
|
require_cmd git
|
||||||
|
require_cmd cargo
|
||||||
|
require_cmd tar
|
||||||
|
require_cmd pnpm
|
||||||
|
|
||||||
|
TMPBASE="${TMPDIR:-/tmp}"
|
||||||
|
WORKDIR="$TMPBASE/wakey-cc-src.$$"
|
||||||
|
STAGING="$TMPBASE/wakey-cc-stage.$$"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
|
||||||
|
rm -rf "$WORKDIR" "$STAGING"
|
||||||
|
else
|
||||||
|
log "keeping temp dir: $WORKDIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
log "cloning $REPO_URL (ref: $REF)"
|
||||||
|
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||||
|
fail 'git clone failed'
|
||||||
|
|
||||||
|
cd "$WORKDIR"
|
||||||
|
|
||||||
|
log 'building UI dist'
|
||||||
|
(
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
)
|
||||||
|
|
||||||
|
log "building control-plane target $TARGET"
|
||||||
|
cargo build --release --target "$TARGET" -p wakey-control-plane ||
|
||||||
|
fail 'cargo build failed'
|
||||||
|
|
||||||
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
VERSION="${WAKEY_CC_VERSION:-v0.0.0-local-$SHA}"
|
||||||
|
|
||||||
|
log "packaging bundle version $VERSION"
|
||||||
|
./scripts/package_wakey_cc_bundle.sh --version "$VERSION" --target "$TARGET" ||
|
||||||
|
fail 'bundle packaging failed'
|
||||||
|
|
||||||
|
PKG="dist/wakey-cc-$VERSION-$TARGET.tgz"
|
||||||
|
[ -f "$PKG" ] || fail "bundle not found: $PKG"
|
||||||
|
|
||||||
|
rm -rf "$STAGING"
|
||||||
|
mkdir -p "$STAGING"
|
||||||
|
tar -xzf "$PKG" -C "$STAGING" || fail 'bundle extract failed'
|
||||||
|
|
||||||
|
[ -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'
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
||||||
|
$SUDO_BIN mkdir -p "$ROOT"
|
||||||
|
$SUDO_BIN cp -a "$STAGING/." "$ROOT/"
|
||||||
|
else
|
||||||
|
mkdir -p "$ROOT"
|
||||||
|
cp -a "$STAGING/." "$ROOT/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
log "restarting $SERVICE"
|
||||||
|
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
||||||
|
$SUDO_BIN systemctl daemon-reload
|
||||||
|
$SUDO_BIN systemctl restart "$SERVICE"
|
||||||
|
$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
|
||||||
|
log "service $SERVICE not installed; skipped restart"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "done: installed into $ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+66
-6
@@ -25,6 +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", 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),
|
||||||
@@ -32,12 +33,17 @@ 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)]
|
||||||
@@ -115,6 +121,42 @@ pub struct DevsArgs {
|
|||||||
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)))
|
||||||
@@ -162,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 {
|
||||||
@@ -205,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(())
|
||||||
|
|||||||
+8
-3
@@ -1,12 +1,17 @@
|
|||||||
|
//! Operator-facing service API: inventory, leases, interfaces, wake, and query resolution.
|
||||||
|
//! The `wakey` binary is Linux-only; use `wakey-agent` / `wakey-control-plane` for remote control.
|
||||||
|
|
||||||
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, query_to_inventory_query, resolve_devices,
|
get_leases_with_neighbor_state, inventory, leases_without_state, local_observation_to_fact,
|
||||||
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query,
|
merge_devices, merge_devices_with_observations, resolve_devices, resolve_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 {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ use clap::Parser;
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
Err(anyhow::anyhow!(
|
Err(anyhow::anyhow!(
|
||||||
"OS not supported! run this on your ahh router!"
|
"wakey only runs on Linux (router/OpenWrt-style targets)."
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-44
@@ -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,30 +51,57 @@ 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<&str> = Vec::new();
|
let mut texts = Vec::new();
|
||||||
let mut devs: Vec<&str> = Vec::new();
|
let mut devs = Vec::new();
|
||||||
let mut ips = Vec::new();
|
let mut ips = Vec::new();
|
||||||
let mut macs = Vec::new();
|
let mut macs = Vec::new();
|
||||||
let mut nuds = Vec::new();
|
let mut nuds = Vec::new();
|
||||||
@@ -72,35 +113,16 @@ pub fn merge_devices(
|
|||||||
Query::Ip(v) => ips.push(*v),
|
Query::Ip(v) => ips.push(*v),
|
||||||
Query::Mac(v) => macs.push(*v),
|
Query::Mac(v) => macs.push(*v),
|
||||||
Query::NeighborState(v) => nuds.push(*v),
|
Query::NeighborState(v) => nuds.push(*v),
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if !texts.is_empty() {
|
|
||||||
devices.retain(|device| device.names.iter().any(|n| texts.iter().any(|t| n == t)));
|
|
||||||
}
|
|
||||||
if !devs.is_empty() {
|
|
||||||
devices.retain(|device| {
|
|
||||||
device
|
|
||||||
.interfaces
|
|
||||||
.iter()
|
|
||||||
.any(|iface| devs.iter().any(|d| iface == d))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !ips.is_empty() {
|
|
||||||
devices.retain(|device| device.ips.iter().any(|ip| ips.contains(ip)));
|
|
||||||
}
|
|
||||||
if !macs.is_empty() {
|
|
||||||
devices.retain(|device| device.macs.iter().any(|mac| macs.contains(mac)));
|
|
||||||
}
|
|
||||||
if !nuds.is_empty() {
|
|
||||||
devices.retain(|device| {
|
|
||||||
device
|
|
||||||
.neighbors
|
|
||||||
.iter()
|
|
||||||
.any(|neighbor| nuds.contains(&neighbor.state))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
devices.retain(|device| {
|
||||||
|
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
|
||||||
|
&& (devs.is_empty() || device.interfaces.iter().any(|i| devs.contains(&i.as_str()))) // same pattern as iter().any
|
||||||
|
&& (ips.is_empty() || device.ips.iter().any(|ip| ips.contains(ip)))
|
||||||
|
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
||||||
|
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
||||||
|
});
|
||||||
devices.sort_by(|a, b| {
|
devices.sort_by(|a, b| {
|
||||||
presence_rank(b.presence)
|
presence_rank(b.presence)
|
||||||
.cmp(&presence_rank(a.presence))
|
.cmp(&presence_rank(a.presence))
|
||||||
@@ -109,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,
|
||||||
@@ -122,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 {
|
||||||
@@ -173,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.
|
||||||
|
|||||||
+6
-3
@@ -5,9 +5,12 @@ 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,
|
||||||
pub use query::{query_to_inventory_query, resolve_query, resolve_selector};
|
resolve_devices,
|
||||||
|
};
|
||||||
|
pub use leases::{get_leases, get_leases_with_neighbor_state, leases_without_state};
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
+1
-12
@@ -2,11 +2,8 @@ use anyhow::Result;
|
|||||||
use wakey_core::{InventoryQuery, Query, QueryInput};
|
use wakey_core::{InventoryQuery, Query, QueryInput};
|
||||||
|
|
||||||
/// Resolve free-form user input into an `InventoryQuery` filter shape.
|
/// Resolve free-form user input into an `InventoryQuery` filter shape.
|
||||||
///
|
|
||||||
/// This is the compatibility entrypoint used by CLI and HTTP paths that still
|
|
||||||
/// speak in terms of query/filter payloads.
|
|
||||||
pub async fn resolve_query(input: impl Into<String>) -> Result<InventoryQuery> {
|
pub async fn resolve_query(input: impl Into<String>) -> Result<InventoryQuery> {
|
||||||
query_to_inventory_query(resolve_selector(input).await?)
|
Ok(vec![resolve_selector(input).await?])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify one piece of free-form user input into a typed selector.
|
/// Classify one piece of free-form user input into a typed selector.
|
||||||
@@ -24,11 +21,3 @@ pub async fn resolve_selector(input: impl Into<String>) -> Result<Query> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert the newer selector-oriented `Query` model into an `InventoryQuery`.
|
|
||||||
///
|
|
||||||
/// This keeps the old filter-based service and HTTP surfaces working while the
|
|
||||||
/// internals migrate toward selector- and device-oriented APIs.
|
|
||||||
pub fn query_to_inventory_query(query: Query) -> Result<InventoryQuery> {
|
|
||||||
Ok(vec![query])
|
|
||||||
}
|
|
||||||
+9
-7
@@ -6,6 +6,7 @@ use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
|
|||||||
|
|
||||||
use crate::service::interfaces::get_interface_summaries;
|
use crate::service::interfaces::get_interface_summaries;
|
||||||
use crate::service::inventory::resolve_devices;
|
use crate::service::inventory::resolve_devices;
|
||||||
|
use crate::utils::product;
|
||||||
|
|
||||||
/// Send Wake-on-LAN packets for already-concrete wake targets.
|
/// Send Wake-on-LAN packets for already-concrete wake targets.
|
||||||
#[instrument(skip_all, fields(targets = targets.len()))]
|
#[instrument(skip_all, fields(targets = targets.len()))]
|
||||||
@@ -50,14 +51,15 @@ pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResul
|
|||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
|
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
|
||||||
let devices = resolve_devices(input).await?;
|
let devices = resolve_devices(input).await?;
|
||||||
|
|
||||||
let targets: Vec<WakeTarget> = devices
|
let targets: Vec<WakeTarget> = devices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|device| {
|
.flat_map(|device| {
|
||||||
let mac = device.macs.first().copied();
|
// TRY EVERYTHING some has to work
|
||||||
device
|
product(device.ips, device.macs).map(|(i, m)| WakeTarget {
|
||||||
.ips
|
ip: Some(i),
|
||||||
.into_iter()
|
mac: Some(m),
|
||||||
.map(move |ip| WakeTarget { ip: Some(ip), mac })
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
debug!(targets = targets.len(), "resolved wake targets");
|
debug!(targets = targets.len(), "resolved wake targets");
|
||||||
@@ -79,8 +81,8 @@ fn broadcast_wake_targets_from_interfaces(
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|iface| iface.addrs.iter())
|
.flat_map(|iface| iface.addrs.iter())
|
||||||
.filter_map(|addr| addr.broadcast)
|
.filter_map(|addr| addr.broadcast)
|
||||||
.map(|ip| WakeTarget {
|
.map(|br| WakeTarget {
|
||||||
ip: Some(IpAddr::V4(ip)),
|
ip: Some(IpAddr::V4(br)), // some will hit eventually AHHH logic
|
||||||
mac: Some(mac),
|
mac: Some(mac),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
+15
-1
@@ -1 +1,15 @@
|
|||||||
// Intentionally left empty.
|
//! rare fns that belong nowhere
|
||||||
|
|
||||||
|
/// absolute dih cheese
|
||||||
|
/// took some inspo from itertools
|
||||||
|
pub fn product<A, B, T1, T2>(i1: T1, i2: T2) -> impl Iterator<Item = (A, B)>
|
||||||
|
where
|
||||||
|
A: Clone,
|
||||||
|
T1: IntoIterator<Item = A>,
|
||||||
|
T2: IntoIterator<Item = B>,
|
||||||
|
T2::IntoIter: Clone,
|
||||||
|
{
|
||||||
|
let i2 = i2.into_iter();
|
||||||
|
i1.into_iter()
|
||||||
|
.flat_map(move |i1i| i2.clone().map(move |i2i| (i1i.clone(), i2i)))
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { AuditPage } from "@/pages/AuditPage";
|
|||||||
import { CommandsPage } from "@/pages/CommandsPage";
|
import { CommandsPage } from "@/pages/CommandsPage";
|
||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { DevicesPage } from "@/pages/DevicesPage";
|
import { DevicesPage } from "@/pages/DevicesPage";
|
||||||
|
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
@@ -151,6 +152,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="observations" element={<ObservationsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="agents"
|
path="agents"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+117
-1
@@ -72,6 +72,56 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
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 +204,72 @@ export function setAgentNickname(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
||||||
|
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 runCommand(
|
export function runCommand(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
kind: CommandKind,
|
kind: CommandKind,
|
||||||
@@ -177,7 +293,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,157 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 end-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
["/", "Devices"],
|
["/", "Devices"],
|
||||||
|
["/observations", "Observed"],
|
||||||
["/commands", "Wake Tools"],
|
["/commands", "Wake Tools"],
|
||||||
["/dashboard", "Fleet Health"],
|
["/dashboard", "Fleet Health"],
|
||||||
["/agents", "Agents"],
|
["/agents", "Agents"],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
|
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
|
||||||
|
import { DeviceDetailsDialog } from "@/pages/devices/DeviceDetailsDialog";
|
||||||
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
|
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
|
||||||
import type {
|
import type {
|
||||||
DeviceRow,
|
DeviceRow,
|
||||||
@@ -112,6 +113,7 @@ export function DevicesPage({
|
|||||||
const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
|
const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
|
||||||
const [quickWake, setQuickWake] = useState("");
|
const [quickWake, setQuickWake] = useState("");
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [detailsDevice, setDetailsDevice] = useState<DeviceRow | null>(null);
|
||||||
const [copyStatus, setCopyStatus] = useState("");
|
const [copyStatus, setCopyStatus] = useState("");
|
||||||
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
||||||
function appendWakeEvent(event: WakeEvent) {
|
function appendWakeEvent(event: WakeEvent) {
|
||||||
@@ -288,9 +290,17 @@ export function DevicesPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Sort
|
// Sort
|
||||||
const sorter = sorters[sort.key];
|
if (sort.key === "name") {
|
||||||
result = [...result].sort(sorter);
|
result = [...result].sort((a, b) => {
|
||||||
if (sort.dir === "desc") result.reverse();
|
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;
|
return result;
|
||||||
}, [rows, query, sort, presenceFilter]);
|
}, [rows, query, sort, presenceFilter]);
|
||||||
|
|
||||||
@@ -342,7 +352,7 @@ export function DevicesPage({
|
|||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<Select
|
<Select
|
||||||
value={selectedAgentId}
|
value={selectedAgentId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value: string | null) => {
|
||||||
if (value) onSelectAgent(value);
|
if (value) onSelectAgent(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -505,6 +515,14 @@ export function DevicesPage({
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
onWakeDevice={(device) => void wakeDevice(device)}
|
onWakeDevice={(device) => void wakeDevice(device)}
|
||||||
onCopyValue={(label, value) => void copyValue(label, value)}
|
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>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type Props = {
|
|||||||
onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
|
onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
|
||||||
onWakeDevice: (device: DeviceRow) => void;
|
onWakeDevice: (device: DeviceRow) => void;
|
||||||
onCopyValue: (label: string, value: string) => void;
|
onCopyValue: (label: string, value: string) => void;
|
||||||
|
onOpenDetails: (device: DeviceRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sortArrow(active: boolean, dir: SortDir) {
|
function sortArrow(active: boolean, dir: SortDir) {
|
||||||
@@ -48,7 +49,13 @@ export function DeviceInventoryTable({
|
|||||||
onSortChange,
|
onSortChange,
|
||||||
onWakeDevice,
|
onWakeDevice,
|
||||||
onCopyValue,
|
onCopyValue,
|
||||||
|
onOpenDetails,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
return Boolean(target.closest("button, input, a, [role='menuitem']"));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-list grid gap-2">
|
<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">
|
<div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||||
@@ -85,14 +92,26 @@ export function DeviceInventoryTable({
|
|||||||
>
|
>
|
||||||
Presence {sortArrow(sort.key === "presence", sort.dir)}
|
Presence {sortArrow(sort.key === "presence", sort.dir)}
|
||||||
</span>
|
</span>
|
||||||
<span className="device-cell">Interfaces</span>
|
|
||||||
<span className="device-cell device-action">Actions</span>
|
<span className="device-cell device-action">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<div
|
<div
|
||||||
className="device-row rounded-md border bg-card px-3 py-2 text-sm"
|
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}
|
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">
|
<span className="device-cell device-select" data-label="Pick">
|
||||||
<input
|
<input
|
||||||
@@ -122,13 +141,6 @@ export function DeviceInventoryTable({
|
|||||||
<span className="device-cell" data-label="Presence">
|
<span className="device-cell" data-label="Presence">
|
||||||
<Badge variant="outline">{row.presence}</Badge>
|
<Badge variant="outline">{row.presence}</Badge>
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
className="device-cell"
|
|
||||||
data-label="Interfaces"
|
|
||||||
title={row.interfaces.join(", ") || "-"}
|
|
||||||
>
|
|
||||||
{summarize(row.interfaces)}
|
|
||||||
</span>
|
|
||||||
<span className="device-cell device-action" data-label="">
|
<span className="device-cell device-action" data-label="">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onWakeDevice(row)}
|
onClick={() => onWakeDevice(row)}
|
||||||
|
|||||||
@@ -52,11 +52,11 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
|
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
|
||||||
minmax(100px, 0.85fr) minmax(100px, 0.9fr) minmax(170px, max-content);
|
minmax(100px, 0.85fr) minmax(170px, max-content);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 0.4rem;
|
column-gap: 0.4rem;
|
||||||
row-gap: 0.55rem;
|
row-gap: 0.55rem;
|
||||||
min-width: 980px;
|
min-width: 860px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-header {
|
.device-header {
|
||||||
@@ -111,8 +111,7 @@
|
|||||||
"name name"
|
"name name"
|
||||||
"presence presence"
|
"presence presence"
|
||||||
"ip ip"
|
"ip ip"
|
||||||
"mac mac"
|
"mac mac";
|
||||||
"interfaces interfaces";
|
|
||||||
align-items: start;
|
align-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -162,10 +161,6 @@
|
|||||||
grid-area: mac;
|
grid-area: mac;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-row .device-cell[data-label="Interfaces"] {
|
|
||||||
grid-area: interfaces;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row .device-action {
|
.device-row .device-action {
|
||||||
grid-area: action;
|
grid-area: action;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type DeviceRow = {
|
export type DeviceRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
isUnnamed: boolean;
|
||||||
ips: string[];
|
ips: string[];
|
||||||
macs: string[];
|
macs: string[];
|
||||||
interfaces: string[];
|
interfaces: string[];
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ export function parseInventoryRows(payload: unknown): DeviceRow[] {
|
|||||||
: [];
|
: [];
|
||||||
const presence =
|
const presence =
|
||||||
typeof device.presence === "string" ? device.presence : "unknown";
|
typeof device.presence === "string" ? device.presence : "unknown";
|
||||||
|
const isUnnamed = names.length === 0;
|
||||||
|
|
||||||
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
|
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: names[0] || "(unnamed)",
|
name: names[0] || "(unnamed)",
|
||||||
|
isUnnamed,
|
||||||
ips,
|
ips,
|
||||||
macs,
|
macs,
|
||||||
interfaces,
|
interfaces,
|
||||||
@@ -122,9 +124,7 @@ export function parseWakeSummary(response: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function chooseWakeTarget(device: DeviceRow): string {
|
export function chooseWakeTarget(device: DeviceRow): string {
|
||||||
return device.name !== "(unnamed)"
|
return device.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
|
||||||
? device.name
|
|
||||||
: device.macs[0] || device.ips[0] || "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarize(values: string[]): string {
|
export function summarize(values: string[]): string {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchObservationHistory,
|
||||||
|
type AgentDeviceObservation,
|
||||||
|
type AgentDeviceObservationEvent,
|
||||||
|
} from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
formatSeen,
|
||||||
|
observationLabel,
|
||||||
|
observationStateLabel,
|
||||||
|
} from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
observation: AgentDeviceObservation | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObservationHistoryDialog({
|
||||||
|
observation,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: Props) {
|
||||||
|
const [events, setEvents] = useState<AgentDeviceObservationEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !observation) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
fetchObservationHistory({
|
||||||
|
observationKey: observation.observation_key,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.then(setEvents)
|
||||||
|
.catch((err) => {
|
||||||
|
setEvents([]);
|
||||||
|
setError(String(err));
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open, observation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Observation History</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{observation ? observationLabel(observation) : "No observation selected"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||||
|
<div className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span>Time</span>
|
||||||
|
<span>State</span>
|
||||||
|
<span>Source</span>
|
||||||
|
<span>Network</span>
|
||||||
|
<span>Known Device</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.event_id}
|
||||||
|
className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{formatSeen(event.ts_unix)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Badge variant={event.action === "remove" ? "outline" : "secondary"}>
|
||||||
|
{observationStateLabel(event.action)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 gap-1">
|
||||||
|
<Badge variant="outline">{event.kind}</Badge>
|
||||||
|
<Badge variant="outline">{event.action}</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-muted-foreground">
|
||||||
|
<span className="block truncate">{event.ip ?? "-"}</span>
|
||||||
|
<span className="block truncate">{event.mac ?? "-"}</span>
|
||||||
|
{event.hostname && (
|
||||||
|
<span className="block truncate">{event.hostname}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{event.known_device?.display_name ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!events.length && (
|
||||||
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
{loading ? "Loading history..." : "No history found"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { identifierSummary, observationLabel } from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
observation: AgentDeviceObservation;
|
||||||
|
devices: KnownDevice[];
|
||||||
|
busy: boolean;
|
||||||
|
selectedDeviceId: string;
|
||||||
|
newName: string;
|
||||||
|
onSelectDevice: (deviceId: string) => void;
|
||||||
|
onNewNameChange: (name: string) => void;
|
||||||
|
onAttachExisting: () => void;
|
||||||
|
onCreateAndAttach: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObservationIdentityActions({
|
||||||
|
observation,
|
||||||
|
devices,
|
||||||
|
busy,
|
||||||
|
selectedDeviceId,
|
||||||
|
newName,
|
||||||
|
onSelectDevice,
|
||||||
|
onNewNameChange,
|
||||||
|
onAttachExisting,
|
||||||
|
onCreateAndAttach,
|
||||||
|
}: Props) {
|
||||||
|
const attachable = Boolean(observation.mac || observation.ip);
|
||||||
|
const selectedDevice = devices.find(
|
||||||
|
(device) => device.device_id === selectedDeviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-w-0 gap-2">
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedDeviceId}
|
||||||
|
onValueChange={(value: string | null) => onSelectDevice(value ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-0 flex-1">
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedDevice?.display_name ?? "Attach to existing"}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,28rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<SelectItem key={device.device_id} value={device.device_id}>
|
||||||
|
<span className="grid min-w-0 gap-0.5">
|
||||||
|
<span className="truncate">{device.display_name}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{identifierSummary(device)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || !attachable || !selectedDeviceId}
|
||||||
|
onClick={onAttachExisting}
|
||||||
|
>
|
||||||
|
Attach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<Input
|
||||||
|
value={newName}
|
||||||
|
onChange={(event) => onNewNameChange(event.target.value)}
|
||||||
|
placeholder={`Create as ${observationLabel(observation)}`}
|
||||||
|
disabled={busy || !attachable}
|
||||||
|
/>
|
||||||
|
<Button size="sm" disabled={busy || !attachable} onClick={onCreateAndAttach}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ObservationIdentityActions } from "@/pages/observations/ObservationIdentityActions";
|
||||||
|
import {
|
||||||
|
formatSeen,
|
||||||
|
observationLabel,
|
||||||
|
observationStateLabel,
|
||||||
|
} from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
observations: AgentDeviceObservation[];
|
||||||
|
devices: KnownDevice[];
|
||||||
|
selectedDevices: Record<string, string>;
|
||||||
|
newNames: Record<string, string>;
|
||||||
|
busyKey: string;
|
||||||
|
onSelectDevice: (observationKey: string, deviceId: string) => void;
|
||||||
|
onNewNameChange: (observationKey: string, name: string) => void;
|
||||||
|
onAttachExisting: (observation: AgentDeviceObservation) => void;
|
||||||
|
onCreateAndAttach: (observation: AgentDeviceObservation) => void;
|
||||||
|
onOpenHistory: (observation: AgentDeviceObservation) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObservationTable({
|
||||||
|
observations,
|
||||||
|
devices,
|
||||||
|
selectedDevices,
|
||||||
|
newNames,
|
||||||
|
busyKey,
|
||||||
|
onSelectDevice,
|
||||||
|
onNewNameChange,
|
||||||
|
onAttachExisting,
|
||||||
|
onCreateAndAttach,
|
||||||
|
onOpenHistory,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 overflow-x-auto">
|
||||||
|
<div className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||||
|
<span>Device</span>
|
||||||
|
<span>Network</span>
|
||||||
|
<span>Agent</span>
|
||||||
|
<span>Seen</span>
|
||||||
|
<span>Known Device</span>
|
||||||
|
<span>History</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{observations.map((observation) => {
|
||||||
|
const busy = busyKey === observation.observation_key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={observation.observation_key}
|
||||||
|
className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{observationLabel(observation)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline">{observation.kind}</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
observation.last_action === "remove"
|
||||||
|
? "outline"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{observationStateLabel(observation.last_action)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{observation.last_action}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-muted-foreground">
|
||||||
|
<div className="truncate" title={observation.ip ?? "-"}>
|
||||||
|
{observation.ip ?? "-"}
|
||||||
|
</div>
|
||||||
|
<div className="truncate" title={observation.mac ?? "-"}>
|
||||||
|
{observation.mac ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{observation.agent_id}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-muted-foreground">
|
||||||
|
<div className="truncate">
|
||||||
|
{formatSeen(observation.last_seen_unix)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{observation.known_device ? (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Badge variant="secondary">known</Badge>
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{observation.known_device.display_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{observation.known_device.pinned
|
||||||
|
? "pinned"
|
||||||
|
: "not pinned"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ObservationIdentityActions
|
||||||
|
observation={observation}
|
||||||
|
devices={devices}
|
||||||
|
busy={busy}
|
||||||
|
selectedDeviceId={
|
||||||
|
selectedDevices[observation.observation_key] ?? ""
|
||||||
|
}
|
||||||
|
newName={newNames[observation.observation_key] ?? ""}
|
||||||
|
onSelectDevice={(deviceId) =>
|
||||||
|
onSelectDevice(observation.observation_key, deviceId)
|
||||||
|
}
|
||||||
|
onNewNameChange={(name) =>
|
||||||
|
onNewNameChange(observation.observation_key, name)
|
||||||
|
}
|
||||||
|
onAttachExisting={() => onAttachExisting(observation)}
|
||||||
|
onCreateAndAttach={() => onCreateAndAttach(observation)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenHistory(observation)}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!observations.length && (
|
||||||
|
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||||
|
No observations found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachObservationIdentifier,
|
||||||
|
createKnownDevice,
|
||||||
|
fetchKnownDevices,
|
||||||
|
fetchObservations,
|
||||||
|
type AgentDeviceObservation,
|
||||||
|
type KnownDevice,
|
||||||
|
} from "@/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ObservationHistoryDialog } from "@/pages/observations/ObservationHistoryDialog";
|
||||||
|
import { ObservationTable } from "@/pages/observations/ObservationTable";
|
||||||
|
import {
|
||||||
|
observationLabel,
|
||||||
|
type ObservationFilter,
|
||||||
|
} from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
export function ObservationsPage() {
|
||||||
|
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
||||||
|
const [filter, setFilter] = useState<ObservationFilter>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
||||||
|
const [busyKey, setBusyKey] = useState("");
|
||||||
|
const [historyObservation, setHistoryObservation] =
|
||||||
|
useState<AgentDeviceObservation | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [nextObservations, nextDevices] = await Promise.all([
|
||||||
|
fetchObservations({ limit: 500 }),
|
||||||
|
fetchKnownDevices(),
|
||||||
|
]);
|
||||||
|
setObservations(nextObservations);
|
||||||
|
setDevices(nextDevices);
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return observations.filter((observation) => {
|
||||||
|
if (filter === "known" && !observation.known_device) return false;
|
||||||
|
if (filter === "unknown" && observation.known_device) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
observation.hostname,
|
||||||
|
observation.mac,
|
||||||
|
observation.ip,
|
||||||
|
observation.agent_id,
|
||||||
|
observation.kind,
|
||||||
|
observation.last_action,
|
||||||
|
observation.known_device?.display_name,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
});
|
||||||
|
}, [filter, observations, query]);
|
||||||
|
|
||||||
|
async function attachExisting(observation: AgentDeviceObservation) {
|
||||||
|
const deviceId = selectedDevices[observation.observation_key];
|
||||||
|
if (!deviceId) return;
|
||||||
|
setBusyKey(observation.observation_key);
|
||||||
|
setStatus("");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const device = await attachObservationIdentifier(
|
||||||
|
deviceId,
|
||||||
|
observation.observation_key,
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndAttach(observation: AgentDeviceObservation) {
|
||||||
|
const displayName =
|
||||||
|
newNames[observation.observation_key]?.trim() ||
|
||||||
|
observationLabel(observation);
|
||||||
|
if (!displayName.trim()) return;
|
||||||
|
setBusyKey(observation.observation_key);
|
||||||
|
setStatus("");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const device = await createKnownDevice({
|
||||||
|
display_name: displayName,
|
||||||
|
pinned: true,
|
||||||
|
identifiers: [],
|
||||||
|
});
|
||||||
|
const updated = await attachObservationIdentifier(
|
||||||
|
device.device_id,
|
||||||
|
observation.observation_key,
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle>Observed Devices</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>Status</span>
|
||||||
|
<Select
|
||||||
|
value={filter}
|
||||||
|
onValueChange={(value: ObservationFilter | null) => {
|
||||||
|
if (value) setFilter(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<span>{filter}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
|
<SelectItem value="unknown">unknown</SelectItem>
|
||||||
|
<SelectItem value="known">known</SelectItem>
|
||||||
|
<SelectItem value="all">all</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>Search</span>
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="hostname, mac, ip, agent, known device"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {filtered.length} of {observations.length}
|
||||||
|
</p>
|
||||||
|
{status && (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||||
|
{status}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ObservationTable
|
||||||
|
observations={filtered}
|
||||||
|
devices={devices}
|
||||||
|
selectedDevices={selectedDevices}
|
||||||
|
newNames={newNames}
|
||||||
|
busyKey={busyKey}
|
||||||
|
onSelectDevice={(observationKey, deviceId) =>
|
||||||
|
setSelectedDevices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[observationKey]: deviceId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onNewNameChange={(observationKey, name) =>
|
||||||
|
setNewNames((prev) => ({ ...prev, [observationKey]: name }))
|
||||||
|
}
|
||||||
|
onAttachExisting={(observation) => void attachExisting(observation)}
|
||||||
|
onCreateAndAttach={(observation) =>
|
||||||
|
void createAndAttach(observation)
|
||||||
|
}
|
||||||
|
onOpenHistory={setHistoryObservation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ObservationHistoryDialog
|
||||||
|
observation={historyObservation}
|
||||||
|
open={Boolean(historyObservation)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setHistoryObservation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||||
|
|
||||||
|
export type ObservationFilter = "all" | "unknown" | "known";
|
||||||
|
|
||||||
|
export function observationLabel(observation: AgentDeviceObservation): string {
|
||||||
|
return (
|
||||||
|
observation.hostname?.trim() ||
|
||||||
|
observation.mac?.trim() ||
|
||||||
|
observation.ip?.trim() ||
|
||||||
|
observation.observation_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSeen(tsUnix: number): string {
|
||||||
|
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
||||||
|
return new Date(tsUnix * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifierSummary(device: KnownDevice): string {
|
||||||
|
if (!device.identifiers.length) return "no identifiers";
|
||||||
|
return device.identifiers
|
||||||
|
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observationStateLabel(action: string): string {
|
||||||
|
if (action === "remove") return "stale";
|
||||||
|
if (action === "add" || action === "update" || action === "old") {
|
||||||
|
return "present";
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.0.1"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub struct Cli {
|
|||||||
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
|
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
|
||||||
pub verbose: u8,
|
pub verbose: u8,
|
||||||
|
|
||||||
|
/// Path to the agent config file for commands that use agent config.
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
@@ -28,6 +32,11 @@ pub enum Command {
|
|||||||
InitConfig(InitConfigArgs),
|
InitConfig(InitConfigArgs),
|
||||||
/// Reload a running agent daemon by sending SIGHUP.
|
/// Reload a running agent daemon by sending SIGHUP.
|
||||||
Reload(ReloadArgs),
|
Reload(ReloadArgs),
|
||||||
|
/// Upload local observations to the control plane once and exit.
|
||||||
|
#[command(visible_alias = "sync")]
|
||||||
|
SyncObservations(SyncObservationsArgs),
|
||||||
|
/// Pass local hotplug observations through to the wakey CLI.
|
||||||
|
Observe(ObserveArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -74,6 +83,10 @@ pub struct InitConfigArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub config: Option<PathBuf>,
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Existing agent config to use as a base before applying explicit overrides.
|
||||||
|
#[arg(long)]
|
||||||
|
pub from_config: Option<PathBuf>,
|
||||||
|
|
||||||
/// Print config to stdout.
|
/// Print config to stdout.
|
||||||
#[arg(long, conflicts_with = "config")]
|
#[arg(long, conflicts_with = "config")]
|
||||||
pub stdout: bool,
|
pub stdout: bool,
|
||||||
@@ -101,3 +114,50 @@ pub struct ReloadArgs {
|
|||||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct SyncObservationsArgs {
|
||||||
|
/// Path to the agent config file.
|
||||||
|
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||||
|
pub config: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ObserveArgs {
|
||||||
|
/// Path to the agent config file. If present, local path settings are passed through.
|
||||||
|
#[arg(long, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||||
|
pub config: PathBuf,
|
||||||
|
|
||||||
|
#[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: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub ip: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ObserveNeighArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub action: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub mac: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub ip: Option<String>,
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ use std::fmt;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
||||||
|
const WAKEY_DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
||||||
|
const WAKEY_MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||||
|
const WAKEY_OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||||
|
const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases";
|
||||||
|
const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
|
||||||
|
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
@@ -14,6 +20,14 @@ pub struct AgentConfig {
|
|||||||
pub reconnect_base_ms: u64,
|
pub reconnect_base_ms: u64,
|
||||||
#[serde(default = "default_reconnect_max_ms")]
|
#[serde(default = "default_reconnect_max_ms")]
|
||||||
pub reconnect_max_ms: u64,
|
pub reconnect_max_ms: u64,
|
||||||
|
#[serde(default = "default_observation_sync_interval_seconds")]
|
||||||
|
pub observation_sync_interval_seconds: u64,
|
||||||
|
#[serde(default = "default_dhcp_leases_path")]
|
||||||
|
pub dhcp_leases_path: PathBuf,
|
||||||
|
#[serde(default = "default_mac_name_cache_path")]
|
||||||
|
pub mac_name_cache_path: PathBuf,
|
||||||
|
#[serde(default = "default_observation_store_path")]
|
||||||
|
pub observation_store_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for AgentConfig {
|
impl fmt::Debug for AgentConfig {
|
||||||
@@ -24,6 +38,13 @@ impl fmt::Debug for AgentConfig {
|
|||||||
.field("agent_token", &"<redacted>")
|
.field("agent_token", &"<redacted>")
|
||||||
.field("reconnect_base_ms", &self.reconnect_base_ms)
|
.field("reconnect_base_ms", &self.reconnect_base_ms)
|
||||||
.field("reconnect_max_ms", &self.reconnect_max_ms)
|
.field("reconnect_max_ms", &self.reconnect_max_ms)
|
||||||
|
.field(
|
||||||
|
"observation_sync_interval_seconds",
|
||||||
|
&self.observation_sync_interval_seconds,
|
||||||
|
)
|
||||||
|
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||||
|
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||||
|
.field("observation_store_path", &self.observation_store_path)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +57,41 @@ const fn default_reconnect_max_ms() -> u64 {
|
|||||||
30_000
|
30_000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn default_observation_sync_interval_seconds() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_dhcp_leases_path() -> PathBuf {
|
||||||
|
DEFAULT_DHCP_LEASES_PATH.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mac_name_cache_path() -> PathBuf {
|
||||||
|
DEFAULT_MAC_NAME_CACHE_PATH.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_observation_store_path() -> PathBuf {
|
||||||
|
DEFAULT_OBSERVATION_STORE_PATH.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentConfig {
|
||||||
|
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
||||||
|
vec![
|
||||||
|
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()),
|
||||||
|
(WAKEY_MAC_NAME_CACHE_ENV, self.mac_name_cache_path.as_path()),
|
||||||
|
(
|
||||||
|
WAKEY_OBSERVATION_STORE_ENV,
|
||||||
|
self.observation_store_path.as_path(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config: &AgentConfig) {
|
||||||
|
for (key, path) in config.local_path_envs() {
|
||||||
|
cmd.env(key, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
||||||
let content = std::fs::read_to_string(path)
|
let content = std::fs::read_to_string(path)
|
||||||
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
||||||
@@ -118,12 +174,22 @@ mod tests {
|
|||||||
agent_token: "secret".into(),
|
agent_token: "secret".into(),
|
||||||
reconnect_base_ms: 123,
|
reconnect_base_ms: 123,
|
||||||
reconnect_max_ms: 456,
|
reconnect_max_ms: 456,
|
||||||
|
observation_sync_interval_seconds: 7,
|
||||||
|
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||||
|
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||||
|
observation_store_path: "/tmp/test-observations.json".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
save_config(&path, &config).expect("save");
|
save_config(&path, &config).expect("save");
|
||||||
let loaded = load_config(&path).expect("load");
|
let loaded = load_config(&path).expect("load");
|
||||||
assert_eq!(loaded, config);
|
assert_eq!(loaded, config);
|
||||||
assert!(format!("{:?}", loaded).contains("<redacted>"));
|
assert!(format!("{:?}", loaded).contains("<redacted>"));
|
||||||
|
assert!(
|
||||||
|
loaded
|
||||||
|
.local_path_envs()
|
||||||
|
.iter()
|
||||||
|
.any(|(key, _)| *key == WAKEY_OBSERVATION_STORE_ENV)
|
||||||
|
);
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user