12 Commits
65 changed files with 4237 additions and 2692 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+12
View File
@@ -0,0 +1,12 @@
# Completion Checklist
Before handing back code changes, run the smallest checks covering the changed area:
- Rust formatting: `cargo fmt`.
- For control-plane SQLx/state/API work: `cargo test -p wakey-control-plane` and `SQLX_OFFLINE=true cargo test -p wakey-control-plane`.
- For SQLx query macro changes: run `scripts/prepare_sqlx_sqlite.sh` and include the `.sqlx` metadata changes.
- For wakey-linux/core inventory/observation work: `cargo test -p wakey-linux -p wakey-core`.
- For agent session/config/protocol work: `cargo test -p wakey-agent`.
- For UI work: `pnpm --dir ui typecheck` and `pnpm --dir ui build`; use `pnpm --dir ui format` when touching TS/TSX/CSS formatting.
- Run relevant `cargo clippy -p <crate> --all-targets --all-features` when behavior or API shape changes.
Always check `git status --short` and mention unrelated dirty files or generated metadata in the final summary.
+14
View File
@@ -0,0 +1,14 @@
# Wakey Project Overview
Wakey is a Wake-on-LAN and LAN observability workspace for Linux/OpenWrt routers. It includes:
- `wakey-core`: shared model and parsing types for devices, neighbors, DHCP, interfaces, wake results.
- `wakey-linux`: Linux/OpenWrt adapter layer for DHCP lease files, neighbor lookup, interface data, WoL sending, and local observation storage.
- root `wakey` crate: service layer and operator CLI for inventory, leases, devs, wake, and observe.
- `wakey-agent`: outbound router daemon. Enrolls with control plane, maintains authenticated websocket, executes relayed commands, syncs observations.
- `wakey-control-plane`: Axum control plane, SQLx SQLite state, agent registry, command relay, known devices, observations, audit/alerts, fleet APIs, static UI hosting.
- `ipjs`: typed wrappers around Linux `ip -j` data.
- `ui`: React/Vite operator UI using shadcn/base-ui style components.
Current persistence direction: control plane uses SQLx SQLite with migrations in `wakey-control-plane/migrations` and offline query metadata in `.sqlx` plus `wakey-control-plane/.sqlx`. Legacy sled import exists as explicit command.
Current product direction: fleet-first device UI and backend. Known device identity is manual. Observations/inventory provide suggestions/current state but should not automatically promote or delete durable identifiers.
+23
View File
@@ -0,0 +1,23 @@
# Style And Conventions
Rust:
- Edition 2024.
- Prefer explicit, small service/API functions over large abstractions.
- Use `anyhow::Context` for fallible operations in application layers.
- Use `tracing` for logs.
- Shared domain types live in `wakey-core`; Linux/OpenWrt I/O in `wakey-linux`; root crate owns service/CLI behavior; agent/control-plane should reuse service/domain types where possible.
- SQLx query macros are used in control-plane state. Regenerate `.sqlx` metadata after query string changes.
- Device identity is manual: observations and inventory may suggest, but should not silently pin, merge, promote, or delete known identifiers.
- Hotplug observation actions should stay event-shaped (`add`, `update`, `remove`). Do not store NUD state as the action. NUD belongs to live neighbor/inventory data.
Frontend:
- React + Vite + TypeScript.
- Use existing component/style conventions; shadcn/base-ui/lucide are available.
- Main operator UI is fleet-first: common path is find device, inspect current IP/MAC/agent, wake, copy, remember/merge.
- Admin/debug pages stay available but should not dominate the primary nav.
Editing:
- Keep changes scoped.
- Preserve user edits in dirty worktrees.
- Use `apply_patch` for manual file edits.
- Default to ASCII unless files already use non-ASCII for a reason.
+31
View File
@@ -0,0 +1,31 @@
# Suggested Commands
Rust formatting and checks:
- `cargo fmt`
- `cargo test -p wakey-control-plane`
- `SQLX_OFFLINE=true cargo test -p wakey-control-plane`
- `cargo test -p wakey-linux -p wakey-core`
- `cargo test -p wakey-agent` (may need elevated permissions if tests bind local sockets)
- `cargo clippy -p wakey-control-plane --all-targets --all-features`
- `cargo clippy -p wakey-linux -p wakey-core --all-targets --all-features`
SQLx metadata after query macro changes:
- `scripts/prepare_sqlx_sqlite.sh`
UI checks:
- `pnpm --dir ui typecheck`
- `pnpm --dir ui build`
- `pnpm --dir ui format`
Local run examples:
- `cargo run -- inventory <query>`
- `cargo run -- leases`
- `cargo run -- devs`
- `cargo run -- wake --mac aa:bb:cc:dd:ee:ff`
- `cargo run -p wakey-agent -- serve --config /etc/wakey-agent/config.toml`
- `cargo run -p wakey-control-plane -- serve --config-file /etc/wakey-control-plane/config.toml`
Useful shell tools:
- Prefer `rg` and `rg --files` for search.
- Use `git status --short`, `git diff --stat`, and focused `git diff -- <path>` before edits.
- Avoid destructive git commands unless explicitly requested.
+155
View File
@@ -0,0 +1,155 @@
# the name by which the project can be referenced within Serena
project_name: "wakey"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- rust
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
"describe": {
"columns": [
{
"name": "event_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "observation_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "action!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 6
},
"nullable": [
true,
true,
false,
false,
false,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "72aadefca9851a8becdd2dcce54b646aff095b64cef9837f5e8e4ea8dde71d02"
}
@@ -1,86 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
"describe": {
"columns": [
{
"name": "event_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "observation_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "action!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 6
},
"nullable": [
true,
true,
false,
false,
false,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
}
+8
View File
@@ -25,3 +25,11 @@ $remote = $files.ForEach({ "/etc/init.d/$_" })
$remote | ForEach-Object { $remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\" pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
} }
$files = "dhcp/95-wakey", "neigh/95-wakey"
$remote = $files.ForEach({ "/etc/hotplug.d/$_" })
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
$remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$hotplugDir\"
}
+23 -8
View File
@@ -6,12 +6,27 @@ BIN="/root/.bin/wakey-agent"
[ -n "$ACTION" ] || exit 0 [ -n "$ACTION" ] || exit 0
[ -n "$MACADDR" ] || exit 0 [ -n "$MACADDR" ] || exit 0
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then run_wakey_agent() {
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true if [ -n "$WAKEY_AGENT_CONFIG" ]; then
elif [ -n "$IPADDR" ]; then set -- --config "$WAKEY_AGENT_CONFIG" "$@"
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true fi
elif [ -n "$HOSTNAME" ]; then if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true set -- -v "$@"
else fi
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true # [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
if command -v logger >/dev/null 2>&1; then
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
else
"$BIN" "$@" >/dev/null 2>&1
fi
}
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" || true
elif [ -n "$IPADDR" ]; then
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
elif [ -n "$HOSTNAME" ]; then
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" || true
else
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" || true
fi fi
+23 -8
View File
@@ -5,12 +5,27 @@ BIN="/root/.bin/wakey-agent"
[ -n "$BIN" ] || exit 0 [ -n "$BIN" ] || exit 0
[ -n "$ACTION" ] || exit 0 [ -n "$ACTION" ] || exit 0
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then run_wakey_agent() {
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true if [ -n "$WAKEY_AGENT_CONFIG" ]; then
elif [ -n "$MACADDR" ]; then set -- --config "$WAKEY_AGENT_CONFIG" "$@"
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true fi
elif [ -n "$IPADDR" ]; then if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
"$BIN" observe neigh --action "$ACTION" --ip "$IPADDR" >/dev/null 2>&1 || true set -- -v "$@"
else fi
"$BIN" observe neigh --action "$ACTION" >/dev/null 2>&1 || true # [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
if command -v logger >/dev/null 2>&1; then
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
else
"$BIN" "$@" >/dev/null 2>&1
fi
}
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
elif [ -n "$MACADDR" ]; then
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" || true
elif [ -n "$IPADDR" ]; then
run_wakey_agent observe neigh --action "$ACTION" --ip "$IPADDR" || true
else
run_wakey_agent observe neigh --action "$ACTION" || true
fi fi
+71 -3
View File
@@ -4,21 +4,30 @@
# then packaging the same bundle format and installing it. # then packaging the same bundle format and installing it.
# #
# Simplest use: # Simplest use:
# sudo -E ./scripts/update_wakey_cc_from_repo.sh # ./scripts/update_wakey_cc_from_repo.sh
# #
# Env options: # Env options:
# WAKEY_CC_REPO_DIR Existing checkout to update/build instead of cloning
# WAKEY_CC_REPO_URL Git URL (default: https://git.ldlda.com/lda/wakey.git) # WAKEY_CC_REPO_URL Git URL (default: https://git.ldlda.com/lda/wakey.git)
# WAKEY_CC_REF Git ref/branch/tag (default: main) # WAKEY_CC_REF Git ref/branch/tag (default: main)
# WAKEY_CC_TARGET Rust target triple (default from uname) # WAKEY_CC_TARGET Rust target triple (default from uname)
# WAKEY_CC_ROOT Install root (default: current directory) # WAKEY_CC_ROOT Install root (default: current directory)
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service) # WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
# WAKEY_CC_USER Owner for installed files/temp dirs (default: current user)
# WAKEY_CC_VERSION Bundle version label (default: v0.0.0-local-<short_sha>) # WAKEY_CC_VERSION Bundle version label (default: v0.0.0-local-<short_sha>)
# WAKEY_CC_NO_RESTART If set, skip systemd restart # WAKEY_CC_NO_RESTART If set, skip systemd restart
# WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging # WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging
# SUDO Privilege escalation command/path (default: sudo when needed) # SUDO Privilege escalation command/path for install steps (default: sudo)
# #
# Requires: git, cargo, tar, pnpm, systemctl (optional for restart) # Requires: git, cargo, tar, pnpm, systemctl (optional for restart)
# Common existing-checkout use:
: <<'USE CASE'
WAKEY_CC_REPO_DIR="$HOME/dev/wakey" \
WAKEY_CC_USER=lda \
WAKEY_CC_KEEP_TMP=1 \
scripts/update_wakey_cc_from_repo.sh
USE CASE
set -eu set -eu
log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; } log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; }
@@ -79,6 +88,28 @@ restore_label() {
fi fi
} }
chown_if_requested() {
[ -n "${OWNER:-}" ] || return 0
priv chown -R "$OWNER:$OWNER" "$@"
}
chmod_user_rw_if_requested() {
[ -n "${OWNER:-}" ] || return 0
priv chmod -R u+rwX "$@"
}
update_existing_repo() {
repo_dir=$1
ref=$2
[ -d "$repo_dir/.git" ] || fail "WAKEY_CC_REPO_DIR is not a git checkout: $repo_dir"
log "using existing checkout $repo_dir (ref: $ref)"
git -C "$repo_dir" fetch --prune origin "$ref" || fail 'git fetch failed'
git -C "$repo_dir" checkout "$ref" || fail "git checkout failed: $ref"
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
}
install_executable_force() { install_executable_force() {
src=$1 src=$1
dst=$2 dst=$2
@@ -90,6 +121,7 @@ install_executable_force() {
priv rm -f "$tmp" priv rm -f "$tmp"
priv cp -f "$src" "$tmp" priv cp -f "$src" "$tmp"
priv chmod 0755 "$tmp" priv chmod 0755 "$tmp"
chown_if_requested "$tmp"
if [ -e "$dst" ]; then if [ -e "$dst" ]; then
priv mv -f "$dst" "$old" || true priv mv -f "$dst" "$old" || true
fi fi
@@ -99,13 +131,25 @@ install_executable_force() {
} }
main() { main() {
REPO_DIR="${WAKEY_CC_REPO_DIR:-}"
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}" REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
REF="${WAKEY_CC_REF:-main}" REF="${WAKEY_CC_REF:-main}"
TARGET="${WAKEY_CC_TARGET:-$(default_target)}" TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
ROOT="${WAKEY_CC_ROOT:-$PWD}" ROOT="${WAKEY_CC_ROOT:-$PWD}"
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}" SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
if [ -n "${WAKEY_CC_USER:-}" ]; then
OWNER="$WAKEY_CC_USER"
elif [ "$(id -u)" -eq 0 ]; then
OWNER=
else
OWNER=$(id -un)
fi
SUDO_BIN=$(sudo_cmd) SUDO_BIN=$(sudo_cmd)
if [ -n "$OWNER" ] && ! id "$OWNER" >/dev/null 2>&1; then
fail "WAKEY_CC_USER does not exist: $OWNER"
fi
require_cmd git require_cmd git
require_cmd cargo require_cmd cargo
require_cmd tar require_cmd tar
@@ -114,19 +158,34 @@ main() {
TMPBASE="${TMPDIR:-/tmp}" TMPBASE="${TMPDIR:-/tmp}"
WORKDIR="$TMPBASE/wakey-cc-src.$$" WORKDIR="$TMPBASE/wakey-cc-src.$$"
STAGING="$TMPBASE/wakey-cc-stage.$$" STAGING="$TMPBASE/wakey-cc-stage.$$"
CLONED_WORKDIR=1
cleanup() { cleanup() {
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
rm -rf "$WORKDIR" "$STAGING" [ "$CLONED_WORKDIR" = "0" ] || rm -rf "$WORKDIR"
rm -rf "$STAGING"
else else
if [ "$CLONED_WORKDIR" != "0" ]; then
chown_if_requested "$WORKDIR"
chmod_user_rw_if_requested "$WORKDIR"
log "keeping temp dir: $WORKDIR" log "keeping temp dir: $WORKDIR"
fi fi
chown_if_requested "$STAGING"
chmod_user_rw_if_requested "$STAGING"
log "keeping temp dir: $STAGING"
fi
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
if [ -n "$REPO_DIR" ]; then
WORKDIR=$REPO_DIR
CLONED_WORKDIR=0
update_existing_repo "$WORKDIR" "$REF"
else
log "cloning $REPO_URL (ref: $REF)" log "cloning $REPO_URL (ref: $REF)"
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" || git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
fail 'git clone failed' fail 'git clone failed'
fi
cd "$WORKDIR" cd "$WORKDIR"
@@ -165,6 +224,15 @@ main() {
priv cp -a "$STAGING/ui" "$ROOT/" priv cp -a "$STAGING/ui" "$ROOT/"
priv cp -a "$STAGING/scripts" "$ROOT/" priv cp -a "$STAGING/scripts" "$ROOT/"
priv cp -a "$STAGING/deploy" "$ROOT/" priv cp -a "$STAGING/deploy" "$ROOT/"
chown_if_requested \
"$ROOT/bin/wakey-control-plane" \
"$ROOT/ui" \
"$ROOT/scripts" \
"$ROOT/deploy"
chmod_user_rw_if_requested \
"$ROOT/ui" \
"$ROOT/scripts" \
"$ROOT/deploy"
if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
+5 -5
View File
@@ -46,7 +46,7 @@ pub struct LeasesArgs {
)] )]
pub include_state: bool, pub include_state: bool,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(long, short)]
pub json: bool, pub json: bool,
} }
@@ -70,7 +70,7 @@ pub struct WakeArgs {
#[arg(long)] #[arg(long)]
pub ip: Option<IpAddr>, pub ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(short, long)]
pub json: bool, pub json: bool,
} }
@@ -100,7 +100,7 @@ pub struct InventoryArgs {
#[arg(long = "mac")] #[arg(long = "mac")]
pub macs: Vec<macaddr::MacAddr>, pub macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(short, long)]
pub json: bool, pub json: bool,
} }
@@ -114,10 +114,10 @@ pub struct DevsArgs {
/// Optional interface name to show. /// Optional interface name to show.
pub dev: Option<String>, pub dev: Option<String>,
/// Show only interfaces whose operstate is `up`. /// Show only interfaces whose operstate is `up`.
#[arg(long)] #[arg(short, long)]
pub up: bool, pub up: bool,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(short, long)]
pub json: bool, pub json: bool,
} }
+2
View File
@@ -18,11 +18,13 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-compiler-runtime": "^1.0.0", "react-compiler-runtime": "^1.0.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
+55
View File
@@ -25,6 +25,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.8.0([email protected]) version: 1.8.0([email protected])
next-themes:
specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected])
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@@ -40,6 +43,9 @@ importers:
shadcn: shadcn:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0(@types/[email protected])([email protected]) version: 4.2.0(@types/[email protected])([email protected])
sonner:
specifier: ^2.0.7
version: 2.0.7([email protected]([email protected]))([email protected])
tailwind-merge: tailwind-merge:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0 version: 3.5.0
@@ -843,6 +849,7 @@ packages:
} }
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -851,6 +858,7 @@ packages:
} }
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -859,6 +867,7 @@ packages:
} }
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -867,6 +876,7 @@ packages:
} }
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -875,6 +885,7 @@ packages:
} }
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -883,6 +894,7 @@ packages:
} }
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -891,6 +903,7 @@ packages:
} }
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -899,6 +912,7 @@ packages:
} }
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -907,6 +921,7 @@ packages:
} }
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -915,6 +930,7 @@ packages:
} }
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -923,6 +939,7 @@ packages:
} }
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -931,6 +948,7 @@ packages:
} }
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -939,6 +957,7 @@ packages:
} }
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
"@rollup/[email protected]": "@rollup/[email protected]":
resolution: resolution:
@@ -1060,6 +1079,7 @@ packages:
engines: { node: ">= 20" } engines: { node: ">= 20" }
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
"@tailwindcss/[email protected]": "@tailwindcss/[email protected]":
resolution: resolution:
@@ -1069,6 +1089,7 @@ packages:
engines: { node: ">= 20" } engines: { node: ">= 20" }
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
"@tailwindcss/[email protected]": "@tailwindcss/[email protected]":
resolution: resolution:
@@ -1078,6 +1099,7 @@ packages:
engines: { node: ">= 20" } engines: { node: ">= 20" }
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
"@tailwindcss/[email protected]": "@tailwindcss/[email protected]":
resolution: resolution:
@@ -1087,6 +1109,7 @@ packages:
engines: { node: ">= 20" } engines: { node: ">= 20" }
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
"@tailwindcss/[email protected]": "@tailwindcss/[email protected]":
resolution: resolution:
@@ -2365,6 +2388,7 @@ packages:
engines: { node: ">= 12.0.0" } engines: { node: ">= 12.0.0" }
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
[email protected]: [email protected]:
resolution: resolution:
@@ -2374,6 +2398,7 @@ packages:
engines: { node: ">= 12.0.0" } engines: { node: ">= 12.0.0" }
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
[email protected]: [email protected]:
resolution: resolution:
@@ -2383,6 +2408,7 @@ packages:
engines: { node: ">= 12.0.0" } engines: { node: ">= 12.0.0" }
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
[email protected]: [email protected]:
resolution: resolution:
@@ -2392,6 +2418,7 @@ packages:
engines: { node: ">= 12.0.0" } engines: { node: ">= 12.0.0" }
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
[email protected]: [email protected]:
resolution: resolution:
@@ -2581,6 +2608,15 @@ packages:
} }
engines: { node: ">= 0.6" } engines: { node: ">= 0.6" }
[email protected]:
resolution:
{
integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==,
}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
[email protected]: [email protected]:
resolution: resolution:
{ {
@@ -3086,6 +3122,15 @@ packages:
integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==, integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==,
} }
[email protected]:
resolution:
{
integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==,
}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
[email protected]: [email protected]:
resolution: resolution:
{ {
@@ -4862,6 +4907,11 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
react: 18.3.1
react-dom: 18.3.1([email protected])
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -5221,6 +5271,11 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
react: 18.3.1
react-dom: 18.3.1([email protected])
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+27 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { toast } from "sonner";
import { import {
type Agent, type Agent,
@@ -13,6 +14,7 @@ import {
revokeAgent, revokeAgent,
setAgentNickname, setAgentNickname,
} from "@/api"; } from "@/api";
import { Toaster } from "@/components/ui/sonner";
import { AppLayout } from "@/layout/AppLayout"; import { AppLayout } from "@/layout/AppLayout";
import { AgentsPage } from "@/pages/AgentsPage"; import { AgentsPage } from "@/pages/AgentsPage";
import { AlertsPage } from "@/pages/AlertsPage"; import { AlertsPage } from "@/pages/AlertsPage";
@@ -20,8 +22,9 @@ import { AuditPage } from "@/pages/AuditPage";
import { CommandsPage } from "@/pages/CommandsPage"; import { CommandsPage } from "@/pages/CommandsPage";
import { DashboardPage } from "@/pages/DashboardPage"; import { DashboardPage } from "@/pages/DashboardPage";
import { DevicesPage } from "@/pages/DevicesPage"; import { DevicesPage } from "@/pages/DevicesPage";
import { ObservationsPage } from "@/pages/ObservationsPage";
import { TokensPage } from "@/pages/TokensPage"; import { TokensPage } from "@/pages/TokensPage";
import { WakeToolsPage } from "@/pages/WakeToolsPage";
import { ObservationsPage } from "@/pages/ObservationsPage";
type LoadState = "idle" | "loading" | "ready" | "error"; type LoadState = "idle" | "loading" | "ready" | "error";
@@ -33,11 +36,9 @@ export function App() {
const [selectedAgentId, setSelectedAgentId] = useState(""); const [selectedAgentId, setSelectedAgentId] = useState("");
const [state, setState] = useState<LoadState>("idle"); const [state, setState] = useState<LoadState>("idle");
const [error, setError] = useState("");
async function loadAll() { async function loadAll() {
setState("loading"); setState("loading");
setError("");
try { try {
const [nextAgents, nextAlerts, nextHistory, nextAudit] = const [nextAgents, nextAlerts, nextHistory, nextAudit] =
await Promise.all([ await Promise.all([
@@ -65,13 +66,16 @@ export function App() {
setState("ready"); setState("ready");
} catch (err) { } catch (err) {
setState("error"); setState("error");
setError(String(err)); toast.error("Failed to load data", { description: String(err) });
} }
} }
async function onRevokeAgent(agentId: string): Promise<boolean> { async function onRevokeAgent(agentId: string): Promise<boolean> {
const result = await revokeAgent(agentId); const result = await revokeAgent(agentId);
await loadAll(); await loadAll();
if (result.revoked) {
toast.success(`Revoked ${agentId}`);
}
return result.revoked; return result.revoked;
} }
@@ -81,6 +85,11 @@ export function App() {
): Promise<boolean> { ): Promise<boolean> {
const result = await setAgentNickname(agentId, nickname); const result = await setAgentNickname(agentId, nickname);
await loadAll(); await loadAll();
if (result.updated) {
toast.success(`Nickname updated for ${agentId}`, {
description: nickname || "(cleared)",
});
}
return result.updated; return result.updated;
} }
@@ -126,7 +135,6 @@ export function App() {
return ( return (
<> <>
{error && <pre className="error">{error}</pre>}
<Routes> <Routes>
<Route path="/" element={<AppLayout />}> <Route path="/" element={<AppLayout />}>
<Route <Route
@@ -140,6 +148,17 @@ export function App() {
/> />
} }
/> />
<Route
path="wake"
element={
<WakeToolsPage
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onAfterWake={loadAll}
/>
}
/>
<Route <Route
path="dashboard" path="dashboard"
element={ element={
@@ -147,12 +166,12 @@ export function App() {
agents={agents} agents={agents}
alerts={alerts} alerts={alerts}
transitions={history} transitions={history}
audit={audit}
loading={state === "loading"} loading={state === "loading"}
onRefresh={loadAll} onRefresh={loadAll}
/> />
} }
/> />
<Route path="observations" element={<ObservationsPage />} />
<Route <Route
path="agents" path="agents"
element={ element={
@@ -196,9 +215,11 @@ export function App() {
} }
/> />
<Route path="tokens" element={<TokensPage />} /> <Route path="tokens" element={<TokensPage />} />
<Route path="observations" element={<ObservationsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
<Toaster richColors position="bottom-right" />
</> </>
); );
} }
+2
View File
@@ -270,6 +270,7 @@ export function fetchFleetDevices(opts?: {
presence?: string; presence?: string;
known?: string; known?: string;
agentId?: string; agentId?: string;
visibility?: "operator" | "all";
limit?: number; limit?: number;
}): Promise<FleetDevice[]> { }): Promise<FleetDevice[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -279,6 +280,7 @@ export function fetchFleetDevices(opts?: {
} }
if (opts?.known && opts.known !== "all") params.set("known", opts.known); if (opts?.known && opts.known !== "all") params.set("known", opts.known);
if (opts?.agentId) params.set("agent_id", opts.agentId); if (opts?.agentId) params.set("agent_id", opts.agentId);
if (opts?.visibility) params.set("visibility", opts.visibility);
params.set("limit", String(opts?.limit ?? 500)); params.set("limit", String(opts?.limit ?? 500));
return request<FleetDevice[]>( return request<FleetDevice[]>(
`/api/v1/control/fleet/devices?${params.toString()}`, `/api/v1/control/fleet/devices?${params.toString()}`,
+89
View File
@@ -0,0 +1,89 @@
import type { Agent } from "@/api";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
type Props = {
agents: Agent[];
value: string;
onChange: (agentId: string) => void;
disabled?: boolean;
className?: string;
};
export function displayAgentLabel(agent: Agent): string {
const nickname = agent.nickname?.trim();
return nickname ? nickname : agent.agent_id;
}
export function AgentSelector({
agents,
value,
onChange,
disabled,
className,
}: Props) {
const selected = agents.find((a) => a.agent_id === value);
return (
<Select
value={value}
onValueChange={(v) => {
if (v) onChange(v);
}}
disabled={disabled}
>
<SelectTrigger className={className ?? "w-full min-w-0"}>
<span className="flex min-w-0 flex-1 items-center gap-2 text-start">
{selected ? (
<span
className={`size-2 shrink-0 rounded-full ${selected.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
aria-hidden
/>
) : null}
<span className="min-w-0 truncate">
{value
? selected
? displayAgentLabel(selected)
: value
: "Select agent"}
</span>
{selected ? (
<span className="shrink-0 text-xs text-muted-foreground">
{selected.connected ? "connected" : "offline"}
</span>
) : null}
</span>
</SelectTrigger>
<SelectContent
className="max-w-[min(92vw,30rem)]"
alignItemWithTrigger={false}
>
{agents.map((agent) => (
<SelectItem
key={agent.agent_id}
value={agent.agent_id}
className="pe-10"
disabled={!agent.connected}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
aria-hidden
/>
<span className="min-w-0 truncate">
{displayAgentLabel(agent)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{agent.connected ? "connected" : "offline"}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+2 -2
View File
@@ -51,7 +51,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 inset-s-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className, className,
)} )}
{...props} {...props}
@@ -63,7 +63,7 @@ function DialogContent({
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-2 end-2" className="absolute top-2 inset-e-2"
size="icon-sm" size="icon-sm"
/> />
} }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+43
View File
@@ -0,0 +1,43 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
);
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pe-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-inset-s-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-inset-e-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+216 -88
View File
@@ -1,118 +1,246 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import { ChevronDown, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes";
import {
Moon,
Sun,
Monitor,
Zap,
BarChart3,
Bell,
Bot,
FileText,
Key,
Eye,
Terminal,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, Tooltip,
DropdownMenuContent, TooltipContent,
DropdownMenuItem, TooltipTrigger,
DropdownMenuTrigger, } from "@/components/ui/tooltip";
} from "@/components/ui/dropdown-menu";
const navItems = [ const SIDEBAR_KEY = "wakey-sidebar-collapsed";
["/", "Devices"],
["/dashboard", "Fleet Health"],
] as const;
const adminItems = [ type NavSection = {
["/agents", "Agents"], label: string;
["/tokens", "Tokens"], items: readonly {
["/alerts", "Alerts"], to: string;
["/observations", "Raw Observations"], label: string;
["/commands", "Command Runner"], icon: React.ElementType;
["/audit", "Audit"], end?: boolean;
] as const; }[];
};
type Theme = "light" | "dark"; const navSections: NavSection[] = [
{
label: "Operations",
items: [
{ to: "/", label: "Devices", icon: Monitor, end: true },
{ to: "/wake", label: "Wake Tools", icon: Zap },
],
},
{
label: "Monitoring",
items: [
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
{ to: "/alerts", label: "Alerts", icon: Bell },
{ to: "/observations", label: "Observations", icon: Eye },
],
},
{
label: "Admin",
items: [
{ to: "/agents", label: "Agents", icon: Bot },
{ to: "/audit", label: "Audit", icon: FileText },
{ to: "/tokens", label: "Tokens", icon: Key },
{ to: "/commands", label: "Commands", icon: Terminal },
],
},
];
const THEME_KEY = "wakey-ui-theme"; function usePageTitle() {
const { pathname } = useLocation();
function resolveInitialTheme(): Theme { useEffect(() => {
const stored = window.localStorage.getItem(THEME_KEY); const flat = navSections.flatMap((s) => s.items);
if (stored === "light" || stored === "dark") return stored; const match = flat.find((item) =>
item.end
return window.matchMedia("(prefers-color-scheme: dark)").matches ? pathname === "/ui" || pathname === "/ui/" || pathname === "/"
? "dark" : pathname.startsWith(item.to) && item.to !== "/",
: "light"; );
document.title = match ? `${match.label} — Wakey` : "Wakey Operator UI";
}, [pathname]);
} }
export function AppLayout() { export function AppLayout() {
const [theme, setTheme] = useState<Theme>(() => resolveInitialTheme()); const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const [collapsed, setCollapsed] = useState(() => {
return window.localStorage.getItem(SIDEBAR_KEY) === "true";
});
usePageTitle();
useEffect(() => { useEffect(() => {
const root = document.documentElement; window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
root.classList.toggle("dark", theme === "dark"); }, [collapsed]);
window.localStorage.setItem(THEME_KEY, theme);
}, [theme]);
return ( return (
<div className="mx-auto max-w-7xl p-4"> <div className="app-shell">
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <aside
<div> className={[
<h1 className="text-2xl font-semibold tracking-tight">Wakey</h1> "sidebar",
<p className="mt-1 text-sm text-muted-foreground"> collapsed ? "sidebar--collapsed" : "",
Fleet devices, presence, and wake controls "fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out",
</p> "md:sticky md:top-0 md:z-30 md:shadow-none",
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
]
.filter(Boolean)
.join(" ")}
data-collapsed={collapsed}
>
<div className="sidebar-brand">
<div className="sidebar-logo">
<Zap className="size-5 text-primary" />
</div> </div>
<Button {!collapsed && (
type="button" <div className="sidebar-brand-text">
variant="outline" <span className="text-sm font-semibold tracking-tight">
size="sm" Wakey
className="fixed right-3 top-3 z-50 size-9 rounded-full p-0 sm:static sm:size-auto sm:rounded-md sm:px-3 sm:py-2" </span>
aria-label={theme === "dark" ? "Switch to light" : "Switch to dark"} <span className="text-[0.65rem] leading-none text-muted-foreground">
title={theme === "dark" ? "Switch to light" : "Switch to dark"} Operator UI
onClick={() => </span>
setTheme((current) => (current === "dark" ? "light" : "dark")) </div>
)}
</div>
<nav className="sidebar-nav">
{navSections.map((section) => (
<div key={section.label} className="sidebar-section">
{!collapsed && (
<div className="sidebar-section-label">{section.label}</div>
)}
{section.items.map((item) => {
const Icon = item.icon;
const link = (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
[
"sidebar-link",
isActive ? "sidebar-link--active" : "",
collapsed ? "sidebar-link--collapsed" : "",
]
.filter(Boolean)
.join(" ")
} }
> >
{theme === "dark" ? ( <Icon className="sidebar-link-icon" />
{!collapsed && (
<span className="sidebar-link-label">{item.label}</span>
)}
</NavLink>
);
if (collapsed) {
return (
<Tooltip key={item.to}>
<TooltipTrigger>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
return link;
})}
</div>
))}
</nav>
<div className="sidebar-footer">
<Tooltip>
<TooltipTrigger>
<Button
type="button"
variant="ghost"
size="sm"
className="sidebar-footer-btn"
aria-label={isDark ? "Switch to light" : "Switch to dark"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? (
<Sun className="size-4" aria-hidden /> <Sun className="size-4" aria-hidden />
) : ( ) : (
<Moon className="size-4" aria-hidden /> <Moon className="size-4" aria-hidden />
)} )}
<span className="hidden sm:inline"> {!collapsed && (
{theme === "dark" ? "Switch to light" : "Switch to dark"} <span className="sidebar-link-label">
{isDark ? "Light mode" : "Dark mode"}
</span> </span>
)}
</Button> </Button>
</header> </TooltipTrigger>
<TooltipContent side="right">
{isDark ? "Switch to light" : "Switch to dark"}
</TooltipContent>
</Tooltip>
<nav className="mb-3 flex flex-wrap items-center gap-2"> <Tooltip>
{navItems.map(([to, label]) => ( <TooltipTrigger>
<NavLink <Button
key={to} type="button"
to={to} variant="ghost"
end={to === "/"} size="sm"
className={({ isActive }) => className="sidebar-footer-btn"
[ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
"rounded-md border px-3 py-1.5 text-sm transition-colors", onClick={() => setCollapsed((c) => !c)}
isActive
? "border-primary/60 bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
> >
{label} {collapsed ? (
</NavLink> <PanelLeft className="size-4" aria-hidden />
))} ) : (
<DropdownMenu> <PanelLeftClose className="size-4" aria-hidden />
<DropdownMenuTrigger className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"> )}
Admin / Debug {!collapsed && (
<ChevronDown className="size-4" aria-hidden /> <span className="sidebar-link-label">Collapse</span>
</DropdownMenuTrigger> )}
<DropdownMenuContent className="w-48"> </Button>
{adminItems.map(([to, label]) => ( </TooltipTrigger>
<DropdownMenuItem key={to}> <TooltipContent side="right">
<NavLink className="w-full" to={to}> {collapsed ? "Expand" : "Collapse"}
{label} </TooltipContent>
</NavLink> </Tooltip>
</DropdownMenuItem> </div>
))} </aside>
</DropdownMenuContent>
</DropdownMenu>
</nav>
<main> {/* Mobile backdrop */}
{!collapsed && (
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
onClick={() => setCollapsed(true)}
/>
)}
{/* Mobile floating button when sidebar is collapsed/hidden */}
{collapsed && (
<Button
type="button"
variant="ghost"
size="sm"
className="fixed bottom-3 left-3 z-40 md:hidden"
aria-label="Open menu"
onClick={() => setCollapsed(false)}
>
<PanelLeft className="size-4" aria-hidden />
</Button>
)}
<main className="app-main @container">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
+11
View File
@@ -1,13 +1,24 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import { App } from "@/App"; import { App } from "@/App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter basename="/ui"> <BrowserRouter basename="/ui">
<ThemeProvider
attribute="class"
defaultTheme="system"
storageKey="wakey-ui-theme"
enableSystem
>
<TooltipProvider>
<App /> <App />
</TooltipProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
); );
+68 -65
View File
@@ -2,7 +2,11 @@ import type { Agent } from "@/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { Bot, Inbox } from "lucide-react";
import { displayAgentLabel } from "@/components/AgentSelector";
type Props = { type Props = {
agents: Agent[]; agents: Agent[];
@@ -23,29 +27,17 @@ export function AgentsPage({
onSetAgentNickname, onSetAgentNickname,
}: Props) { }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
function displayLabel(agent: Agent): string {
const nickname = agent.nickname?.trim();
return nickname ? nickname : agent.agent_id;
}
async function onRevoke(agentId: string) { async function onRevoke(agentId: string) {
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) { if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
return; return;
} }
setBusy(true); setBusy(true);
setStatus("");
setError("");
try { try {
const revoked = await onRevokeAgent(agentId); await onRevokeAgent(agentId);
setStatus(
revoked ? `Revoked ${agentId}` : `${agentId} was already absent`,
);
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Revoke failed", { description: String(err) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -54,17 +46,10 @@ export function AgentsPage({
async function onSaveNickname(agent: Agent) { async function onSaveNickname(agent: Agent) {
const draft = drafts[agent.agent_id] ?? agent.nickname ?? ""; const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
setBusy(true); setBusy(true);
setStatus("");
setError("");
try { try {
const updated = await onSetAgentNickname(agent.agent_id, draft); await onSetAgentNickname(agent.agent_id, draft);
setStatus(
updated
? `Updated nickname for ${agent.agent_id}`
: `${agent.agent_id} not found`,
);
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Failed to update nickname", { description: String(err) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -73,49 +58,49 @@ export function AgentsPage({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Agents</CardTitle> <CardTitle className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
Agents
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid gap-2"> <div className="grid gap-2">
{agents.map((agent) => ( {agents.map((agent) => (
<div <div
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2" className={`rounded-lg border px-4 py-3 transition-colors ${
selectedAgentId === agent.agent_id
? "border-primary/40 bg-primary/5"
: "bg-card"
}`}
key={agent.agent_id} key={agent.agent_id}
> >
<Button <div className="flex items-start justify-between gap-3">
variant={ <button
selectedAgentId === agent.agent_id ? "secondary" : "outline" type="button"
} className="flex min-w-0 flex-1 flex-col items-start gap-1 text-left"
className="flex h-auto flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
onClick={() => onSelectAgent(agent.agent_id)} onClick={() => onSelectAgent(agent.agent_id)}
disabled={busy} disabled={busy}
> >
<span className="min-w-0 truncate">{displayLabel(agent)}</span> <span className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span
{agent.connected ? "connected" : "offline"} className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
</span> aria-hidden
</Button>
<div className="grid min-w-52 gap-2">
<Input
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[agent.agent_id]: e.target.value,
}))
}
placeholder="nickname (optional)"
disabled={busy}
/> />
<div className="flex items-center justify-end gap-2"> <span className="text-sm font-medium">
<Button {displayAgentLabel(agent)}
variant="outline" </span>
size="sm" <Badge
onClick={() => void onSaveNickname(agent)} variant={agent.connected ? "secondary" : "outline"}
disabled={busy} className="text-[0.6rem] px-1.5 py-0"
> >
Save Name {agent.connected ? "connected" : "offline"}
</Button> </Badge>
</span>
<code className="text-[0.65rem] text-muted-foreground/70 font-mono truncate max-w-full">
{agent.agent_id}
</code>
</button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -126,25 +111,43 @@ export function AgentsPage({
Revoke Revoke
</Button> </Button>
</div> </div>
<div className="mt-2 flex items-center gap-2">
<Input
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[agent.agent_id]: e.target.value,
}))
}
placeholder="nickname (optional)"
disabled={busy}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => void onSaveNickname(agent)}
disabled={busy}
>
Save
</Button>
</div> </div>
</div> </div>
))} ))}
{!agents.length && ( {!agents.length && (
<div className="px-1 py-2 text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center py-12 text-center">
<Inbox className="size-10 text-muted-foreground/30" />
<p className="mt-3 text-sm text-muted-foreground">
No agents enrolled No agents enrolled
</p>
<p className="text-xs text-muted-foreground/70">
Issue a token and enroll an agent to get started
</p>
</div> </div>
)} )}
</div> </div>
{status && (
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
{status}
</pre>
)}
{error && (
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
+22 -37
View File
@@ -1,7 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { Terminal } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@@ -11,6 +19,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AgentSelector } from "@/components/AgentSelector";
import { runCommand, type Agent, type CommandKind } from "@/api"; import { runCommand, type Agent, type CommandKind } from "@/api";
type Props = { type Props = {
@@ -26,11 +35,6 @@ export function CommandsPage({
onSelectAgent, onSelectAgent,
onAfterCommand, onAfterCommand,
}: Props) { }: Props) {
function displayAgentLabel(agent: Agent): string {
const nickname = agent.nickname?.trim();
return nickname ? nickname : agent.agent_id;
}
const [kind, setKind] = useState<CommandKind>("devs"); const [kind, setKind] = useState<CommandKind>("devs");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [output, setOutput] = useState("Select agent and run a command"); const [output, setOutput] = useState("Select agent and run a command");
@@ -44,9 +48,11 @@ export function CommandsPage({
try { try {
const out = await runCommand(selectedAgentId, kind, query.trim()); const out = await runCommand(selectedAgentId, kind, query.trim());
setOutput(JSON.stringify(out, null, 2)); setOutput(JSON.stringify(out, null, 2));
toast.success(`Command "${kind}" completed`);
if (onAfterCommand) await onAfterCommand(); if (onAfterCommand) await onAfterCommand();
} catch (err) { } catch (err) {
setOutput(String(err)); setOutput(String(err));
toast.error("Command failed", { description: String(err) });
} finally { } finally {
setRunning(false); setRunning(false);
} }
@@ -55,44 +61,23 @@ export function CommandsPage({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Command Runner</CardTitle> <CardTitle className="flex items-center gap-2">
<Terminal className="size-5 text-primary" />
Command Runner
</CardTitle>
<CardDescription>
Debug tool — send raw commands to agents and inspect JSON responses
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="grid gap-3" onSubmit={submit}> <form className="grid gap-3" onSubmit={submit}>
<label className="grid gap-1 text-sm text-muted-foreground"> <label className="grid gap-1 text-sm text-muted-foreground">
<span>Agent</span> <span>Agent</span>
<Select <AgentSelector
agents={agents}
value={selectedAgentId} value={selectedAgentId}
onValueChange={(value) => { onChange={onSelectAgent}
if (value) onSelectAgent(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem
key={agent.agent_id}
value={agent.agent_id}
disabled={!agent.connected}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
aria-hidden
/> />
<span className="min-w-0 truncate">
{displayAgentLabel(agent)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{agent.connected ? "connected" : "offline"}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label className="grid gap-1 text-sm text-muted-foreground">
+271 -38
View File
@@ -1,4 +1,15 @@
import type { Agent, Alert, AlertTransition } from "@/api"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Activity, Bell, Bot, Key, RefreshCw, ArrowRight } from "lucide-react";
import {
type Agent,
type Alert,
type AlertTransition,
type AuditEvent,
type EnrollTokenStatus,
fetchEnrollTokens,
} from "@/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -7,66 +18,288 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
type Props = { type Props = {
agents: Agent[]; agents: Agent[];
alerts: Alert[]; alerts: Alert[];
transitions: AlertTransition[]; transitions: AlertTransition[];
audit: AuditEvent[];
loading: boolean; loading: boolean;
onRefresh: () => void; onRefresh: () => void;
}; };
function relativeTime(unixMs: number): string {
const diff = Date.now() - unixMs;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
return "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30";
case "warning":
return "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30";
case "info":
return "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30";
default:
return "";
}
}
function eventIcon(eventType: string): string {
if (eventType.includes("wake")) return "⚡";
if (eventType.includes("enroll")) return "🔑";
if (eventType.includes("command")) return "▶";
if (eventType.includes("connect")) return "🔗";
if (eventType.includes("disconnect")) return "⛓️‍💥";
return "•";
}
export function DashboardPage({ export function DashboardPage({
agents, agents,
alerts, alerts,
transitions, transitions,
audit,
loading, loading,
onRefresh, onRefresh,
}: Props) { }: Props) {
const connected = agents.filter((a) => a.connected).length; const connected = agents.filter((a) => a.connected).length;
const total = agents.length;
const severityBuckets = alerts.reduce<Record<string, number>>((acc, a) => {
acc[a.severity] = (acc[a.severity] || 0) + 1;
return acc;
}, {});
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
useEffect(() => {
void fetchEnrollTokens()
.then(setTokens)
.catch(() => undefined);
}, []);
const activeTokens = tokens.filter((t) => !t.expired).length;
const recentAudit = audit.slice(0, 8);
if (loading && !agents.length) {
return ( return (
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card> {Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription>Agents</CardDescription> <Skeleton className="h-4 w-20" />
<CardTitle className="text-2xl">{agents.length}</CardTitle> <Skeleton className="mt-2 h-8 w-12" />
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Connected</CardDescription>
<CardTitle className="text-2xl">{connected}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Active Alerts</CardDescription>
<CardTitle className="text-2xl">{alerts.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Transitions</CardDescription>
<CardTitle className="text-2xl">{transitions.length}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
))}
<Card className="sm:col-span-2 lg:col-span-4"> <Card className="sm:col-span-2 lg:col-span-4">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0"> <CardHeader>
<CardTitle>Overview</CardTitle> <Skeleton className="h-5 w-32" />
<Button
onClick={onRefresh}
disabled={loading}
variant="outline"
size="sm"
>
{loading ? "Refreshing..." : "Refresh"}
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> {Array.from({ length: 4 }).map((_, i) => (
Use tabs to run commands, inspect audits, and monitor alerts in real <Skeleton key={i} className="h-12 w-full" />
time. ))}
</p> </CardContent>
</Card>
</section>
);
}
return (
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Fleet Status */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5">
<Bot className="size-3.5" />
Fleet Status
</CardDescription>
<Link
to="/agents"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowRight className="size-3.5" />
</Link>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">
{connected}
<span className="text-base font-normal text-muted-foreground">
/{total}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{connected === total
? "All agents connected"
: `${total - connected} offline`}
</p>
{total > 0 && (
<div className="mt-2 flex h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${(connected / total) * 100}%` }}
/>
</div>
)}
</CardContent>
</Card>
{/* Active Alerts */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5">
<Bell className="size-3.5" />
Active Alerts
</CardDescription>
<Link
to="/alerts"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowRight className="size-3.5" />
</Link>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
{Object.keys(severityBuckets).length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{Object.entries(severityBuckets)
.sort(([a], [b]) => a.localeCompare(b))
.map(([sev, count]) => (
<Badge
key={sev}
variant="outline"
className={`text-[0.65rem] ${severityColor(sev)}`}
>
{count} {sev}
</Badge>
))}
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">All clear</p>
)}
</CardContent>
</Card>
{/* Transitions */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5">
<Activity className="size-3.5" />
Transitions
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">
{transitions.length}
</div>
<p className="mt-1 text-xs text-muted-foreground">
Recent state changes
</p>
</CardContent>
</Card>
{/* Token Health */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5">
<Key className="size-3.5" />
Enroll Tokens
</CardDescription>
<Link
to="/tokens"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowRight className="size-3.5" />
</Link>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{activeTokens}</div>
<p className="mt-1 text-xs text-muted-foreground">
{activeTokens === 1 ? "active token" : "active tokens"}
</p>
</CardContent>
</Card>
{/* Recent Activity Feed */}
<Card className="sm:col-span-2 lg:col-span-4">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle>Recent Activity</CardTitle>
<CardDescription className="mt-1">
Latest audit events across the fleet
</CardDescription>
</div>
<Button
onClick={onRefresh}
disabled={loading}
variant="outline"
size="sm"
className="gap-1.5"
>
<RefreshCw
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</CardHeader>
<CardContent>
{recentAudit.length > 0 ? (
<div className="grid gap-0">
{recentAudit.map((event, i) => (
<div
key={event.event_id}
className={`flex items-start gap-3 py-3 ${
i < recentAudit.length - 1
? "border-b border-border/50"
: ""
}`}
>
<span
className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-sm"
aria-hidden
>
{eventIcon(event.event_type)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{event.event_type.replace(/_/g, " ")}
</span>
<Badge
variant={
event.outcome === "success"
? "secondary"
: "destructive"
}
className="text-[0.6rem] px-1.5 py-0"
>
{event.outcome}
</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{event.message}
</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{relativeTime(event.ts_unix * 1000)}
</span>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Activity className="size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No recent activity
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
+43 -448
View File
@@ -1,37 +1,19 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
Check, import { toast } from "sonner";
Copy,
MoreHorizontal,
RefreshCw,
Search,
Zap,
} from "lucide-react";
import { import {
attachDeviceIdentifier,
createKnownDevice,
fetchFleetDevices, fetchFleetDevices,
fetchKnownDevices, fetchKnownDevices,
mergeKnownDevice,
refreshFleetDevices, refreshFleetDevices,
wakeFleetDevice, wakeFleetDevice,
type Agent, type Agent,
type FleetDevice, type FleetDevice,
type FleetWakeRoute,
type KnownDevice, type KnownDevice,
} from "@/api"; } from "@/api";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -45,7 +27,24 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton";
import {
FilterSelect,
MobileLabel,
PresenceBadge,
} from "@/pages/fleet/FleetComponents";
import { FleetDeviceDetailsDialog } from "@/pages/fleet/FleetDeviceDetailsDialog";
import {
agentLabel,
formatSeen,
knownFilters,
presenceFilters,
presenceRank,
summarize,
type KnownFilter,
type SortDir,
type SortKey,
} from "@/pages/fleet/utils";
type Props = { type Props = {
agents: Agent[]; agents: Agent[];
@@ -54,20 +53,6 @@ type Props = {
onAfterWake?: () => Promise<void>; onAfterWake?: () => Promise<void>;
}; };
type SortKey = "name" | "presence" | "last_seen" | "agents" | "ip" | "mac";
type SortDir = "asc" | "desc";
type KnownFilter = "all" | "known" | "unknown";
const presenceFilters = [
"all",
"online",
"likely_online",
"unknown",
"offline",
] as const;
const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
export function DevicesPage({ agents, onAfterWake }: Props) { export function DevicesPage({ agents, onAfterWake }: Props) {
const [devices, setDevices] = useState<FleetDevice[]>([]); const [devices, setDevices] = useState<FleetDevice[]>([]);
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]); const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
@@ -82,14 +67,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [wakeBusyKey, setWakeBusyKey] = useState(""); const [wakeBusyKey, setWakeBusyKey] = useState("");
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const [copied, setCopied] = useState("");
const [details, setDetails] = useState<FleetDevice | null>(null); const [details, setDetails] = useState<FleetDevice | null>(null);
async function loadFleet() { async function loadFleet() {
setLoading(true); setLoading(true);
setError("");
try { try {
const [nextDevices, nextKnownDevices] = await Promise.all([ const [nextDevices, nextKnownDevices] = await Promise.all([
fetchFleetDevices({ fetchFleetDevices({
@@ -104,7 +85,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
setDevices(nextDevices); setDevices(nextDevices);
setKnownDevices(nextKnownDevices); setKnownDevices(nextKnownDevices);
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Failed to load fleet", { description: String(err) });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -112,19 +93,16 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
async function refreshAllConnected() { async function refreshAllConnected() {
setRefreshing(true); setRefreshing(true);
setStatus("");
setError("");
try { try {
const result = await refreshFleetDevices(); const result = await refreshFleetDevices();
const failed = result.agents.filter((agent) => agent.status !== "ok"); const failed = result.agents.filter((agent) => agent.status !== "ok");
setStatus( const msg = failed.length
failed.length
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed` ? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`, : `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
); toast.success(msg);
await loadFleet(); await loadFleet();
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Fleet refresh failed", { description: String(err) });
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -133,20 +111,17 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
async function wakeDevice(device: FleetDevice, routeId?: string | null) { async function wakeDevice(device: FleetDevice, routeId?: string | null) {
if (!device.recommended_route && !routeId) return; if (!device.recommended_route && !routeId) return;
setWakeBusyKey(device.device_key); setWakeBusyKey(device.device_key);
setStatus("");
setError("");
try { try {
const result = await wakeFleetDevice({ const result = await wakeFleetDevice({
deviceKey: device.device_key, deviceKey: device.device_key,
routeId, routeId,
}); });
setStatus( const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
`Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`, toast.success(msg);
);
if (onAfterWake) await onAfterWake(); if (onAfterWake) await onAfterWake();
await loadFleet(); await loadFleet();
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Wake failed", { description: String(err) });
} finally { } finally {
setWakeBusyKey(""); setWakeBusyKey("");
} }
@@ -157,7 +132,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
if (!text) return; if (!text) return;
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(label); toast.success(`Copied ${label}`);
} catch { } catch {
const area = document.createElement("textarea"); const area = document.createElement("textarea");
area.value = text; area.value = text;
@@ -167,7 +142,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
area.select(); area.select();
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(area); document.body.removeChild(area);
setCopied(label); toast.success(`Copied ${label}`);
} }
} }
@@ -182,12 +157,6 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]); }, [query]);
useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(""), 1400);
return () => window.clearTimeout(timer);
}, [copied]);
const sortedDevices = useMemo(() => { const sortedDevices = useMemo(() => {
const rows = [...devices]; const rows = [...devices];
rows.sort((a, b) => { rows.sort((a, b) => {
@@ -320,23 +289,14 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
/> />
</div> </div>
{status && ( {/* Skeleton loading */}
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm"> {loading && !devices.length ? (
{status} <div className="grid gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div> </div>
)} ) : (
{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>
)}
{copied && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="size-4" aria-hidden />
Copied {copied}
</div>
)}
<div className="grid gap-2"> <div className="grid gap-2">
<div className="hidden grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm font-medium xl:grid"> <div className="hidden grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm font-medium xl:grid">
<span>Device</span> <span>Device</span>
@@ -358,11 +318,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
/> />
))} ))}
{!sortedDevices.length && ( {!sortedDevices.length && (
<div className="rounded-md border bg-card p-4 text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center rounded-md border bg-card py-12 text-center">
No devices match the current filters. <Inbox className="size-10 text-muted-foreground/30" />
<p className="mt-3 text-sm text-muted-foreground">
No devices match the current filters
</p>
</div> </div>
)} )}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -471,372 +435,3 @@ function FleetDeviceRow({
</div> </div>
); );
} }
function FleetDeviceDetailsDialog({
device,
knownDevices,
open,
busy,
onOpenChange,
onWake,
onCopy,
onChanged,
}: {
device: FleetDevice | null;
knownDevices: KnownDevice[];
open: boolean;
busy: boolean;
onOpenChange: (open: boolean) => void;
onWake: (routeId?: string | null) => void;
onCopy: (label: string, value: string) => void;
onChanged: () => void;
}) {
const [displayName, setDisplayName] = useState("");
const [targetDeviceId, setTargetDeviceId] = useState("");
const [routeId, setRouteId] = useState("");
const [error, setError] = useState("");
useEffect(() => {
setDisplayName(device?.display_name ?? "");
setTargetDeviceId("");
setRouteId(device?.recommended_route?.route_id ?? "");
setError("");
}, [device]);
if (!device) return null;
const currentDevice = device;
const identifiers = identifiersFor(currentDevice);
const otherKnownDevices = knownDevices.filter(
(known) => known.device_id !== currentDevice.known_device?.device_id,
);
async function createRemembered() {
setError("");
try {
await createKnownDevice({
display_name: displayName.trim() || currentDevice.display_name,
pinned: true,
identifiers,
});
onChanged();
} catch (err) {
setError(String(err));
}
}
async function attachToExisting() {
if (!targetDeviceId) return;
setError("");
try {
for (const identifier of identifiers) {
await attachDeviceIdentifier(targetDeviceId, identifier);
}
onChanged();
} catch (err) {
setError(String(err));
}
}
async function mergeIntoExisting() {
if (!targetDeviceId || !currentDevice.known_device) return;
setError("");
try {
await mergeKnownDevice(
targetDeviceId,
currentDevice.known_device.device_id,
);
onChanged();
onOpenChange(false);
} catch (err) {
setError(String(err));
}
}
const selectedRoute =
device.route_candidates.find((route) => route.route_id === routeId) ??
device.recommended_route;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
<DialogHeader>
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
<div className="min-w-0">
<DialogTitle className="truncate">
{device.display_name}
</DialogTitle>
<DialogDescription>
{device.known_device
? "Remembered fleet device"
: "Observed fleet device"}
</DialogDescription>
</div>
<PresenceBadge presence={device.presence} />
</div>
</DialogHeader>
{error && (
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
<div className="grid gap-3 md:grid-cols-2">
<DetailBlock label="IPs" values={device.ips} onCopy={onCopy} />
<DetailBlock label="MACs" values={device.macs} onCopy={onCopy} />
<DetailBlock
label="Hostnames"
values={device.hostnames}
onCopy={onCopy}
/>
<DetailBlock
label="Agents"
values={device.agents.map((agent) =>
agentLabel(agent.agent_id, agent.nickname),
)}
onCopy={onCopy}
/>
</div>
<Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Wake route</h3>
<Button
size="sm"
onClick={() => onWake(selectedRoute?.route_id ?? null)}
disabled={busy || !selectedRoute?.wakeable}
>
<Zap className="size-4" aria-hidden />
{busy ? "Waking" : "Wake"}
</Button>
</div>
<Select
value={routeId}
onValueChange={(value) => setRouteId(value ?? "")}
>
<SelectTrigger>
<span>
{selectedRoute
? routeLabel(selectedRoute)
: "No connected MAC-backed route"}
</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{device.route_candidates.map((route) => (
<SelectItem key={route.route_id} value={route.route_id}>
{routeLabel(route)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="grid gap-3">
<h3 className="text-sm font-medium">Remember / merge</h3>
{!device.known_device && (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
<Input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Device name"
/>
<Button
onClick={() => void createRemembered()}
disabled={!identifiers.length}
>
Remember device
</Button>
</div>
)}
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
<Select
value={targetDeviceId}
onValueChange={(value) => setTargetDeviceId(value ?? "")}
>
<SelectTrigger>
<span>
{targetDeviceId
? knownDevices.find(
(known) => known.device_id === targetDeviceId,
)?.display_name
: "Select known device"}
</span>
</SelectTrigger>
<SelectContent
className="max-w-[min(92vw,30rem)]"
alignItemWithTrigger={false}
>
{otherKnownDevices.map((known) => (
<SelectItem key={known.device_id} value={known.device_id}>
{known.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => void attachToExisting()}
disabled={!targetDeviceId || !identifiers.length}
>
Attach IDs
</Button>
<Button
variant="outline"
onClick={() => void mergeIntoExisting()}
disabled={!targetDeviceId || !device.known_device}
>
Merge
</Button>
</div>
<p className="text-xs text-muted-foreground">
Proposed identifiers:{" "}
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
"-"}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FilterSelect({
label,
value,
values,
onChange,
}: {
label: string;
value: string;
values: readonly string[];
onChange: (value: string) => void;
}) {
return (
<label className="grid gap-1 text-sm text-muted-foreground">
<span>{label}</span>
<Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger>
<span>{value.replace("_", " ")}</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{values.map((item) => (
<SelectItem key={item} value={item}>
{item.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
function DetailBlock({
label,
values,
onCopy,
}: {
label: string;
values: string[];
onCopy: (label: string, value: string) => void;
}) {
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
{label}
</div>
<div className="grid gap-1">
{values.length ? (
values.map((value) => (
<button
key={value}
type="button"
className="flex min-w-0 items-center justify-between gap-2 rounded px-1 py-0.5 text-left hover:bg-accent"
onClick={() => onCopy(label, value)}
>
<span className="min-w-0 truncate">{value}</span>
<Copy
className="size-3.5 shrink-0 text-muted-foreground"
aria-hidden
/>
</button>
))
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</div>
</div>
);
}
function PresenceBadge({ presence }: { presence: string }) {
return (
<Badge variant={presence === "offline" ? "outline" : "secondary"}>
{presence.replace("_", " ")}
</Badge>
);
}
function MobileLabel({ label }: { label: string }) {
return (
<span className="mb-1 block text-xs font-medium text-muted-foreground xl:hidden">
{label}
</span>
);
}
function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length <= 2) return values.join(", ");
return `${values[0]}, ${values[1]} (+${values.length - 2})`;
}
function formatSeen(value: number | null): string {
if (!value) return "-";
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
return new Date(value * 1000).toLocaleString();
}
function presenceRank(presence: string): number {
if (presence === "online") return 4;
if (presence === "likely_online") return 3;
if (presence === "unknown") return 2;
if (presence === "offline") return 1;
return 0;
}
function identifiersFor(
device: FleetDevice,
): { kind: string; value: string }[] {
return [
...device.macs.map((value) => ({ kind: "mac", value })),
...device.ips.map((value) => ({ kind: "ip", value })),
];
}
function agentLabel(agentId: string, nickname?: string | null): string {
const trimmed = nickname?.trim();
return trimmed ? trimmed : agentId;
}
function routeLabel(route: FleetWakeRoute): string {
const status = route.connected ? "connected" : "offline";
const target = route.mac
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
: (route.ip ?? "-");
return `${agentLabel(route.agent_id, route.nickname)} · ${target} · ${route.source} · ${status}`;
}
+156 -43
View File
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Key, Copy, Inbox } from "lucide-react";
import { import {
type EnrollTokenStatus, type EnrollTokenStatus,
@@ -8,19 +10,36 @@ import {
} from "@/api"; } from "@/api";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
function formatUnix(unix: number): string { function formatUnix(unix: number): string {
return new Date(unix * 1000).toLocaleString(); return new Date(unix * 1000).toLocaleString();
} }
const TTL_PRESETS = [
{ label: "1 hour", seconds: 3600 },
{ label: "12 hours", seconds: 43200 },
{ label: "1 day", seconds: 86400 },
{ label: "7 days", seconds: 604800 },
] as const;
export function TokensPage() { export function TokensPage() {
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]); const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
const [ttlSeconds, setTtlSeconds] = useState("86400"); const [ttlSeconds, setTtlSeconds] = useState(86400);
const [status, setStatus] = useState(""); const [customTtl, setCustomTtl] = useState("");
const [error, setError] = useState(""); const [useCustom, setUseCustom] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [lastIssued, setLastIssued] = useState<{
token: string;
expires: string;
} | null>(null);
const activeCount = useMemo( const activeCount = useMemo(
() => tokens.filter((t) => !t.expired).length, () => tokens.filter((t) => !t.expired).length,
@@ -29,12 +48,11 @@ export function TokensPage() {
async function load() { async function load() {
setBusy(true); setBusy(true);
setError("");
try { try {
const next = await fetchEnrollTokens(); const next = await fetchEnrollTokens();
setTokens(next); setTokens(next);
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Failed to load tokens", { description: String(err) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -42,17 +60,23 @@ export function TokensPage() {
async function onIssue() { async function onIssue() {
setBusy(true); setBusy(true);
setError("");
setStatus("");
try { try {
const ttl = Number.parseInt(ttlSeconds, 10); const effectiveTtl = useCustom
const issued = await issueEnrollToken(Number.isFinite(ttl) ? ttl : 86400); ? Number.parseInt(customTtl, 10) || 86400
setStatus( : ttlSeconds;
`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`, const issued = await issueEnrollToken(
Number.isFinite(effectiveTtl) ? effectiveTtl : 86400,
); );
setLastIssued({
token: issued.enroll_token,
expires: formatUnix(issued.expires_at_unix),
});
toast.success("Token issued", {
description: `Expires ${formatUnix(issued.expires_at_unix)}`,
});
await load(); await load();
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Failed to issue token", { description: String(err) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -60,30 +84,50 @@ export function TokensPage() {
async function onRevoke(token: string) { async function onRevoke(token: string) {
setBusy(true); setBusy(true);
setError("");
setStatus("");
try { try {
const result = await revokeEnrollToken(token); const result = await revokeEnrollToken(token);
setStatus( if (result.revoked) {
result.revoked ? `Revoked ${token}` : `${token} was already absent`, toast.success(`Revoked ${token}`);
); } else {
toast.info(`${token} was already absent`);
}
await load(); await load();
} catch (err) { } catch (err) {
setError(String(err)); toast.error("Failed to revoke token", { description: String(err) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
} catch {
toast.error("Copy failed");
}
}
useEffect(() => { useEffect(() => {
void load(); void load();
}, []); }, []);
const controlPlaneUrl =
window.location.origin.replace(/:\d+$/, "") || "https://cp.example.com";
return ( return (
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-4 xl:grid-cols-2">
<Card> <Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0"> <CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<CardTitle>Enroll Tokens</CardTitle> <div>
<CardTitle className="flex items-center gap-2">
<Key className="size-5 text-primary" />
Enroll Tokens
</CardTitle>
<CardDescription className="mt-1">
{activeCount} active, {tokens.length} shown
</CardDescription>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -94,17 +138,16 @@ export function TokensPage() {
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{activeCount} active, {tokens.length} shown
</p>
<div className="grid gap-2"> <div className="grid gap-2">
{tokens.map((token) => ( {tokens.map((token) => (
<div <div
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2" className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
key={token.enroll_token} key={token.enroll_token}
> >
<div> <div className="min-w-0">
<strong>{token.enroll_token}</strong> <strong className="block truncate text-sm">
{token.enroll_token}
</strong>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
expires: {formatUnix(token.expires_at_unix)} expires: {formatUnix(token.expires_at_unix)}
</div> </div>
@@ -125,43 +168,113 @@ export function TokensPage() {
</div> </div>
))} ))}
{!tokens.length && ( {!tokens.length && (
<div className="px-1 py-2 text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center py-8 text-center">
<Inbox className="size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No tokens found No tokens found
</p>
</div> </div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="grid gap-4 content-start">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Issue Token</CardTitle> <CardTitle>Issue Token</CardTitle>
<CardDescription>
Create a new enrollment token for agents
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-4">
<label className="grid gap-1 text-sm text-muted-foreground"> <div>
<span>TTL seconds</span> <p className="mb-2 text-sm text-muted-foreground">
Token lifetime
</p>
<div className="flex flex-wrap gap-2">
{TTL_PRESETS.map((preset) => (
<Button
key={preset.seconds}
variant={
!useCustom && ttlSeconds === preset.seconds
? "secondary"
: "outline"
}
size="sm"
onClick={() => {
setTtlSeconds(preset.seconds);
setUseCustom(false);
}}
>
{preset.label}
</Button>
))}
<Button
variant={useCustom ? "secondary" : "outline"}
size="sm"
onClick={() => setUseCustom(true)}
>
Custom
</Button>
</div>
{useCustom && (
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
<span>TTL (seconds)</span>
<Input <Input
type="number" type="number"
min={1} min={1}
value={ttlSeconds} value={customTtl}
onChange={(e) => setTtlSeconds(e.target.value)} onChange={(e) => setCustomTtl(e.target.value)}
placeholder="86400"
/> />
</label> </label>
)}
</div>
<Button onClick={() => void onIssue()} disabled={busy}> <Button onClick={() => void onIssue()} disabled={busy}>
Issue Issue Token
</Button> </Button>
{status && (
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
{status}
</pre>
)}
{error && (
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Install command after issuing */}
{lastIssued && (
<Card className="border-primary/30 bg-primary/5">
<CardHeader>
<CardTitle className="text-base">Agent Installation</CardTitle>
<CardDescription>
Token: <code className="text-xs">{lastIssued.token}</code>
<br />
Expires: {lastIssued.expires}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-xs text-muted-foreground">
Run this on the target device to enroll:
</p>
<div className="relative">
<pre className="overflow-auto rounded-md border bg-muted/60 p-3 pr-10 font-mono text-xs">
{`wakey-agent enroll \\
--server-url ${controlPlaneUrl} \\
--token ${lastIssued.token}`}
</pre>
<Button
variant="ghost"
size="sm"
className="absolute right-1.5 top-1.5 size-7 p-0"
onClick={() =>
void copyToClipboard(
`wakey-agent enroll --server-url ${controlPlaneUrl} --token ${lastIssued.token}`,
)
}
>
<Copy className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</section> </section>
); );
} }
+188
View File
@@ -0,0 +1,188 @@
import { useState } from "react";
import { toast } from "sonner";
import { Zap, Inbox } from "lucide-react";
import { runCommand, type Agent } from "@/api";
import { AgentSelector } from "@/components/AgentSelector";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type WakeEvent = {
ts: number;
target: string;
agentId: string;
outcome: string;
detail: string;
};
type Props = {
agents: Agent[];
selectedAgentId: string;
onSelectAgent: (agentId: string) => void;
onAfterWake?: () => Promise<void>;
};
export function WakeToolsPage({
agents,
selectedAgentId,
onSelectAgent,
onAfterWake,
}: Props) {
const [target, setTarget] = useState("");
const [busy, setBusy] = useState(false);
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
async function handleWake(e?: React.FormEvent) {
e?.preventDefault();
const trimmed = target.trim();
if (!trimmed || !selectedAgentId || busy) return;
setBusy(true);
try {
const response = await runCommand(selectedAgentId, "wake", trimmed);
const detail = JSON.stringify(response);
const ok = detail.includes('"ok"') || detail.includes('"success"');
setRecentWakes((prev) =>
[
{
ts: Date.now(),
target: trimmed,
agentId: selectedAgentId,
outcome: ok ? "ok" : "fail",
detail,
},
...prev,
].slice(0, 20),
);
if (ok) {
toast.success(`Woke ${trimmed}`);
} else {
toast.error(`Wake failed: ${trimmed}`, { description: detail });
}
if (onAfterWake) await onAfterWake();
setTarget("");
} catch (err) {
setRecentWakes((prev) =>
[
{
ts: Date.now(),
target: trimmed,
agentId: selectedAgentId,
outcome: "error",
detail: String(err),
},
...prev,
].slice(0, 20),
);
toast.error("Wake failed", { description: String(err) });
} finally {
setBusy(false);
}
}
return (
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,1fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="size-5 text-primary" />
Wake Target
</CardTitle>
<CardDescription>
Send a Wake-on-LAN packet by hostname, IP, or MAC address
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="grid gap-1.5 text-sm text-muted-foreground">
<span>Agent</span>
<AgentSelector
agents={agents}
value={selectedAgentId}
onChange={onSelectAgent}
/>
</label>
<form onSubmit={handleWake} className="grid gap-3">
<label className="grid gap-1.5 text-sm text-muted-foreground">
<span>Target</span>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
disabled={busy}
autoFocus
/>
</label>
<Button
type="submit"
disabled={!selectedAgentId || !target.trim() || busy}
className="gap-2"
>
<Zap className="size-4" />
{busy ? "Sending..." : "Wake"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Recent Wakes</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setRecentWakes([])}
>
Clear
</Button>
</CardHeader>
<CardContent>
{recentWakes.length > 0 ? (
<div className="grid gap-0">
{recentWakes.map((event, idx) => (
<div
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
key={`${event.ts}-${idx}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{event.target}
</span>
<Badge
variant={
event.outcome === "ok" ? "secondary" : "destructive"
}
className="text-[0.6rem] px-1.5 py-0"
>
{event.outcome}
</Badge>
</div>
<p className="text-[0.65rem] text-muted-foreground/70">
{new Date(event.ts).toLocaleString()} via {event.agentId}
</p>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Inbox className="size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No wake actions yet
</p>
</div>
)}
</CardContent>
</Card>
</section>
);
}
@@ -1,68 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import type { DeviceRow } from "@/pages/devices/types";
type Props = {
device: DeviceRow | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
function summarizeAll(values: string[]): string {
if (!values.length) return "-";
return values.join(", ");
}
export function DeviceDetailsDialog({ device, open, onOpenChange }: Props) {
if (!device) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0">
<div className="grid gap-4 p-4 text-sm">
<DialogHeader className="pr-8">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="truncate">{device.name}</DialogTitle>
<DialogDescription>Device details</DialogDescription>
</div>
<Badge variant="outline">{device.presence}</Badge>
</div>
</DialogHeader>
<div>
<p className="mb-1 font-medium">IP addresses</p>
<p className="text-muted-foreground">{summarizeAll(device.ips)}</p>
</div>
<Separator />
<div>
<p className="mb-1 font-medium">MAC addresses</p>
<p className="text-muted-foreground">{summarizeAll(device.macs)}</p>
</div>
<Separator />
<div>
<p className="mb-1 font-medium">Interfaces</p>
<p className="text-muted-foreground">
{summarizeAll(device.interfaces)}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,196 +0,0 @@
import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { DeviceRow, SortDir } from "@/pages/devices/types";
import {
chooseWakeTarget,
nextSort,
summarize,
type SortKey,
} from "@/pages/devices/utils";
type Props = {
rows: DeviceRow[];
selectedIds: string[];
sort: { key: SortKey; dir: SortDir };
allVisibleSelected: boolean;
wakeBusyId: string;
selectedAgentId: string;
bulkWakeBusy: boolean;
onToggleAllVisible: (checked: boolean) => void;
onToggleRow: (id: string, checked: boolean) => void;
onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
onWakeDevice: (device: DeviceRow) => void;
onCopyValue: (label: string, value: string) => void;
onOpenDetails: (device: DeviceRow) => void;
};
function sortArrow(active: boolean, dir: SortDir) {
if (!active) return "";
return dir === "asc" ? "▲" : "▼";
}
export function DeviceInventoryTable({
rows,
selectedIds,
sort,
allVisibleSelected,
wakeBusyId,
selectedAgentId,
bulkWakeBusy,
onToggleAllVisible,
onToggleRow,
onSortChange,
onWakeDevice,
onCopyValue,
onOpenDetails,
}: Props) {
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("button, input, a, [role='menuitem']"));
}
return (
<div className="device-list grid gap-2">
<div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
<span className="device-cell device-select">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(e) => onToggleAllVisible(e.target.checked)}
disabled={!rows.length}
aria-label="Select all visible devices"
/>
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "name"))}
>
Name {sortArrow(sort.key === "name", sort.dir)}
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "ip"))}
>
IP {sortArrow(sort.key === "ip", sort.dir)}
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "mac"))}
>
MAC {sortArrow(sort.key === "mac", sort.dir)}
</span>
<span
className="sortable-col device-cell"
onClick={() => onSortChange(nextSort(sort, "presence"))}
>
Presence {sortArrow(sort.key === "presence", sort.dir)}
</span>
<span className="device-cell device-action">Actions</span>
</div>
{rows.map((row) => (
<div
className="device-row cursor-pointer rounded-md border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent/30"
key={row.id}
role="button"
tabIndex={0}
onClick={(event) => {
if (shouldIgnoreRowClick(event.target)) return;
onOpenDetails(row);
}}
onKeyDown={(event) => {
if (shouldIgnoreRowClick(event.target)) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenDetails(row);
}
}}
>
<span className="device-cell device-select" data-label="Pick">
<input
type="checkbox"
checked={selectedIds.includes(row.id)}
onChange={(e) => onToggleRow(row.id, e.target.checked)}
aria-label={`Select ${row.name}`}
/>
</span>
<span className="device-cell" data-label="Name" title={row.name}>
{row.name}
</span>
<span
className="device-cell text-muted-foreground"
data-label="IP"
title={row.ips.join(", ") || "-"}
>
{summarize(row.ips)}
</span>
<span
className="device-cell text-muted-foreground"
data-label="MAC"
title={row.macs.join(", ") || "-"}
>
{summarize(row.macs)}
</span>
<span className="device-cell" data-label="Presence">
<Badge variant="outline">{row.presence}</Badge>
</span>
<span className="device-cell device-action" data-label="">
<Button
onClick={() => onWakeDevice(row)}
disabled={
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
}
size="sm"
className="device-btn"
>
{wakeBusyId === row.id ? "Waking..." : "Wake"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"device-copy-trigger",
)}
>
Copy
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-32">
<DropdownMenuItem
onClick={() => onCopyValue("target", chooseWakeTarget(row))}
>
Copy target
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopyValue("name", row.name)}>
Copy name
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onCopyValue("ip", row.ips[0] || "")}
>
Copy IP
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onCopyValue("mac", row.macs[0] || "")}
>
Copy MAC
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
))}
{!rows.length && (
<div className="px-1 py-2 text-sm text-muted-foreground">
No devices found
</div>
)}
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { WakeEvent } from "@/pages/devices/types";
type Props = {
events: WakeEvent[];
onClear: () => void;
};
export function WakeHistoryCard({ events, onClear }: Props) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Recent Wake Actions</CardTitle>
<Button variant="outline" size="sm" onClick={onClear}>
Clear
</Button>
</CardHeader>
<CardContent>
<div className="grid gap-2">
{events.map((event, idx) => (
<div
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
key={`${event.ts}-${event.target}-${idx}`}
>
<div>
<strong>{event.target}</strong>
<div className="text-xs text-muted-foreground">
{new Date(event.ts).toLocaleString()} on {event.agentId}
</div>
<div className="text-xs text-muted-foreground">
{event.detail}
</div>
{event.requestId && (
<Link
className="linkish text-xs text-muted-foreground"
to={`/audit?q=${encodeURIComponent(event.requestId)}`}
>
View related audit ({event.requestId})
</Link>
)}
</div>
<Badge
variant={event.outcome === "ok" ? "secondary" : "destructive"}
>
{event.outcome}
</Badge>
</div>
))}
{!events.length && (
<div className="px-1 py-2 text-sm text-muted-foreground">
No wake actions yet
</div>
)}
</div>
</CardContent>
</Card>
);
}
-174
View File
@@ -1,174 +0,0 @@
.quick-wake {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.6rem;
align-items: end;
margin-top: 0.65rem;
}
.quick-wake label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.9rem;
}
.presence-filters {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.65rem;
}
.bulk-actions {
display: flex;
gap: 0.45rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.65rem;
}
.select-visible {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.88rem;
}
.linkish {
display: inline-block;
margin-top: 0.25rem;
text-decoration: underline;
}
.device-list {
margin-top: 0.8rem;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.device-row {
display: grid;
grid-template-columns:
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
minmax(100px, 0.85fr) minmax(170px, max-content);
align-items: center;
column-gap: 0.4rem;
row-gap: 0.55rem;
min-width: 860px;
}
.device-header {
font-weight: 600;
position: sticky;
top: 0;
z-index: 2;
}
.device-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sortable-col {
cursor: pointer;
user-select: none;
}
.device-action {
display: flex;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 0.35rem;
justify-self: end;
}
.device-btn {
min-width: 4.25rem;
}
.device-copy-trigger {
min-width: 6.2rem;
}
.device-select {
display: flex;
justify-content: center;
}
@media (max-width: 720px) {
.device-header {
display: none;
}
.device-row {
grid-template-columns: 1fr auto;
grid-template-areas:
"select action"
"name name"
"presence presence"
"ip ip"
"mac mac";
align-items: start;
min-width: 0;
}
.device-row .device-cell {
white-space: normal;
overflow-wrap: anywhere;
text-overflow: clip;
}
.device-row .device-cell[data-label]::before {
content: attr(data-label);
display: block;
font-size: 0.72rem;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.1rem;
}
.device-row .device-cell[data-label="Name"] {
grid-area: name;
}
.device-row .device-cell[data-label="Pick"] {
grid-area: select;
display: flex;
align-items: center;
justify-content: flex-start;
padding-top: 0.15rem;
}
.device-row .device-cell[data-label="Pick"]::before {
display: none;
content: "";
}
.device-row .device-cell[data-label="Presence"] {
grid-area: presence;
}
.device-row .device-cell[data-label="IP"] {
grid-area: ip;
}
.device-row .device-cell[data-label="MAC"] {
grid-area: mac;
}
.device-row .device-action {
grid-area: action;
justify-self: end;
justify-content: flex-end;
flex-wrap: wrap;
}
.quick-wake {
grid-template-columns: 1fr;
}
}
-21
View File
@@ -1,21 +0,0 @@
export type DeviceRow = {
id: string;
name: string;
isUnnamed: boolean;
ips: string[];
macs: string[];
interfaces: string[];
presence: string;
};
export type WakeEvent = {
ts: number;
target: string;
agentId: string;
outcome: string;
requestId: string;
detail: string;
};
export type SortDir = "asc" | "desc";
export type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
-174
View File
@@ -1,174 +0,0 @@
import type {
PresenceFilter,
SortDir,
DeviceRow,
WakeEvent,
} from "@/pages/devices/types";
export const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
export const PRESENCE_FILTERS: PresenceFilter[] = [
"all",
"online",
"likely_online",
"unknown",
];
export const sorters = {
name: (a: DeviceRow, b: DeviceRow) => a.name.localeCompare(b.name),
ip: (a: DeviceRow, b: DeviceRow) =>
(a.ips[0] || "").localeCompare(b.ips[0] || ""),
mac: (a: DeviceRow, b: DeviceRow) =>
(a.macs[0] || "").localeCompare(b.macs[0] || ""),
presence: (a: DeviceRow, b: DeviceRow) =>
a.presence.localeCompare(b.presence),
};
export type SortKey = keyof typeof sorters;
export function parseInventoryRows(payload: unknown): DeviceRow[] {
if (!payload || typeof payload !== "object") return [];
const obj = payload as Record<string, unknown>;
if (obj.kind !== "inventory") return [];
if (!obj.devices || !Array.isArray(obj.devices)) return [];
return obj.devices
.map((raw, idx) => {
if (!raw || typeof raw !== "object") return null;
const device = raw as Record<string, unknown>;
const names = Array.isArray(device.names)
? (device.names.filter((v) => typeof v === "string") as string[])
: [];
const ips = Array.isArray(device.ips)
? (device.ips.filter((v) => typeof v === "string") as string[])
: [];
const macs = Array.isArray(device.macs)
? (device.macs.filter((v) => typeof v === "string") as string[])
: [];
const interfaces = Array.isArray(device.interfaces)
? (device.interfaces.filter((v) => typeof v === "string") as string[])
: [];
const presence =
typeof device.presence === "string" ? device.presence : "unknown";
const isUnnamed = names.length === 0;
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
id,
name: names[0] || "(unnamed)",
isUnnamed,
ips,
macs,
interfaces,
presence,
};
})
.filter((v): v is DeviceRow => Boolean(v));
}
export function parseWakeSummary(response: unknown): {
outcome: string;
requestId: string;
detail: string;
} {
if (!response || typeof response !== "object") {
return { outcome: "error", requestId: "", detail: "invalid wake response" };
}
const envelope = response as Record<string, unknown>;
const requestId =
typeof envelope.request_id === "string" ? envelope.request_id : "";
const status =
typeof envelope.status === "string" ? envelope.status : "error";
if (status !== "ok") {
const error = envelope.error as Record<string, unknown> | undefined;
const detail =
typeof error?.message === "string" ? error.message : "wake failed";
return { outcome: "error", requestId, detail };
}
const result = envelope.result as Record<string, unknown> | undefined;
if (result?.kind !== "wake") {
return { outcome: "ok", requestId, detail: "wake dispatched" };
}
const entries = Array.isArray(result.result) ? result.result : [];
const targetOutcomes = entries
.map((row) => {
if (!row || typeof row !== "object") return "";
const rowObj = row as Record<string, unknown>;
const statusRaw = rowObj.status;
const status =
typeof statusRaw === "string"
? statusRaw
: typeof (statusRaw as Record<string, unknown> | undefined)?.kind ===
"string"
? String((statusRaw as Record<string, unknown>).kind)
: "unknown";
const ip = typeof rowObj.ip === "string" ? rowObj.ip : "?";
const mac = typeof rowObj.mac === "string" ? rowObj.mac : "?";
return `${status}(${ip}/${mac})`;
})
.filter(Boolean);
const detail = targetOutcomes.length
? targetOutcomes.join(", ")
: "wake dispatched";
const failed = targetOutcomes.some(
(s) =>
s.startsWith("incomplete") ||
s.startsWith("nonexistent_address") ||
s.startsWith("wrong_size"),
);
return { outcome: failed ? "error" : "ok", requestId, detail };
}
export function chooseWakeTarget(device: DeviceRow): string {
return device.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
}
export function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length === 1) return values[0];
return `${values[0]} (+${values.length - 1})`;
}
export function loadHistory(): WakeEvent[] {
try {
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((row): row is WakeEvent => {
if (!row || typeof row !== "object") return false;
const event = row as Record<string, unknown>;
return (
typeof event.ts === "number" &&
typeof event.target === "string" &&
typeof event.agentId === "string" &&
typeof event.outcome === "string" &&
typeof event.requestId === "string" &&
typeof event.detail === "string"
);
});
} catch {
return [];
}
}
export function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(
WAKE_HISTORY_KEY,
JSON.stringify(history.slice(0, 20)),
);
}
export function nextSort(
current: { key: SortKey; dir: SortDir },
key: SortKey,
): { key: SortKey; dir: SortDir } {
return {
key,
dir: current.key === key && current.dir === "asc" ? "desc" : "asc",
};
}
+108
View File
@@ -0,0 +1,108 @@
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
export function PresenceBadge({ presence }: { presence: string }) {
return (
<Badge variant="outline" className={`presence-badge--${presence}`}>
<span className={`presence-dot presence-dot--${presence}`} aria-hidden />
{presence.replace("_", " ")}
</Badge>
);
}
export function MobileLabel({ label }: { label: string }) {
return (
<span className="mb-1 block text-xs font-medium text-muted-foreground xl:hidden">
{label}
</span>
);
}
export function FilterSelect({
label,
value,
values,
onChange,
}: {
label: string;
value: string;
values: readonly string[];
onChange: (value: string) => void;
}) {
return (
<label className="grid gap-1 text-sm text-muted-foreground">
<span>{label}</span>
<Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger>
<span>{value.replace("_", " ")}</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{values.map((item) => (
<SelectItem key={item} value={item}>
{item.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
export function DetailBlock({
label,
values,
onCopy,
}: {
label: string;
values: string[];
onCopy: (label: string, value: string) => void;
}) {
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
{label}
</div>
<div className="grid gap-1">
{values.length ? (
values.map((value) => (
<button
key={value}
type="button"
className="flex min-w-0 items-center justify-between gap-2 rounded px-1 py-0.5 text-left hover:bg-accent"
onClick={() => onCopy(label, value)}
>
<span className="min-w-0 truncate">{value}</span>
<CopyIcon />
</button>
))
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</div>
</div>
);
}
function CopyIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 shrink-0 text-muted-foreground"
aria-hidden
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
}
@@ -0,0 +1,269 @@
import { useEffect, useState } from "react";
import { Zap } from "lucide-react";
import {
attachDeviceIdentifier,
createKnownDevice,
mergeKnownDevice,
type FleetDevice,
type KnownDevice,
} from "@/api";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
type Props = {
device: FleetDevice | null;
knownDevices: KnownDevice[];
open: boolean;
busy: boolean;
onOpenChange: (open: boolean) => void;
onWake: (routeId?: string | null) => void;
onCopy: (label: string, value: string) => void;
onChanged: () => void;
};
export function FleetDeviceDetailsDialog({
device,
knownDevices,
open,
busy,
onOpenChange,
onWake,
onCopy,
onChanged,
}: Props) {
const [displayName, setDisplayName] = useState("");
const [targetDeviceId, setTargetDeviceId] = useState("");
const [routeId, setRouteId] = useState("");
const [error, setError] = useState("");
useEffect(() => {
setDisplayName(device?.display_name ?? "");
setTargetDeviceId("");
setRouteId(device?.recommended_route?.route_id ?? "");
setError("");
}, [device]);
if (!device) return null;
const currentDevice = device;
const identifiers = identifiersFor(currentDevice);
const otherKnownDevices = knownDevices.filter(
(known) => known.device_id !== currentDevice.known_device?.device_id,
);
async function createRemembered() {
setError("");
try {
await createKnownDevice({
display_name: displayName.trim() || currentDevice.display_name,
pinned: true,
identifiers,
});
onChanged();
} catch (err) {
setError(String(err));
}
}
async function attachToExisting() {
if (!targetDeviceId) return;
setError("");
try {
for (const identifier of identifiers) {
await attachDeviceIdentifier(targetDeviceId, identifier);
}
onChanged();
} catch (err) {
setError(String(err));
}
}
async function mergeIntoExisting() {
if (!targetDeviceId || !currentDevice.known_device) return;
setError("");
try {
await mergeKnownDevice(
targetDeviceId,
currentDevice.known_device.device_id,
);
onChanged();
onOpenChange(false);
} catch (err) {
setError(String(err));
}
}
const selectedRoute =
device.route_candidates.find((route) => route.route_id === routeId) ??
device.recommended_route;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
<DialogHeader>
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
<div className="min-w-0">
<DialogTitle className="truncate">
{device.display_name}
</DialogTitle>
<DialogDescription>
{device.known_device
? "Remembered fleet device"
: "Observed fleet device"}
</DialogDescription>
</div>
<PresenceBadge presence={device.presence} />
</div>
</DialogHeader>
{error && (
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
<div className="grid gap-3 md:grid-cols-2">
<DetailBlock label="IPs" values={device.ips} onCopy={onCopy} />
<DetailBlock label="MACs" values={device.macs} onCopy={onCopy} />
<DetailBlock
label="Hostnames"
values={device.hostnames}
onCopy={onCopy}
/>
<DetailBlock
label="Agents"
values={device.agents.map((agent) =>
agentLabel(agent.agent_id, agent.nickname),
)}
onCopy={onCopy}
/>
</div>
<Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Wake route</h3>
<Button
size="sm"
onClick={() => onWake(selectedRoute?.route_id ?? null)}
disabled={busy || !selectedRoute?.wakeable}
>
<Zap className="size-4" aria-hidden />
{busy ? "Waking" : "Wake"}
</Button>
</div>
<Select
value={routeId}
onValueChange={(value) => setRouteId(value ?? "")}
>
<SelectTrigger>
<span>
{selectedRoute
? routeLabel(selectedRoute)
: "No connected MAC-backed route"}
</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{device.route_candidates.map((route) => (
<SelectItem key={route.route_id} value={route.route_id}>
{routeLabel(route)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="grid gap-3">
<h3 className="text-sm font-medium">Remember / merge</h3>
{!device.known_device && (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
<Input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Device name"
/>
<Button
onClick={() => void createRemembered()}
disabled={!identifiers.length}
>
Remember device
</Button>
</div>
)}
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
<Select
value={targetDeviceId}
onValueChange={(value) => setTargetDeviceId(value ?? "")}
>
<SelectTrigger>
<span>
{targetDeviceId
? knownDevices.find(
(known) => known.device_id === targetDeviceId,
)?.display_name
: "Select known device"}
</span>
</SelectTrigger>
<SelectContent
className="max-w-[min(92vw,30rem)]"
alignItemWithTrigger={false}
>
{otherKnownDevices.map((known) => (
<SelectItem key={known.device_id} value={known.device_id}>
{known.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => void attachToExisting()}
disabled={!targetDeviceId || !identifiers.length}
>
Attach IDs
</Button>
<Button
variant="outline"
onClick={() => void mergeIntoExisting()}
disabled={!targetDeviceId || !device.known_device}
>
Merge
</Button>
</div>
<p className="text-xs text-muted-foreground">
Proposed identifiers:{" "}
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
"-"}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+68
View File
@@ -0,0 +1,68 @@
import type { FleetDevice, FleetWakeRoute } from "@/api";
export type SortKey =
| "name"
| "presence"
| "last_seen"
| "agents"
| "ip"
| "mac";
export type SortDir = "asc" | "desc";
export type KnownFilter = "all" | "known" | "unknown";
export const presenceFilters = [
"all",
"online",
"likely_online",
"unknown",
"offline",
] as const;
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
export function summarize(values: string[]): string {
if (!values.length) return "-";
if (values.length <= 2) return values.join(", ");
return `${values[0]}, ${values[1]} (+${values.length - 2})`;
}
export function formatSeen(value: number | null): string {
if (!value) return "-";
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
return new Date(value * 1000).toLocaleString();
}
export function presenceRank(presence: string): number {
if (presence === "online") return 4;
if (presence === "likely_online") return 3;
if (presence === "unknown") return 2;
if (presence === "offline") return 1;
return 0;
}
export function identifiersFor(
device: FleetDevice,
): { kind: string; value: string }[] {
return [
...device.macs.map((value) => ({ kind: "mac", value })),
...device.ips.map((value) => ({ kind: "ip", value })),
];
}
export function agentLabel(agentId: string, nickname?: string | null): string {
const trimmed = nickname?.trim();
return trimmed ? trimmed : agentId;
}
export function routeLabel(route: FleetWakeRoute): string {
const status = route.connected ? "connected" : "offline";
const target = route.mac
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
: (route.ip ?? "-");
return `${agentLabel(route.agent_id, route.nickname)} · ${target} · ${route.source} · ${status}`;
}
@@ -69,7 +69,7 @@ export function ObservationHistoryDialog({
)} )}
<div className="max-h-[60vh] overflow-auto rounded-md border"> <div className="max-h-[60vh] overflow-auto rounded-md border">
<div className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground"> <div className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Time</span> <span>Time</span>
<span>State</span> <span>State</span>
<span>Source</span> <span>Source</span>
@@ -80,7 +80,7 @@ export function ObservationHistoryDialog({
{events.map((event) => ( {events.map((event) => (
<div <div
key={event.event_id} key={event.event_id}
className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0" className="grid min-w-176 grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
> >
<span className="truncate text-muted-foreground"> <span className="truncate text-muted-foreground">
{formatSeen(event.ts_unix)} {formatSeen(event.ts_unix)}
@@ -35,7 +35,7 @@ export function ObservationTable({
}: Props) { }: Props) {
return ( return (
<div className="grid gap-2 overflow-x-auto"> <div className="grid gap-2 overflow-x-auto">
<div className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm"> <div className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
<span>Device</span> <span>Device</span>
<span>Network</span> <span>Network</span>
<span>Agent</span> <span>Agent</span>
@@ -49,7 +49,7 @@ export function ObservationTable({
return ( return (
<div <div
key={observation.observation_key} key={observation.observation_key}
className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm" className="grid min-w-304 grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium"> <div className="truncate font-medium">
+272 -66
View File
@@ -8,36 +8,42 @@
:root { :root {
--card: oklch(1 0 0); --card: oklch(1 0 0);
--muted: oklch(0.96 0.004 145); --muted: oklch(0.96 0.004 145);
--background: oklch(1 0 0); --background: oklch(0.985 0.002 200);
--foreground: oklch(0.23 0.02 155); --foreground: oklch(0.18 0.02 240);
--card-foreground: oklch(0.23 0.02 155); --card-foreground: oklch(0.18 0.02 240);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 155); --popover-foreground: oklch(0.18 0.02 240);
--primary: oklch(0.56 0.12 156); --primary: oklch(0.55 0.14 155);
--primary-foreground: oklch(0.985 0.003 95); --primary-foreground: oklch(0.985 0.005 155);
--secondary: oklch(0.965 0.01 155); --secondary: oklch(0.96 0.01 155);
--secondary-foreground: oklch(0.3 0.035 156); --secondary-foreground: oklch(0.3 0.035 156);
--muted-foreground: oklch(0.5 0.02 155); --muted-foreground: oklch(0.48 0.02 240);
--accent: oklch(0.945 0.015 156); --accent: oklch(0.945 0.018 155);
--accent-foreground: oklch(0.3 0.035 156); --accent-foreground: oklch(0.3 0.035 156);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.9 0.01 155); --border: oklch(0.91 0.008 240);
--input: oklch(0.9 0.01 155); --input: oklch(0.91 0.008 240);
--ring: oklch(0.62 0.08 156); --ring: oklch(0.55 0.14 155);
--chart-1: oklch(0.62 0.12 156); --chart-1: oklch(0.55 0.14 155);
--chart-2: oklch(0.56 0.11 188); --chart-2: oklch(0.56 0.11 188);
--chart-3: oklch(0.67 0.12 120); --chart-3: oklch(0.67 0.12 120);
--chart-4: oklch(0.72 0.15 85); --chart-4: oklch(0.72 0.15 85);
--chart-5: oklch(0.58 0.18 35); --chart-5: oklch(0.58 0.18 35);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.975 0.005 155);
--sidebar-foreground: oklch(0.23 0.02 155); --sidebar-foreground: oklch(0.18 0.02 240);
--sidebar-primary: oklch(0.56 0.12 156); --sidebar-primary: oklch(0.55 0.14 155);
--sidebar-primary-foreground: oklch(0.985 0.003 95); --sidebar-primary-foreground: oklch(0.985 0.005 155);
--sidebar-accent: oklch(0.945 0.015 156); --sidebar-accent: oklch(0.94 0.02 155);
--sidebar-accent-foreground: oklch(0.3 0.035 156); --sidebar-accent-foreground: oklch(0.25 0.03 155);
--sidebar-border: oklch(0.9 0.01 155); --sidebar-border: oklch(0.92 0.008 240);
--sidebar-ring: oklch(0.62 0.08 156); --sidebar-ring: oklch(0.55 0.14 155);
/* Semantic presence colors */
--presence-online: oklch(0.62 0.17 145);
--presence-likely: oklch(0.75 0.15 75);
--presence-unknown: oklch(0.65 0.02 240);
--presence-offline: oklch(0.62 0.2 25);
} }
@theme inline { @theme inline {
@@ -84,67 +90,267 @@
} }
.dark { .dark {
--muted: oklch(0.25 0.01 156); --muted: oklch(0.22 0.015 240);
--background: oklch(0.19 0.02 156); --background: oklch(0.14 0.02 250);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.93 0.005 240);
--card: oklch(0.24 0.015 156); --card: oklch(0.18 0.018 245);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.93 0.005 240);
--popover: oklch(0.24 0.015 156); --popover: oklch(0.18 0.018 245);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.93 0.005 240);
--primary: oklch(0.68 0.11 156); --primary: oklch(0.7 0.14 155);
--primary-foreground: oklch(0.17 0.02 156); --primary-foreground: oklch(0.14 0.02 155);
--secondary: oklch(0.28 0.02 156); --secondary: oklch(0.24 0.02 240);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.93 0.005 240);
--muted-foreground: oklch(0.75 0.02 156); --muted-foreground: oklch(0.65 0.015 240);
--accent: oklch(0.3 0.02 156); --accent: oklch(0.24 0.025 245);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.93 0.005 240);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 10%);
--ring: oklch(0.65 0.08 156); --ring: oklch(0.7 0.14 155);
--chart-1: oklch(0.68 0.11 156); --chart-1: oklch(0.7 0.14 155);
--chart-2: oklch(0.63 0.11 188); --chart-2: oklch(0.63 0.11 188);
--chart-3: oklch(0.73 0.12 120); --chart-3: oklch(0.73 0.12 120);
--chart-4: oklch(0.78 0.14 85); --chart-4: oklch(0.78 0.14 85);
--chart-5: oklch(0.67 0.16 35); --chart-5: oklch(0.67 0.16 35);
--sidebar: oklch(0.24 0.015 156); --sidebar: oklch(0.16 0.02 248);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.93 0.005 240);
--sidebar-primary: oklch(0.68 0.11 156); --sidebar-primary: oklch(0.7 0.14 155);
--sidebar-primary-foreground: oklch(0.17 0.02 156); --sidebar-primary-foreground: oklch(0.14 0.02 155);
--sidebar-accent: oklch(0.3 0.02 156); --sidebar-accent: oklch(0.22 0.025 245);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.93 0.005 240);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.65 0.08 156); --sidebar-ring: oklch(0.7 0.14 155);
--presence-online: oklch(0.72 0.18 145);
--presence-likely: oklch(0.8 0.14 75);
--presence-unknown: oklch(0.6 0.015 240);
--presence-offline: oklch(0.7 0.2 25);
} }
/* ── Base ─────────────────────────────────────────────────────────────── */
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
background-color: var(--background);
background-image:
radial-gradient(
1200px 700px at 15% -10%,
color-mix(in oklab, var(--primary) 15%, var(--background)) 0%,
transparent 60%
),
radial-gradient(
900px 600px at 85% 0%,
color-mix(in oklab, var(--accent) 12%, var(--background)) 0%,
transparent 58%
),
linear-gradient(
180deg,
color-mix(in oklab, var(--background) 85%, var(--foreground) 15%) 0%,
var(--background) 45%
);
background-repeat: no-repeat;
background-size: 100% 100%;
margin: 0; margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }
/* ── Sidebar shell ────────────────────────────────────────────────────── */
.app-shell {
display: flex;
min-height: 100dvh;
}
.sidebar {
display: flex;
flex-direction: column;
width: 15rem;
height: 100dvh;
padding: 0.75rem;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-border);
overflow: hidden;
flex-shrink: 0;
}
.sidebar--collapsed {
width: 3.5rem;
padding: 0.75rem 0.45rem;
}
.app-main {
flex: 1;
min-width: 0;
padding: 1.25rem 1.5rem;
background:
radial-gradient(
1200px 700px at 15% -10%,
color-mix(in oklab, var(--primary) 8%, var(--background)) 0%,
transparent 60%
),
radial-gradient(
900px 600px at 85% 0%,
color-mix(in oklab, var(--accent) 6%, var(--background)) 0%,
transparent 58%
),
var(--background);
background-repeat: no-repeat;
background-size: 100% 100%;
overflow-y: auto;
max-width: 100%;
}
/* ── Sidebar internals ────────────────────────────────────────────────── */
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.3rem 0.35rem 0.75rem;
min-height: 2.5rem;
}
.sidebar-logo {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
}
.sidebar-brand-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-section {
margin-bottom: 0.35rem;
}
.sidebar-section-label {
padding: 0.5rem 0.55rem 0.25rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
user-select: none;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.55rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 450;
color: var(--sidebar-foreground);
text-decoration: none;
transition:
background-color 120ms ease,
color 120ms ease;
white-space: nowrap;
}
.sidebar-link:hover {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
.sidebar-link--active {
background: color-mix(in oklab, var(--sidebar-primary) 14%, transparent);
color: var(--sidebar-primary);
font-weight: 550;
}
.sidebar-link--collapsed {
justify-content: center;
padding: 0.5rem;
}
.sidebar-link-icon {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
opacity: 0.8;
}
.sidebar-link--active .sidebar-link-icon {
opacity: 1;
}
.sidebar-link-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-footer {
border-top: 1px solid var(--sidebar-border);
padding-top: 0.5rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.sidebar-footer-btn {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
justify-content: flex-start;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
font-weight: 450;
color: var(--sidebar-foreground);
}
.sidebar--collapsed .sidebar-footer-btn {
justify-content: center;
padding: 0.5rem;
}
/* ── Presence badges ──────────────────────────────────────────────────── */
.presence-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.presence-dot--online {
background: var(--presence-online);
}
.presence-dot--likely_online {
background: var(--presence-likely);
}
.presence-dot--unknown {
background: var(--presence-unknown);
}
.presence-dot--offline {
background: var(--presence-offline);
}
.presence-badge--online {
border-color: color-mix(in oklab, var(--presence-online) 40%, transparent);
color: var(--presence-online);
}
.presence-badge--likely_online {
border-color: color-mix(in oklab, var(--presence-likely) 40%, transparent);
color: var(--presence-likely);
}
.presence-badge--unknown {
border-color: var(--border);
color: var(--muted-foreground);
}
.presence-badge--offline {
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
color: var(--presence-offline);
}
+6 -10
View File
@@ -6,15 +6,15 @@ import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
function sourcemapEnabled(): boolean { const debugEnabled = (): boolean => {
const explicit = process.env.UI_SOURCEMAP?.toLowerCase(); const explicit = process.env.DEBUG?.toLowerCase();
if (explicit) { if (explicit) {
return explicit === "1" || explicit === "true" || explicit === "yes"; return explicit === "1" || explicit === "true" || explicit === "yes";
} }
const rustLog = (process.env.RUST_LOG || "").toLowerCase(); const rustLog = (process.env.RUST_LOG || "").toLowerCase();
return rustLog.includes("debug") || rustLog.includes("trace"); return rustLog.includes("debug") || rustLog.includes("trace");
} };
const ReactCompilerConfig = { const ReactCompilerConfig = {
target: "18", target: "18",
@@ -23,15 +23,11 @@ const ReactCompilerConfig = {
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react( react({
process.env.UI_REACT_COMPILER === "1"
? {
babel: { babel: {
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]], plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
}, },
} }),
: undefined,
),
tailwindcss(), tailwindcss(),
], ],
base: "/ui/", base: "/ui/",
@@ -41,6 +37,6 @@ export default defineConfig({
}, },
}, },
build: { build: {
sourcemap: sourcemapEnabled(), sourcemap: debugEnabled(),
}, },
}); });
+5 -5
View File
@@ -42,7 +42,7 @@ pub enum Command {
#[derive(Args)] #[derive(Args)]
pub struct ServeArgs { pub struct ServeArgs {
/// Path to the agent config file. /// Path to the agent config file.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)] #[arg(long, short = 'c', default_value = config::DEFAULT_CONFIG_PATH)]
pub config: PathBuf, pub config: PathBuf,
/// Path to pid file for reload signaling. /// Path to pid file for reload signaling.
@@ -80,11 +80,11 @@ pub struct EnrollArgs {
#[derive(Args)] #[derive(Args)]
pub struct InitConfigArgs { pub struct InitConfigArgs {
/// Path to the agent config file to write. If omitted, config is printed to stdout. /// Path to the agent config file to write. If omitted, config is printed to stdout.
#[arg(long)] #[arg(long, short, visible_alias = "to", visible_short_alias = 't')]
pub config: Option<PathBuf>, pub config: Option<PathBuf>,
/// Existing agent config to use as a base before applying explicit overrides. /// Existing agent config to use as a base before applying explicit overrides.
#[arg(long)] #[arg(long, short = 'f', visible_alias = "from")]
pub from_config: Option<PathBuf>, pub from_config: Option<PathBuf>,
/// Print config to stdout. /// Print config to stdout.
@@ -118,14 +118,14 @@ pub struct ReloadArgs {
#[derive(Args)] #[derive(Args)]
pub struct SyncObservationsArgs { pub struct SyncObservationsArgs {
/// Path to the agent config file. /// Path to the agent config file.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)] #[arg(long, short, default_value = config::DEFAULT_CONFIG_PATH)]
pub config: PathBuf, pub config: PathBuf,
} }
#[derive(Args)] #[derive(Args)]
pub struct ObserveArgs { pub struct ObserveArgs {
/// Path to the agent config file. If present, local path settings are passed through. /// Path to the agent config file. If present, local path settings are passed through.
#[arg(long, global = true, default_value = config::DEFAULT_CONFIG_PATH)] #[arg(long, short, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
pub config: PathBuf, pub config: PathBuf,
#[command(subcommand)] #[command(subcommand)]
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
"describe": {
"columns": [
{
"name": "event_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "observation_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "action!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 6
},
"nullable": [
true,
true,
false,
false,
false,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "72aadefca9851a8becdd2dcce54b646aff095b64cef9837f5e8e4ea8dde71d02"
}
@@ -1,86 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
"describe": {
"columns": [
{
"name": "event_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "observation_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "action!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 6
},
"nullable": [
true,
true,
false,
false,
false,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
}
+1 -1
View File
@@ -6,7 +6,7 @@ mod stats;
pub use devices::{ pub use devices::{
attach_device_identifier, attach_observation_identifier, create_known_device, attach_device_identifier, attach_observation_identifier, create_known_device,
forget_known_device, list_known_devices, merge_known_device, detach_device_identifier, forget_known_device, list_known_devices, merge_known_device,
}; };
pub use enroll::{ pub use enroll::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
@@ -198,6 +198,32 @@ pub async fn attach_observation_identifier(
} }
} }
pub async fn detach_device_identifier(
State(state): State<AppState>,
AxumPath((device_id, identifier_key)): AxumPath<(String, String)>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state
.store
.detach_device_identifier(&device_id, &identifier_key)
.await
{
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
Ok(None) => Err(json_error(
StatusCode::NOT_FOUND,
"known_device_not_found",
"known device not found",
)),
Err(err) => {
warn!(error = %err, "failed to detach device identifier");
Err(json_error(
StatusCode::BAD_REQUEST,
"detach_device_identifier_failed",
&err.to_string(),
))
}
}
}
pub async fn merge_known_device( pub async fn merge_known_device(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>, AxumPath(device_id): AxumPath<String>,
@@ -1,953 +0,0 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::net::IpAddr;
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::warn;
use wakey_agent::protocol::{AgentCommand, InventoryRequest, WakeRequest};
use crate::api::commands::{RelayCommandResponse, relay_agent_command};
use crate::api::json_error;
use crate::runtime::AppState;
use crate::state::{
AgentDeviceObservation, AgentDeviceObservationInput, DeviceIdentifier, KnownDevice,
KnownDeviceSummary,
};
#[derive(Debug, Deserialize)]
pub struct ListFleetDevicesQuery {
pub query: Option<String>,
pub presence: Option<String>,
pub known: Option<String>,
pub agent_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct RefreshFleetDevicesRequest {
#[serde(default)]
pub agent_ids: Vec<String>,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshFleetDevicesResponse {
pub total_accepted: usize,
pub agents: Vec<RefreshFleetAgentResult>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshFleetAgentResult {
pub agent_id: String,
pub status: String,
pub accepted: usize,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WakeFleetDeviceRequest {
pub device_key: String,
pub route_id: Option<String>,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct WakeFleetDeviceResponse {
pub route: FleetWakeRoute,
pub command: RelayCommandResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetDevice {
pub device_key: String,
pub display_name: String,
pub known_device: Option<KnownDeviceSummary>,
pub pinned: bool,
pub ips: Vec<String>,
pub macs: Vec<String>,
pub hostnames: Vec<String>,
pub agents: Vec<FleetDeviceAgent>,
pub sources: Vec<String>,
pub first_seen_unix: Option<u64>,
pub last_seen_unix: Option<u64>,
pub presence: String,
pub route_candidates: Vec<FleetWakeRoute>,
pub recommended_route: Option<FleetWakeRoute>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetDeviceAgent {
pub agent_id: String,
pub nickname: Option<String>,
pub connected: bool,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetWakeRoute {
pub route_id: String,
pub agent_id: String,
pub nickname: Option<String>,
pub connected: bool,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub source: String,
pub last_seen_unix: u64,
pub wakeable: bool,
}
#[derive(Debug, Default)]
struct FleetBuildContext {
agent_status: HashMap<String, AgentRuntimeStatus>,
identifier_map: HashMap<String, KnownDeviceSummary>,
}
#[derive(Debug, Clone)]
struct AgentRuntimeStatus {
nickname: Option<String>,
connected: bool,
}
#[derive(Debug, Default)]
struct FleetAccumulator {
device_key: String,
display_name: Option<String>,
known_device: Option<KnownDeviceSummary>,
pinned: bool,
ips: BTreeSet<String>,
macs: BTreeSet<String>,
hostnames: BTreeSet<String>,
sources: BTreeSet<String>,
agents: BTreeMap<String, FleetDeviceAgent>,
first_seen_unix: Option<u64>,
last_seen_unix: Option<u64>,
presence_rank: u8,
routes: BTreeMap<String, FleetWakeRoute>,
}
#[derive(Debug, Deserialize)]
struct InventoryEnvelope {
kind: String,
devices: Vec<InventoryDevice>,
}
#[derive(Debug, Deserialize)]
struct InventoryDevice {
#[serde(default)]
names: Vec<String>,
#[serde(default)]
ips: Vec<IpAddr>,
#[serde(default)]
macs: Vec<String>,
#[serde(default)]
presence: String,
}
pub async fn list_fleet_devices(
State(state): State<AppState>,
Query(query): Query<ListFleetDevicesQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match load_fleet_devices(&state, &query).await {
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
Err(err) => {
warn!(error = %err, "failed to list fleet devices");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_fleet_devices_failed",
&err.to_string(),
))
}
}
}
pub async fn refresh_fleet_devices(
State(state): State<AppState>,
Json(req): Json<RefreshFleetDevicesRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let mut agent_ids = if req.agent_ids.is_empty() {
let sessions = state.sessions.read().await;
sessions.keys().cloned().collect::<Vec<_>>()
} else {
req.agent_ids.clone()
};
agent_ids.sort();
agent_ids.dedup();
let mut results = Vec::with_capacity(agent_ids.len());
let mut total_accepted = 0usize;
for agent_id in agent_ids {
let response = relay_agent_command(
&state,
&agent_id,
AgentCommand::Inventory(InventoryRequest {
query: None,
name: None,
ips: Vec::new(),
devs: Vec::new(),
nuds: Vec::new(),
macs: Vec::new(),
}),
req.timeout_ms,
)
.await;
match response {
Ok(response) if response.status == "ok" => {
let Some(result) = response.result else {
results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some("inventory command returned no result".into()),
});
continue;
};
match inventory_result_to_observations(result) {
Ok(observations) => match state
.store
.upsert_agent_observations(&agent_id, observations)
.await
{
Ok(accepted) => {
total_accepted = total_accepted.saturating_add(accepted);
results.push(RefreshFleetAgentResult {
agent_id,
status: "ok".into(),
accepted,
error: None,
});
}
Err(err) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some(err.to_string()),
}),
},
Err(err) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some(err.to_string()),
}),
}
}
Ok(response) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: response
.error
.map(|error| error.message)
.or_else(|| Some("inventory command failed".into())),
}),
Err((status, body)) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some(format!("{status}: {}", body.0)),
}),
}
}
Ok((
StatusCode::OK,
Json(RefreshFleetDevicesResponse {
total_accepted,
agents: results,
}),
))
}
pub async fn wake_fleet_device(
State(state): State<AppState>,
Json(req): Json<WakeFleetDeviceRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = ListFleetDevicesQuery {
query: None,
presence: None,
known: None,
agent_id: None,
limit: Some(1000),
};
let devices = load_fleet_devices(&state, &query).await.map_err(|err| {
warn!(error = %err, "failed loading fleet devices for wake");
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"load_fleet_devices_failed",
&err.to_string(),
)
})?;
let device = devices
.into_iter()
.find(|device| device.device_key == req.device_key)
.ok_or_else(|| {
json_error(
StatusCode::NOT_FOUND,
"fleet_device_not_found",
"fleet device not found",
)
})?;
let route = match req.route_id.as_deref() {
Some(route_id) => device
.route_candidates
.into_iter()
.find(|route| route.route_id == route_id),
None => device.recommended_route,
}
.ok_or_else(|| {
json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"no wakeable connected MAC-backed route is available",
)
})?;
let Some(mac) = route.mac.as_deref() else {
return Err(json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"selected route does not include a MAC address",
));
};
if !route.connected {
return Err(json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"selected route agent is not connected",
));
}
let mac = mac.parse().map_err(|err| {
json_error(
StatusCode::BAD_REQUEST,
"invalid_wake_route",
&format!("invalid route MAC: {err}"),
)
})?;
let ip = route
.ip
.as_deref()
.map(str::parse)
.transpose()
.map_err(|err| {
json_error(
StatusCode::BAD_REQUEST,
"invalid_wake_route",
&format!("invalid route IP: {err}"),
)
})?;
let command = relay_agent_command(
&state,
&route.agent_id,
AgentCommand::Wake(WakeRequest {
query: None,
mac: Some(mac),
ip,
}),
req.timeout_ms,
)
.await?;
Ok((
StatusCode::OK,
Json(WakeFleetDeviceResponse { route, command }),
))
}
async fn load_fleet_devices(
state: &AppState,
query: &ListFleetDevicesQuery,
) -> anyhow::Result<Vec<FleetDevice>> {
let known_devices = state.store.list_known_devices().await?;
let observations = state
.store
.list_agent_observations(None, query.limit.unwrap_or(1000).max(1))
.await?;
let connected = {
let sessions = state.sessions.read().await;
sessions.keys().cloned().collect::<BTreeSet<_>>()
};
let agents = state.store.list_agents_with_nicknames().await;
let agent_status = agents
.into_iter()
.map(|(agent_id, nickname)| {
let connected = connected.contains(&agent_id);
(
agent_id,
AgentRuntimeStatus {
nickname,
connected,
},
)
})
.collect::<HashMap<_, _>>();
let mut identifier_map = HashMap::new();
for device in &known_devices {
let summary = known_device_summary(device);
for identifier in &device.identifiers {
identifier_map.insert(identifier.identifier_key.clone(), summary.clone());
}
}
let context = FleetBuildContext {
agent_status,
identifier_map,
};
let mut devices = build_fleet_devices(known_devices, observations, &context);
filter_fleet_devices(&mut devices, query);
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
devices.truncate(limit);
Ok(devices)
}
fn build_fleet_devices(
known_devices: Vec<KnownDevice>,
observations: Vec<AgentDeviceObservation>,
context: &FleetBuildContext,
) -> Vec<FleetDevice> {
let mut by_key = BTreeMap::<String, FleetAccumulator>::new();
for device in known_devices {
let key = format!("known:{}", device.device_id);
let entry = by_key
.entry(key.clone())
.or_insert_with(|| FleetAccumulator {
device_key: key,
display_name: Some(device.display_name.clone()),
known_device: Some(known_device_summary(&device)),
pinned: device.pinned,
presence_rank: 1,
..Default::default()
});
for identifier in device.identifiers {
add_identifier_to_entry(entry, &identifier);
}
}
for observation in observations {
let key = observation_group_key(&observation, context);
let entry = by_key
.entry(key.clone())
.or_insert_with(|| FleetAccumulator {
device_key: key,
..Default::default()
});
add_observation_to_entry(entry, observation, context);
}
let mut devices = by_key
.into_values()
.map(FleetAccumulator::into_response)
.collect::<Vec<_>>();
devices.sort_by(|a, b| {
b.pinned
.cmp(&a.pinned)
.then_with(|| b.known_device.is_some().cmp(&a.known_device.is_some()))
.then_with(|| presence_rank(&b.presence).cmp(&presence_rank(&a.presence)))
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
.then_with(|| a.display_name.cmp(&b.display_name))
});
devices
}
fn filter_fleet_devices(devices: &mut Vec<FleetDevice>, query: &ListFleetDevicesQuery) {
let search = normalize_filter(query.query.as_deref());
let presence = normalize_filter(query.presence.as_deref());
let known = normalize_filter(query.known.as_deref());
let agent_id = normalize_filter(query.agent_id.as_deref());
devices.retain(|device| {
if let Some(presence) = presence.as_deref()
&& presence != "all"
&& device.presence != presence
{
return false;
}
if let Some(known) = known.as_deref() {
match known {
"known" if device.known_device.is_none() => return false,
"unknown" if device.known_device.is_some() => return false,
_ => {}
}
}
if let Some(agent_id) = agent_id.as_deref()
&& !device
.agents
.iter()
.any(|agent| agent.agent_id.to_ascii_lowercase().contains(agent_id))
{
return false;
}
if let Some(search) = search.as_deref() {
let mut haystack = vec![
device.device_key.as_str(),
device.display_name.as_str(),
device.presence.as_str(),
];
haystack.extend(device.ips.iter().map(String::as_str));
haystack.extend(device.macs.iter().map(String::as_str));
haystack.extend(device.hostnames.iter().map(String::as_str));
haystack.extend(device.sources.iter().map(String::as_str));
haystack.extend(device.agents.iter().map(|agent| agent.agent_id.as_str()));
if !haystack
.into_iter()
.any(|value| value.to_ascii_lowercase().contains(search))
{
return false;
}
}
true
});
}
fn observation_group_key(
observation: &AgentDeviceObservation,
context: &FleetBuildContext,
) -> String {
if let Some(summary) = observation_known_device(observation, context) {
return format!("known:{}", summary.device_id);
}
if let Some(mac) = observation.mac.as_deref() {
return format!("mac:{mac}");
}
if let Some(ip) = observation.ip.as_deref() {
return format!("ip:{ip}");
}
observation.observation_key.clone()
}
fn add_observation_to_entry(
entry: &mut FleetAccumulator,
observation: AgentDeviceObservation,
context: &FleetBuildContext,
) {
if let Some(summary) = observation_known_device(&observation, context)
&& entry.known_device.is_none()
{
entry.display_name = Some(summary.display_name.clone());
entry.pinned = summary.pinned;
entry.known_device = Some(summary);
}
if let Some(mac) = observation.mac.as_deref() {
entry.macs.insert(mac.to_string());
}
if let Some(ip) = observation.ip.as_deref() {
entry.ips.insert(ip.to_string());
}
if let Some(hostname) = observation.hostname.as_deref() {
if entry.display_name.is_none() {
entry.display_name = Some(hostname.to_string());
}
entry.hostnames.insert(hostname.to_string());
}
entry.sources.insert(observation.kind.clone());
entry.first_seen_unix = Some(
entry
.first_seen_unix
.map(|current| current.min(observation.first_seen_unix))
.unwrap_or(observation.first_seen_unix),
);
entry.last_seen_unix = Some(
entry
.last_seen_unix
.map(|current| current.max(observation.last_seen_unix))
.unwrap_or(observation.last_seen_unix),
);
entry.presence_rank = entry
.presence_rank
.max(observation_presence_rank(&observation));
let status = context
.agent_status
.get(&observation.agent_id)
.cloned()
.unwrap_or(AgentRuntimeStatus {
nickname: None,
connected: false,
});
entry
.agents
.entry(observation.agent_id.clone())
.and_modify(|agent| {
agent.last_seen_unix = agent.last_seen_unix.max(observation.last_seen_unix);
agent.connected = status.connected;
agent.nickname = status.nickname.clone();
})
.or_insert(FleetDeviceAgent {
agent_id: observation.agent_id.clone(),
nickname: status.nickname.clone(),
connected: status.connected,
last_seen_unix: observation.last_seen_unix,
});
let route_id = route_id(
&observation.agent_id,
observation.mac.as_deref(),
observation.ip.as_deref(),
&observation.kind,
);
let wakeable = status.connected && observation.mac.is_some();
entry.routes.insert(
route_id.clone(),
FleetWakeRoute {
route_id: route_id.clone(),
agent_id: observation.agent_id,
nickname: status.nickname,
connected: status.connected,
mac: observation.mac,
ip: observation.ip,
hostname: observation.hostname,
source: observation.kind,
last_seen_unix: observation.last_seen_unix,
wakeable,
},
);
if let Some(route) = entry.routes.get_mut(&route_id) {
route.wakeable = route.connected && route.mac.is_some();
}
}
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
match identifier.kind.as_str() {
"mac" => {
entry.macs.insert(identifier.value.clone());
}
"ip" => {
entry.ips.insert(identifier.value.clone());
}
_ => {}
}
}
fn observation_known_device(
observation: &AgentDeviceObservation,
context: &FleetBuildContext,
) -> Option<KnownDeviceSummary> {
observation
.mac
.as_deref()
.and_then(|mac| context.identifier_map.get(&format!("mac:{mac}")).cloned())
.or_else(|| {
observation
.ip
.as_deref()
.and_then(|ip| context.identifier_map.get(&format!("ip:{ip}")).cloned())
})
}
fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
KnownDeviceSummary {
device_id: device.device_id.clone(),
display_name: device.display_name.clone(),
pinned: device.pinned,
}
}
impl FleetAccumulator {
fn into_response(self) -> FleetDevice {
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
route_candidates.sort_by(|a, b| {
b.connected
.cmp(&a.connected)
.then_with(|| b.wakeable.cmp(&a.wakeable))
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
let recommended_route = route_candidates
.iter()
.find(|route| route.wakeable)
.cloned();
let display_name = self
.display_name
.or_else(|| self.hostnames.iter().next().cloned())
.or_else(|| self.macs.iter().next().cloned())
.or_else(|| self.ips.iter().next().cloned())
.unwrap_or_else(|| "(unknown device)".to_string());
FleetDevice {
device_key: self.device_key,
display_name,
known_device: self.known_device,
pinned: self.pinned,
ips: self.ips.into_iter().collect(),
macs: self.macs.into_iter().collect(),
hostnames: self.hostnames.into_iter().collect(),
agents: self.agents.into_values().collect(),
sources: self.sources.into_iter().collect(),
first_seen_unix: self.first_seen_unix,
last_seen_unix: self.last_seen_unix,
presence: rank_presence(self.presence_rank).to_string(),
route_candidates,
recommended_route,
}
}
}
fn inventory_result_to_observations(
result: serde_json::Value,
) -> anyhow::Result<Vec<AgentDeviceObservationInput>> {
let envelope: InventoryEnvelope = serde_json::from_value(result)?;
if envelope.kind != "inventory" {
anyhow::bail!("expected inventory result, got {}", envelope.kind);
}
let now = now_unix();
let mut out = Vec::new();
for device in envelope.devices {
let hostname = device
.names
.iter()
.find(|name| !name.trim().is_empty())
.cloned();
let action = if device.presence == "offline" {
"remove"
} else {
"update"
};
if !device.macs.is_empty() {
for mac in device.macs {
out.push(AgentDeviceObservationInput {
kind: "inventory".into(),
action: action.into(),
mac: Some(mac),
ip: device.ips.first().map(ToString::to_string),
hostname: hostname.clone(),
first_seen_unix: now,
last_seen_unix: now,
});
}
} else {
for ip in &device.ips {
out.push(AgentDeviceObservationInput {
kind: "inventory".into(),
action: action.into(),
mac: None,
ip: Some(ip.to_string()),
hostname: hostname.clone(),
first_seen_unix: now,
last_seen_unix: now,
});
}
}
}
Ok(out)
}
fn observation_presence_rank(observation: &AgentDeviceObservation) -> u8 {
match observation.last_action.as_str() {
"remove" => 0,
"add" | "old" | "update" => 2,
_ => 1,
}
}
fn rank_presence(rank: u8) -> &'static str {
match rank {
3 => "online",
2 => "likely_online",
0 => "offline",
_ => "unknown",
}
}
fn presence_rank(presence: &str) -> u8 {
match presence {
"online" => 3,
"likely_online" => 2,
"offline" => 0,
_ => 1,
}
}
fn route_id(agent_id: &str, mac: Option<&str>, ip: Option<&str>, source: &str) -> String {
format!(
"{}|{}|{}|{}",
agent_id,
source,
mac.unwrap_or(""),
ip.unwrap_or("")
)
}
fn normalize_filter(value: Option<&str>) -> Option<String> {
value
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn context(connected: &[&str]) -> FleetBuildContext {
FleetBuildContext {
agent_status: connected
.iter()
.map(|agent| {
(
(*agent).to_string(),
AgentRuntimeStatus {
nickname: None,
connected: true,
},
)
})
.collect(),
identifier_map: HashMap::new(),
}
}
fn observation(
agent_id: &str,
mac: Option<&str>,
ip: Option<&str>,
last_seen_unix: u64,
) -> AgentDeviceObservation {
AgentDeviceObservation {
observation_key: format!(
"agent:{agent_id}:dhcp:{}",
mac.map(|mac| format!("mac:{mac}"))
.or_else(|| ip.map(|ip| format!("ip:{ip}")))
.unwrap_or_default()
),
agent_id: agent_id.into(),
kind: "dhcp".into(),
mac: mac.map(str::to_string),
ip: ip.map(str::to_string),
hostname: Some("lda".into()),
first_seen_unix: 1,
last_seen_unix,
last_action: "update".into(),
}
}
#[test]
fn fleet_grouping_combines_same_mac_across_agents() {
let devices = build_fleet_devices(
Vec::new(),
vec![
observation(
"agent-a",
Some("aa:bb:cc:dd:ee:ff"),
Some("192.168.1.2"),
10,
),
observation(
"agent-b",
Some("aa:bb:cc:dd:ee:ff"),
Some("192.168.2.2"),
20,
),
],
&context(&["agent-a", "agent-b"]),
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].macs, vec!["aa:bb:cc:dd:ee:ff"]);
assert_eq!(devices[0].agents.len(), 2);
assert_eq!(
devices[0]
.recommended_route
.as_ref()
.map(|route| route.agent_id.as_str()),
Some("agent-b")
);
}
#[test]
fn known_device_with_two_macs_absorbs_both_observation_groups() {
let known = KnownDevice {
device_id: "dev-1".into(),
display_name: "lda".into(),
pinned: true,
created_at_unix: 1,
updated_at_unix: 1,
notes: None,
identifiers: vec![
DeviceIdentifier {
identifier_key: "mac:aa:bb:cc:dd:ee:01".into(),
device_id: "dev-1".into(),
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:01".into(),
created_at_unix: 1,
},
DeviceIdentifier {
identifier_key: "mac:aa:bb:cc:dd:ee:02".into(),
device_id: "dev-1".into(),
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:02".into(),
created_at_unix: 1,
},
],
};
let mut ctx = context(&["agent-a"]);
for identifier in &known.identifiers {
ctx.identifier_map.insert(
identifier.identifier_key.clone(),
known_device_summary(&known),
);
}
let devices = build_fleet_devices(
vec![known],
vec![
observation("agent-a", Some("aa:bb:cc:dd:ee:01"), None, 10),
observation("agent-a", Some("aa:bb:cc:dd:ee:02"), None, 20),
],
&ctx,
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].device_key, "known:dev-1");
assert_eq!(devices[0].macs.len(), 2);
}
#[test]
fn ip_only_unknown_is_visible_but_not_wakeable() {
let devices = build_fleet_devices(
Vec::new(),
vec![observation("agent-a", None, Some("192.168.1.2"), 10)],
&context(&["agent-a"]),
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].ips, vec!["192.168.1.2"]);
assert!(devices[0].recommended_route.is_none());
assert!(!devices[0].route_candidates[0].wakeable);
}
#[test]
fn inventory_result_maps_to_stored_observations() {
let observations = inventory_result_to_observations(serde_json::json!({
"kind": "inventory",
"devices": [{
"names": ["lda"],
"ips": ["192.168.1.2"],
"macs": ["aa:bb:cc:dd:ee:ff"],
"presence": "likely_online"
}]
}))
.expect("inventory should map");
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].kind, "inventory");
assert_eq!(observations[0].action, "update");
assert_eq!(observations[0].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
}
}
@@ -0,0 +1,379 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use crate::state::{AgentDeviceObservation, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
use super::types::{FleetDevice, FleetDeviceAgent, FleetWakeRoute, ListFleetDevicesQuery};
#[derive(Debug, Default)]
pub(crate) struct FleetBuildContext {
pub(crate) agent_status: HashMap<String, AgentRuntimeStatus>,
pub(crate) identifier_map: HashMap<String, KnownDeviceSummary>,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentRuntimeStatus {
pub(crate) nickname: Option<String>,
pub(crate) connected: bool,
}
#[derive(Debug, Default)]
struct FleetAccumulator {
device_key: String,
display_name: Option<String>,
known_device: Option<KnownDeviceSummary>,
pinned: bool,
ips: BTreeSet<String>,
macs: BTreeSet<String>,
hostnames: BTreeSet<String>,
sources: BTreeSet<String>,
agents: BTreeMap<String, FleetDeviceAgent>,
first_seen_unix: Option<u64>,
last_seen_unix: Option<u64>,
presence_rank: u8,
routes: BTreeMap<String, FleetWakeRoute>,
}
pub(crate) fn build_fleet_devices(
known_devices: Vec<KnownDevice>,
observations: Vec<AgentDeviceObservation>,
context: &FleetBuildContext,
) -> Vec<FleetDevice> {
let mut by_key = BTreeMap::<String, FleetAccumulator>::new();
for device in known_devices {
let key = format!("known:{}", device.device_id);
let entry = by_key
.entry(key.clone())
.or_insert_with(|| FleetAccumulator {
device_key: key,
display_name: Some(device.display_name.clone()),
known_device: Some(known_device_summary(&device)),
pinned: device.pinned,
presence_rank: 1,
..Default::default()
});
for identifier in device.identifiers {
add_identifier_to_entry(entry, &identifier);
}
}
for observation in observations {
let key = observation_group_key(&observation, context);
let entry = by_key
.entry(key.clone())
.or_insert_with(|| FleetAccumulator {
device_key: key,
..Default::default()
});
add_observation_to_entry(entry, observation, context);
}
let mut devices = by_key
.into_values()
.map(FleetAccumulator::into_response)
.collect::<Vec<_>>();
devices.sort_by(|a, b| {
b.pinned
.cmp(&a.pinned)
.then_with(|| b.known_device.is_some().cmp(&a.known_device.is_some()))
.then_with(|| presence_rank(&b.presence).cmp(&presence_rank(&a.presence)))
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
.then_with(|| a.display_name.cmp(&b.display_name))
});
devices
}
pub(crate) fn filter_fleet_devices(devices: &mut Vec<FleetDevice>, query: &ListFleetDevicesQuery) {
let search = normalize_filter(query.query.as_deref());
let presence = normalize_filter(query.presence.as_deref());
let known = normalize_filter(query.known.as_deref());
let agent_id = normalize_filter(query.agent_id.as_deref());
let visibility = normalize_filter(query.visibility.as_deref());
devices.retain(|device| {
if visibility.as_deref().unwrap_or("operator") != "all"
&& fleet_device_is_operator_noise(device)
{
return false;
}
if let Some(presence) = presence.as_deref()
&& presence != "all"
&& device.presence != presence
{
return false;
}
if let Some(known) = known.as_deref() {
match known {
"known" if device.known_device.is_none() => return false,
"unknown" if device.known_device.is_some() => return false,
_ => {}
}
}
if let Some(agent_id) = agent_id.as_deref()
&& !device
.agents
.iter()
.any(|agent| agent.agent_id.to_ascii_lowercase().contains(agent_id))
{
return false;
}
if let Some(search) = search.as_deref() {
let mut haystack = vec![
device.device_key.as_str(),
device.display_name.as_str(),
device.presence.as_str(),
];
haystack.extend(device.ips.iter().map(String::as_str));
haystack.extend(device.macs.iter().map(String::as_str));
haystack.extend(device.hostnames.iter().map(String::as_str));
haystack.extend(device.sources.iter().map(String::as_str));
haystack.extend(device.agents.iter().map(|agent| agent.agent_id.as_str()));
if !haystack
.into_iter()
.any(|value| value.to_ascii_lowercase().contains(search))
{
return false;
}
}
true
});
}
fn fleet_device_is_operator_noise(device: &FleetDevice) -> bool {
device.known_device.is_none()
&& device.macs.is_empty()
&& device.hostnames.is_empty()
&& device.recommended_route.is_none()
&& device.ips.is_empty()
&& device.presence == "offline"
}
fn observation_group_key(
observation: &AgentDeviceObservation,
context: &FleetBuildContext,
) -> String {
if let Some(summary) = observation_known_device(observation, context) {
return format!("known:{}", summary.device_id);
}
if let Some(mac) = observation.mac.as_deref() {
return format!("mac:{mac}");
}
if let Some(ip) = observation.ip.as_deref() {
return format!("ip:{ip}");
}
observation.observation_key.clone()
}
fn add_observation_to_entry(
entry: &mut FleetAccumulator,
observation: AgentDeviceObservation,
context: &FleetBuildContext,
) {
let observation_offline = observation_is_offline(&observation);
if let Some(summary) = observation_known_device(&observation, context)
&& entry.known_device.is_none()
{
entry.display_name = Some(summary.display_name.clone());
entry.pinned = summary.pinned;
entry.known_device = Some(summary);
}
if let Some(mac) = observation.mac.as_deref() {
entry.macs.insert(mac.to_string());
}
if !observation_offline && let Some(ip) = observation.ip.as_deref() {
entry.ips.insert(ip.to_string());
}
if let Some(hostname) = observation.hostname.as_deref() {
if entry.display_name.is_none() {
entry.display_name = Some(hostname.to_string());
}
entry.hostnames.insert(hostname.to_string());
}
entry.sources.insert(observation.kind.clone());
entry.first_seen_unix = Some(
entry
.first_seen_unix
.map(|current| current.min(observation.first_seen_unix))
.unwrap_or(observation.first_seen_unix),
);
entry.last_seen_unix = Some(
entry
.last_seen_unix
.map(|current| current.max(observation.last_seen_unix))
.unwrap_or(observation.last_seen_unix),
);
entry.presence_rank = entry
.presence_rank
.max(observation_presence_rank(&observation));
let status = context
.agent_status
.get(&observation.agent_id)
.cloned()
.unwrap_or(AgentRuntimeStatus {
nickname: None,
connected: false,
});
entry
.agents
.entry(observation.agent_id.clone())
.and_modify(|agent| {
agent.last_seen_unix = agent.last_seen_unix.max(observation.last_seen_unix);
agent.connected = status.connected;
agent.nickname = status.nickname.clone();
})
.or_insert(FleetDeviceAgent {
agent_id: observation.agent_id.clone(),
nickname: status.nickname.clone(),
connected: status.connected,
last_seen_unix: observation.last_seen_unix,
});
let route_id = route_id(
&observation.agent_id,
observation.mac.as_deref(),
observation.ip.as_deref(),
&observation.kind,
);
let wakeable = status.connected && observation.mac.is_some() && !observation_offline;
entry.routes.insert(
route_id.clone(),
FleetWakeRoute {
route_id: route_id.clone(),
agent_id: observation.agent_id,
nickname: status.nickname,
connected: status.connected,
mac: observation.mac,
ip: observation.ip,
hostname: observation.hostname,
source: observation.kind,
last_seen_unix: observation.last_seen_unix,
wakeable,
},
);
if let Some(route) = entry.routes.get_mut(&route_id) {
route.wakeable = route.connected && route.mac.is_some() && !observation_offline;
}
}
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
match identifier.kind.as_str() {
"mac" => {
entry.macs.insert(identifier.value.clone());
}
"ip" => {
entry.ips.insert(identifier.value.clone());
}
_ => {}
}
}
fn observation_known_device(
observation: &AgentDeviceObservation,
context: &FleetBuildContext,
) -> Option<KnownDeviceSummary> {
observation
.mac
.as_deref()
.and_then(|mac| context.identifier_map.get(&format!("mac:{mac}")).cloned())
.or_else(|| {
observation
.ip
.as_deref()
.and_then(|ip| context.identifier_map.get(&format!("ip:{ip}")).cloned())
})
}
pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
KnownDeviceSummary {
device_id: device.device_id.clone(),
display_name: device.display_name.clone(),
pinned: device.pinned,
}
}
impl FleetAccumulator {
fn into_response(self) -> FleetDevice {
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
route_candidates.sort_by(|a, b| {
b.connected
.cmp(&a.connected)
.then_with(|| b.wakeable.cmp(&a.wakeable))
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
let recommended_route = route_candidates
.iter()
.find(|route| route.wakeable)
.cloned();
let display_name = self
.display_name
.or_else(|| self.hostnames.iter().next().cloned())
.or_else(|| self.macs.iter().next().cloned())
.or_else(|| self.ips.iter().next().cloned())
.unwrap_or_else(|| "(unknown device)".to_string());
FleetDevice {
device_key: self.device_key,
display_name,
known_device: self.known_device,
pinned: self.pinned,
ips: self.ips.into_iter().collect(),
macs: self.macs.into_iter().collect(),
hostnames: self.hostnames.into_iter().collect(),
agents: self.agents.into_values().collect(),
sources: self.sources.into_iter().collect(),
first_seen_unix: self.first_seen_unix,
last_seen_unix: self.last_seen_unix,
presence: rank_presence(self.presence_rank).to_string(),
route_candidates,
recommended_route,
}
}
}
fn observation_presence_rank(observation: &AgentDeviceObservation) -> u8 {
match observation.last_action.as_str() {
"remove" => 0,
"add" | "old" | "update" => 2,
_ => 1,
}
}
fn observation_is_offline(observation: &AgentDeviceObservation) -> bool {
observation.last_action == "remove"
}
fn rank_presence(rank: u8) -> &'static str {
match rank {
3 => "online",
2 => "likely_online",
0 => "offline",
_ => "unknown",
}
}
fn presence_rank(presence: &str) -> u8 {
match presence {
"online" => 3,
"likely_online" => 2,
"offline" => 0,
_ => 1,
}
}
fn route_id(agent_id: &str, mac: Option<&str>, ip: Option<&str>, source: &str) -> String {
format!(
"{}|{}|{}|{}",
agent_id,
source,
mac.unwrap_or(""),
ip.unwrap_or("")
)
}
fn normalize_filter(value: Option<&str>) -> Option<String> {
value
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
}
@@ -0,0 +1,208 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use serde::Deserialize;
use crate::state::AgentDeviceObservationInput;
#[derive(Debug, Deserialize)]
struct InventoryEnvelope {
kind: String,
devices: Vec<InventoryDevice>,
}
#[derive(Debug, Deserialize)]
struct InventoryDevice {
#[serde(default)]
names: Vec<String>,
#[serde(default)]
ips: Vec<IpAddr>,
#[serde(default)]
macs: Vec<String>,
#[serde(default)]
neighbors: Vec<InventoryNeighbor>,
#[serde(default)]
leases: Vec<InventoryLease>,
#[serde(default)]
observations: Vec<InventoryObservationFact>,
#[serde(default)]
presence: String,
}
#[derive(Debug, Deserialize)]
struct InventoryNeighbor {
ip: IpAddr,
#[serde(default)]
mac: Option<String>,
#[serde(default)]
state: Option<String>,
}
#[derive(Debug, Deserialize)]
struct InventoryLease {
ip: IpAddr,
mac: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct InventoryObservationFact {
kind: String,
action: String,
#[serde(default)]
mac: Option<String>,
#[serde(default)]
ip: Option<IpAddr>,
#[serde(default)]
hostname: Option<String>,
}
pub(crate) fn inventory_result_to_observations(
result: serde_json::Value,
) -> anyhow::Result<Vec<AgentDeviceObservationInput>> {
let envelope: InventoryEnvelope = serde_json::from_value(result)?;
if envelope.kind != "inventory" {
anyhow::bail!("expected inventory result, got {}", envelope.kind);
}
let now = now_unix();
let mut out = Vec::new();
for device in envelope.devices {
let hostname = device
.names
.iter()
.find(|name| !name.trim().is_empty())
.cloned();
let mut wrote_source_observation = false;
for neighbor in &device.neighbors {
if neighbor.mac.is_none() && device.macs.is_empty() {
out.push(inventory_observation(
inventory_action_for_neighbor(neighbor),
None,
Some(neighbor.ip.to_string()),
hostname.clone(),
now,
));
wrote_source_observation = true;
continue;
}
let macs = neighbor
.mac
.iter()
.chain(device.macs.iter())
.collect::<BTreeSet<_>>();
for mac in macs {
out.push(inventory_observation(
inventory_action_for_neighbor(neighbor),
Some(mac.clone()),
Some(neighbor.ip.to_string()),
hostname.clone(),
now,
));
wrote_source_observation = true;
}
}
for observation in &device.observations {
if observation.mac.is_none() && observation.ip.is_none() {
continue;
}
out.push(inventory_observation(
inventory_action_for_observation(observation),
observation.mac.clone(),
observation.ip.map(|ip| ip.to_string()),
observation.hostname.clone().or_else(|| hostname.clone()),
now,
));
wrote_source_observation = true;
}
for lease in &device.leases {
out.push(inventory_observation(
"update",
Some(lease.mac.clone()),
Some(lease.ip.to_string()),
lease.name.clone().or_else(|| hostname.clone()),
now,
));
wrote_source_observation = true;
}
if wrote_source_observation {
continue;
}
let action = if device.presence == "offline" {
"remove"
} else {
"update"
};
if !device.macs.is_empty() {
for mac in device.macs {
out.push(inventory_observation(
action,
Some(mac),
device.ips.first().map(ToString::to_string),
hostname.clone(),
now,
));
}
} else {
for ip in &device.ips {
out.push(inventory_observation(
action,
None,
Some(ip.to_string()),
hostname.clone(),
now,
));
}
}
}
Ok(out)
}
fn inventory_observation(
action: &str,
mac: Option<String>,
ip: Option<String>,
hostname: Option<String>,
now: u64,
) -> AgentDeviceObservationInput {
AgentDeviceObservationInput {
kind: "inventory".into(),
action: action.into(),
mac,
ip,
hostname,
first_seen_unix: now,
last_seen_unix: now,
}
}
fn inventory_action_for_neighbor(neighbor: &InventoryNeighbor) -> &'static str {
if neighbor
.state
.as_deref()
.is_some_and(|state| state.eq_ignore_ascii_case("FAILED"))
{
"remove"
} else {
"update"
}
}
fn inventory_action_for_observation(observation: &InventoryObservationFact) -> &str {
match observation.action.as_str() {
"add" | "old" | "update" => "update",
"remove" | "del" => "remove",
_ if observation.kind == "neigh" => "update",
_ => "update",
}
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
@@ -0,0 +1,299 @@
use std::collections::{BTreeSet, HashMap};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use tracing::warn;
use wakey_agent::protocol::{AgentCommand, InventoryRequest, WakeRequest};
use crate::api::commands::relay_agent_command;
use crate::api::json_error;
use crate::runtime::AppState;
mod build;
mod inventory;
mod types;
#[cfg(test)]
mod tests;
use build::{
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
known_device_summary,
};
use inventory::inventory_result_to_observations;
use types::{
FleetDevice, ListFleetDevicesQuery, RefreshFleetAgentResult, RefreshFleetDevicesRequest,
RefreshFleetDevicesResponse, WakeFleetDeviceRequest, WakeFleetDeviceResponse,
};
pub async fn list_fleet_devices(
State(state): State<AppState>,
Query(query): Query<ListFleetDevicesQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match load_fleet_devices(&state, &query).await {
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
Err(err) => {
warn!(error = %err, "failed to list fleet devices");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_fleet_devices_failed",
&err.to_string(),
))
}
}
}
pub async fn refresh_fleet_devices(
State(state): State<AppState>,
Json(req): Json<RefreshFleetDevicesRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let mut agent_ids = if req.agent_ids.is_empty() {
let sessions = state.sessions.read().await;
sessions.keys().cloned().collect::<Vec<_>>()
} else {
req.agent_ids.clone()
};
agent_ids.sort();
agent_ids.dedup();
let mut results = Vec::with_capacity(agent_ids.len());
let mut total_accepted = 0usize;
for agent_id in agent_ids {
let response = relay_agent_command(
&state,
&agent_id,
AgentCommand::Inventory(InventoryRequest {
query: None,
name: None,
ips: Vec::new(),
devs: Vec::new(),
nuds: Vec::new(),
macs: Vec::new(),
}),
req.timeout_ms,
)
.await;
match response {
Ok(response) if response.status == "ok" => {
let Some(result) = response.result else {
results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some("inventory command returned no result".into()),
});
continue;
};
match inventory_result_to_observations(result) {
Ok(observations) => match state
.store
.upsert_agent_observations(&agent_id, observations)
.await
{
Ok(accepted) => {
total_accepted = total_accepted.saturating_add(accepted);
results.push(RefreshFleetAgentResult {
agent_id,
status: "ok".into(),
accepted,
error: None,
});
}
Err(err) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some(err.to_string()),
}),
},
Err(err) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some(err.to_string()),
}),
}
}
Ok(response) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: response
.error
.map(|error| error.message)
.or_else(|| Some("inventory command failed".into())),
}),
Err((status, body)) => results.push(RefreshFleetAgentResult {
agent_id,
status: "error".into(),
accepted: 0,
error: Some(format!("{status}: {}", body.0)),
}),
}
}
Ok((
StatusCode::OK,
Json(RefreshFleetDevicesResponse {
total_accepted,
agents: results,
}),
))
}
pub async fn wake_fleet_device(
State(state): State<AppState>,
Json(req): Json<WakeFleetDeviceRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = ListFleetDevicesQuery {
query: None,
presence: None,
known: None,
agent_id: None,
visibility: Some("all".into()),
limit: Some(1000),
};
let devices = load_fleet_devices(&state, &query).await.map_err(|err| {
warn!(error = %err, "failed loading fleet devices for wake");
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"load_fleet_devices_failed",
&err.to_string(),
)
})?;
let device = devices
.into_iter()
.find(|device| device.device_key == req.device_key)
.ok_or_else(|| {
json_error(
StatusCode::NOT_FOUND,
"fleet_device_not_found",
"fleet device not found",
)
})?;
let route = match req.route_id.as_deref() {
Some(route_id) => device
.route_candidates
.into_iter()
.find(|route| route.route_id == route_id),
None => device.recommended_route,
}
.ok_or_else(|| {
json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"no wakeable connected MAC-backed route is available",
)
})?;
let Some(mac) = route.mac.as_deref() else {
return Err(json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"selected route does not include a MAC address",
));
};
if !route.connected {
return Err(json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"selected route agent is not connected",
));
}
if !route.wakeable {
return Err(json_error(
StatusCode::BAD_REQUEST,
"wake_route_unavailable",
"selected route is not wakeable",
));
}
let mac = mac.parse().map_err(|err| {
json_error(
StatusCode::BAD_REQUEST,
"invalid_wake_route",
&format!("invalid route MAC: {err}"),
)
})?;
let ip = route
.ip
.as_deref()
.map(str::parse)
.transpose()
.map_err(|err| {
json_error(
StatusCode::BAD_REQUEST,
"invalid_wake_route",
&format!("invalid route IP: {err}"),
)
})?;
let command = relay_agent_command(
&state,
&route.agent_id,
AgentCommand::Wake(WakeRequest {
query: None,
mac: Some(mac),
ip,
}),
req.timeout_ms,
)
.await?;
Ok((
StatusCode::OK,
Json(WakeFleetDeviceResponse { route, command }),
))
}
async fn load_fleet_devices(
state: &AppState,
query: &ListFleetDevicesQuery,
) -> anyhow::Result<Vec<FleetDevice>> {
let known_devices = state.store.list_known_devices().await?;
let observations = state
.store
.list_agent_observations(None, query.limit.unwrap_or(1000).max(1))
.await?;
let connected = {
let sessions = state.sessions.read().await;
sessions.keys().cloned().collect::<BTreeSet<_>>()
};
let agents = state.store.list_agents_with_nicknames().await;
let agent_status = agents
.into_iter()
.map(|(agent_id, nickname)| {
let connected = connected.contains(&agent_id);
(
agent_id,
AgentRuntimeStatus {
nickname,
connected,
},
)
})
.collect::<HashMap<_, _>>();
let mut identifier_map = HashMap::new();
for device in &known_devices {
let summary = known_device_summary(device);
for identifier in &device.identifiers {
identifier_map.insert(identifier.identifier_key.clone(), summary.clone());
}
}
let context = FleetBuildContext {
agent_status,
identifier_map,
};
let mut devices = build_fleet_devices(known_devices, observations, &context);
filter_fleet_devices(&mut devices, query);
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
devices.truncate(limit);
Ok(devices)
}
@@ -0,0 +1,333 @@
use std::collections::HashMap;
use super::build::{
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
known_device_summary,
};
use super::inventory::inventory_result_to_observations;
use super::types::ListFleetDevicesQuery;
use crate::state::{AgentDeviceObservation, DeviceIdentifier, KnownDevice};
fn context(connected: &[&str]) -> FleetBuildContext {
FleetBuildContext {
agent_status: connected
.iter()
.map(|agent| {
(
(*agent).to_string(),
AgentRuntimeStatus {
nickname: None,
connected: true,
},
)
})
.collect(),
identifier_map: HashMap::new(),
}
}
fn observation(
agent_id: &str,
mac: Option<&str>,
ip: Option<&str>,
last_seen_unix: u64,
) -> AgentDeviceObservation {
AgentDeviceObservation {
observation_key: format!(
"agent:{agent_id}:dhcp:{}",
mac.map(|mac| format!("mac:{mac}"))
.or_else(|| ip.map(|ip| format!("ip:{ip}")))
.unwrap_or_default()
),
agent_id: agent_id.into(),
kind: "dhcp".into(),
mac: mac.map(str::to_string),
ip: ip.map(str::to_string),
hostname: Some("lda".into()),
first_seen_unix: 1,
last_seen_unix,
last_action: "update".into(),
}
}
#[test]
fn fleet_grouping_combines_same_mac_across_agents() {
let devices = build_fleet_devices(
Vec::new(),
vec![
observation(
"agent-a",
Some("aa:bb:cc:dd:ee:ff"),
Some("192.168.1.2"),
10,
),
observation(
"agent-b",
Some("aa:bb:cc:dd:ee:ff"),
Some("192.168.2.2"),
20,
),
],
&context(&["agent-a", "agent-b"]),
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].macs, vec!["aa:bb:cc:dd:ee:ff"]);
assert_eq!(devices[0].agents.len(), 2);
assert_eq!(
devices[0]
.recommended_route
.as_ref()
.map(|route| route.agent_id.as_str()),
Some("agent-b")
);
}
#[test]
fn known_device_with_two_macs_absorbs_both_observation_groups() {
let known = KnownDevice {
device_id: "dev-1".into(),
display_name: "lda".into(),
pinned: true,
created_at_unix: 1,
updated_at_unix: 1,
notes: None,
identifiers: vec![
DeviceIdentifier {
identifier_key: "mac:aa:bb:cc:dd:ee:01".into(),
device_id: "dev-1".into(),
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:01".into(),
created_at_unix: 1,
},
DeviceIdentifier {
identifier_key: "mac:aa:bb:cc:dd:ee:02".into(),
device_id: "dev-1".into(),
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:02".into(),
created_at_unix: 1,
},
],
};
let mut ctx = context(&["agent-a"]);
for identifier in &known.identifiers {
ctx.identifier_map.insert(
identifier.identifier_key.clone(),
known_device_summary(&known),
);
}
let devices = build_fleet_devices(
vec![known],
vec![
observation("agent-a", Some("aa:bb:cc:dd:ee:01"), None, 10),
observation("agent-a", Some("aa:bb:cc:dd:ee:02"), None, 20),
],
&ctx,
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].device_key, "known:dev-1");
assert_eq!(devices[0].macs.len(), 2);
}
#[test]
fn ip_only_unknown_is_visible_but_not_wakeable() {
let devices = build_fleet_devices(
Vec::new(),
vec![observation("agent-a", None, Some("192.168.1.2"), 10)],
&context(&["agent-a"]),
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].ips, vec!["192.168.1.2"]);
assert!(devices[0].recommended_route.is_none());
assert!(!devices[0].route_candidates[0].wakeable);
}
#[test]
fn offline_observation_does_not_advertise_current_ip_or_wake_route() {
let mut offline = observation(
"agent-a",
Some("aa:bb:cc:dd:ee:ff"),
Some("192.168.1.2"),
20,
);
offline.kind = "neigh".into();
offline.last_action = "remove".into();
let devices = build_fleet_devices(Vec::new(), vec![offline], &context(&["agent-a"]));
assert_eq!(devices.len(), 1);
assert!(devices[0].ips.is_empty());
assert_eq!(devices[0].presence, "offline");
assert!(devices[0].recommended_route.is_none());
assert!(!devices[0].route_candidates[0].wakeable);
}
#[test]
fn unknown_ip_only_remove_observation_is_hidden() {
let mut offline = observation("agent-a", None, Some("192.168.1.2"), 20);
offline.kind = "neigh".into();
offline.hostname = None;
offline.last_action = "remove".into();
let mut devices = build_fleet_devices(Vec::new(), vec![offline], &context(&["agent-a"]));
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].display_name, "(unknown device)");
filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
assert!(devices.is_empty());
}
#[test]
fn visibility_all_keeps_unknown_ip_only_remove_observation() {
let mut offline = observation("agent-a", None, Some("192.168.1.2"), 20);
offline.kind = "neigh".into();
offline.hostname = None;
offline.last_action = "remove".into();
let mut devices = build_fleet_devices(Vec::new(), vec![offline], &context(&["agent-a"]));
filter_fleet_devices(
&mut devices,
&ListFleetDevicesQuery {
visibility: Some("all".into()),
..Default::default()
},
);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].display_name, "(unknown device)");
}
#[test]
fn known_ip_only_remove_observation_is_kept() {
let known = KnownDevice {
device_id: "dev-1".into(),
display_name: "lda".into(),
pinned: true,
created_at_unix: 1,
updated_at_unix: 1,
notes: None,
identifiers: vec![DeviceIdentifier {
identifier_key: "ip:192.168.1.2".into(),
device_id: "dev-1".into(),
kind: "ip".into(),
value: "192.168.1.2".into(),
created_at_unix: 1,
}],
};
let mut ctx = context(&["agent-a"]);
ctx.identifier_map
.insert("ip:192.168.1.2".into(), known_device_summary(&known));
let mut offline = observation("agent-a", None, Some("192.168.1.2"), 20);
offline.kind = "neigh".into();
offline.hostname = None;
offline.last_action = "remove".into();
let devices = build_fleet_devices(vec![known], vec![offline], &ctx);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].device_key, "known:dev-1");
assert_eq!(devices[0].display_name, "lda");
assert!(devices[0].ips.contains(&"192.168.1.2".to_string()));
}
#[test]
fn inventory_result_maps_to_stored_observations() {
let observations = inventory_result_to_observations(serde_json::json!({
"kind": "inventory",
"devices": [{
"names": ["lda"],
"ips": ["192.168.1.2"],
"macs": ["aa:bb:cc:dd:ee:ff"],
"presence": "likely_online"
}]
}))
.expect("inventory should map");
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].kind, "inventory");
assert_eq!(observations[0].action, "update");
assert_eq!(observations[0].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
}
#[test]
fn inventory_result_preserves_neighbor_failed_ip_as_remove() {
let observations = inventory_result_to_observations(serde_json::json!({
"kind": "inventory",
"devices": [{
"names": ["lda"],
"ips": ["192.168.1.2", "192.168.1.3"],
"macs": ["aa:bb:cc:dd:ee:ff"],
"neighbors": [
{
"ip": "192.168.1.2",
"mac": "aa:bb:cc:dd:ee:ff",
"state": "FAILED"
},
{
"ip": "192.168.1.3",
"mac": "aa:bb:cc:dd:ee:ff",
"state": "REACHABLE"
}
],
"presence": "online"
}]
}))
.expect("inventory should map");
assert_eq!(observations.len(), 2);
let removed = observations
.iter()
.find(|observation| observation.ip.as_deref() == Some("192.168.1.2"))
.expect("failed neighbor observation should exist");
assert_eq!(removed.action, "remove");
assert_eq!(removed.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
let current = observations
.iter()
.find(|observation| observation.ip.as_deref() == Some("192.168.1.3"))
.expect("reachable neighbor observation should exist");
assert_eq!(current.action, "update");
assert_eq!(current.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
}
#[test]
fn inventory_result_preserves_hook_observations_and_leases() {
let observations = inventory_result_to_observations(serde_json::json!({
"kind": "inventory",
"devices": [{
"names": ["lda"],
"ips": ["192.168.1.2", "192.168.1.3"],
"macs": ["aa:bb:cc:dd:ee:ff"],
"observations": [{
"kind": "neigh",
"action": "remove",
"mac": "aa:bb:cc:dd:ee:ff",
"ip": "192.168.1.2"
}],
"leases": [{
"expires_epoch": 1893456000_u64,
"ip": "192.168.1.3",
"mac": "aa:bb:cc:dd:ee:ff",
"name": "lda"
}],
"presence": "likely_online"
}]
}))
.expect("inventory should map");
assert_eq!(observations.len(), 2);
assert!(observations.iter().any(|observation| {
observation.action == "remove"
&& observation.mac.as_deref() == Some("aa:bb:cc:dd:ee:ff")
&& observation.ip.as_deref() == Some("192.168.1.2")
}));
assert!(observations.iter().any(|observation| {
observation.action == "update"
&& observation.mac.as_deref() == Some("aa:bb:cc:dd:ee:ff")
&& observation.ip.as_deref() == Some("192.168.1.3")
&& observation.hostname.as_deref() == Some("lda")
}));
}
@@ -0,0 +1,88 @@
use serde::{Deserialize, Serialize};
use crate::api::commands::RelayCommandResponse;
use crate::state::KnownDeviceSummary;
#[derive(Debug, Default, Deserialize)]
pub struct ListFleetDevicesQuery {
pub query: Option<String>,
pub presence: Option<String>,
pub known: Option<String>,
pub agent_id: Option<String>,
pub visibility: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct RefreshFleetDevicesRequest {
#[serde(default)]
pub agent_ids: Vec<String>,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshFleetDevicesResponse {
pub total_accepted: usize,
pub agents: Vec<RefreshFleetAgentResult>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshFleetAgentResult {
pub agent_id: String,
pub status: String,
pub accepted: usize,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WakeFleetDeviceRequest {
pub device_key: String,
pub route_id: Option<String>,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct WakeFleetDeviceResponse {
pub route: FleetWakeRoute,
pub command: RelayCommandResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetDevice {
pub device_key: String,
pub display_name: String,
pub known_device: Option<KnownDeviceSummary>,
pub pinned: bool,
pub ips: Vec<String>,
pub macs: Vec<String>,
pub hostnames: Vec<String>,
pub agents: Vec<FleetDeviceAgent>,
pub sources: Vec<String>,
pub first_seen_unix: Option<u64>,
pub last_seen_unix: Option<u64>,
pub presence: String,
pub route_candidates: Vec<FleetWakeRoute>,
pub recommended_route: Option<FleetWakeRoute>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetDeviceAgent {
pub agent_id: String,
pub nickname: Option<String>,
pub connected: bool,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetWakeRoute {
pub route_id: String,
pub agent_id: String,
pub nickname: Option<String>,
pub connected: bool,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub source: String,
pub last_seen_unix: u64,
pub wakeable: bool,
}
+5 -5
View File
@@ -12,11 +12,11 @@ pub use commands::{list_agents, run_command};
pub use control::{ pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, attach_device_identifier, attach_observation_identifier, StateStatsResponse, attach_device_identifier, attach_observation_identifier,
create_known_device, enroll, forget_known_device, healthz, issue_enroll_token, create_known_device, detach_device_identifier, enroll, forget_known_device, healthz,
list_agent_observation_history, list_agent_observations, list_enroll_tokens, issue_enroll_token, list_agent_observation_history, list_agent_observations,
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices, list_enroll_tokens, list_fleet_devices, list_known_devices, merge_known_device,
request_agent_observation_sync, revoke_agent, revoke_enroll_token, set_agent_nickname, refresh_fleet_devices, request_agent_observation_sync, revoke_agent, revoke_enroll_token,
state_stats, upload_agent_observations, wake_fleet_device, set_agent_nickname, state_stats, upload_agent_observations, wake_fleet_device,
}; };
pub fn json_error( pub fn json_error(
+10 -4
View File
@@ -87,10 +87,16 @@ pub struct ServeArgs {
#[derive(Args, Clone)] #[derive(Args, Clone)]
pub struct InitConfigArgs { pub struct InitConfigArgs {
#[arg(long = "config-file", alias = "config")] #[arg(
long = "config-file",
short,
visible_alias = "config",
visible_alias = "to",
visible_short_alias = 't'
)]
pub config_file: Option<PathBuf>, pub config_file: Option<PathBuf>,
#[arg(long)] #[arg(long, short, visible_alias = "from")]
pub from_config: Option<PathBuf>, pub from_config: Option<PathBuf>,
#[arg(long, conflicts_with = "config_file")] #[arg(long, conflicts_with = "config_file")]
@@ -138,7 +144,7 @@ pub struct InitConfigArgs {
#[derive(Args)] #[derive(Args)]
pub struct IssueEnrollTokenArgs { pub struct IssueEnrollTokenArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)] #[arg(long, visible_alias = "config", default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf, pub config_file: PathBuf,
#[arg(long)] #[arg(long)]
@@ -159,7 +165,7 @@ pub struct IssueEnrollTokenArgs {
#[derive(Args)] #[derive(Args)]
pub struct ListEnrollTokensArgs { pub struct ListEnrollTokensArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)] #[arg(long, visible_alias = "config", default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf, pub config_file: PathBuf,
#[arg(long)] #[arg(long)]
+4
View File
@@ -130,6 +130,10 @@ fn control_api_routes() -> Router<AppState> {
"/api/v1/control/devices/{device_id}/identifiers", "/api/v1/control/devices/{device_id}/identifiers",
post(api::attach_device_identifier), post(api::attach_device_identifier),
) )
.route(
"/api/v1/control/devices/{device_id}/identifiers/{identifier_key}",
axum::routing::delete(api::detach_device_identifier),
)
.route( .route(
"/api/v1/control/devices/{device_id}/identifiers/from-observation", "/api/v1/control/devices/{device_id}/identifiers/from-observation",
post(api::attach_observation_identifier), post(api::attach_observation_identifier),
+46
View File
@@ -337,6 +337,52 @@ mod tests {
cleanup_dir(&dir); cleanup_dir(&dir);
} }
#[tokio::test]
async fn device_identifier_can_be_detached_manually() {
let (store, dir) = make_store().await;
let created = store
.create_known_device(KnownDeviceInput {
display_name: "lda".into(),
pinned: true,
notes: None,
identifiers: vec![
DeviceIdentifierInput {
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:ff".into(),
},
DeviceIdentifierInput {
kind: "ip".into(),
value: "192.168.1.2".into(),
},
],
})
.await
.expect("known device should create");
let updated = store
.detach_device_identifier(&created.device_id, "ip:192.168.1.2")
.await
.expect("identifier detach should succeed")
.expect("device should exist");
assert_eq!(updated.identifiers.len(), 1);
assert_eq!(
updated.identifiers[0].identifier_key,
"mac:aa:bb:cc:dd:ee:ff"
);
let unmatched = store
.lookup_known_device_by_identifier(DeviceIdentifierInput {
kind: "ip".into(),
value: "192.168.1.2".into(),
})
.await
.expect("lookup should succeed");
assert!(unmatched.is_none());
cleanup_dir(&dir);
}
#[tokio::test] #[tokio::test]
async fn merge_known_devices_moves_identifiers_and_deletes_source() { async fn merge_known_devices_moves_identifiers_and_deletes_source() {
let (store, dir) = make_store().await; let (store, dir) = make_store().await;
@@ -196,6 +196,51 @@ impl Store {
self.attach_device_identifier(device_id, input).await self.attach_device_identifier(device_id, input).await
} }
pub async fn detach_device_identifier(
&self,
device_id: &str,
identifier_key: &str,
) -> Result<Option<KnownDevice>> {
let now = now_unix();
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
let mut tx = self
.pool
.begin()
.await
.context("failed starting device identifier detach transaction")?;
let exists = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
device_id
)
.fetch_one(&mut *tx)
.await
.context("failed checking known device existence")?;
if exists == 0 {
return Ok(None);
}
sqlx::query!(
"DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
device_id,
identifier_key
)
.execute(&mut *tx)
.await
.context("failed detaching device identifier")?;
sqlx::query!(
"UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
now_i64,
device_id
)
.execute(&mut *tx)
.await
.context("failed updating known device timestamp")?;
tx.commit()
.await
.context("failed committing device identifier detach transaction")?;
self.get_known_device(device_id).await
}
#[cfg_attr(not(test), allow(dead_code))] #[cfg_attr(not(test), allow(dead_code))]
pub async fn lookup_known_device_by_identifier( pub async fn lookup_known_device_by_identifier(
&self, &self,
@@ -259,10 +259,7 @@ pub(in crate::state::store) fn normalize_agent_observation(
let mac = normalize_optional_text(input.mac.as_deref()).map(|value| value.to_ascii_lowercase()); let mac = normalize_optional_text(input.mac.as_deref()).map(|value| value.to_ascii_lowercase());
let ip = normalize_optional_text(input.ip.as_deref()); let ip = normalize_optional_text(input.ip.as_deref());
let hostname = normalize_optional_text(input.hostname.as_deref()); let hostname = normalize_optional_text(input.hostname.as_deref());
let identifier = mac let identifier = observation_identifier(&kind, mac.as_deref(), ip.as_deref())
.as_ref()
.map(|value| format!("mac:{value}"))
.or_else(|| ip.as_ref().map(|value| format!("ip:{value}")))
.ok_or_else(|| anyhow::anyhow!("observation requires mac or ip"))?; .ok_or_else(|| anyhow::anyhow!("observation requires mac or ip"))?;
let observation_key = format!("agent:{agent_id}:{kind}:{identifier}"); let observation_key = format!("agent:{agent_id}:{kind}:{identifier}");
Ok(AgentDeviceObservation { Ok(AgentDeviceObservation {
@@ -278,6 +275,15 @@ pub(in crate::state::store) fn normalize_agent_observation(
}) })
} }
fn observation_identifier(kind: &str, mac: Option<&str>, ip: Option<&str>) -> Option<String> {
match (kind, mac, ip) {
("neigh" | "inventory", Some(mac), Some(ip)) => Some(format!("mac:{mac}:ip:{ip}")),
(_, Some(mac), _) => Some(format!("mac:{mac}")),
(_, None, Some(ip)) => Some(format!("ip:{ip}")),
(_, None, None) => None,
}
}
pub(in crate::state::store) async fn insert_device_identifier_tx( pub(in crate::state::store) async fn insert_device_identifier_tx(
tx: &mut Transaction<'_, Sqlite>, tx: &mut Transaction<'_, Sqlite>,
device_id: &str, device_id: &str,
@@ -211,6 +211,8 @@ impl Store {
r#"SELECT events.event_id as "event_id!", r#"SELECT events.event_id as "event_id!",
('agent:' || events.agent_id || ':' || events.kind || ':' || ('agent:' || events.agent_id || ':' || events.kind || ':' ||
CASE CASE
WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL
THEN 'mac:' || events.mac || ':ip:' || events.ip
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
ELSE '' ELSE ''
@@ -239,6 +241,8 @@ impl Store {
AND (?4 IS NULL OR events.ip = ?4) AND (?4 IS NULL OR events.ip = ?4)
AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' || AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||
CASE CASE
WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL
THEN 'mac:' || events.mac || ':ip:' || events.ip
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
ELSE '' ELSE ''
+100
View File
@@ -136,4 +136,104 @@ mod tests {
let _ = tokio::fs::remove_file(observation_path).await; let _ = tokio::fs::remove_file(observation_path).await;
let _ = tokio::fs::remove_file(cache_path).await; let _ = tokio::fs::remove_file(cache_path).await;
} }
#[tokio::test]
#[serial]
async fn neighbor_observations_are_keyed_by_mac_ip_pair() {
let observation_path = temp_file("neighbor-observations");
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
let mac = "aa:bb:cc:dd:ee:ff".parse().expect("mac should parse");
observe_neighbor_event(
"add",
Some(mac),
Some("192.168.1.2".parse().expect("ip should parse")),
)
.await
.expect("first observation should write");
observe_neighbor_event(
"update",
Some(mac),
Some("192.168.1.3".parse().expect("ip should parse")),
)
.await
.expect("second observation should write");
let store = load_observation_store()
.await
.expect("observation store should read");
assert!(
store
.neighbors
.contains_key("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2")
);
assert!(
store
.neighbors
.contains_key("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.3")
);
assert_eq!(
store.neighbors["mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2"].last_action,
"remove"
);
let _ = tokio::fs::remove_file(observation_path).await;
}
#[tokio::test]
#[serial]
async fn neighbor_observation_migrates_coarse_mac_key_to_mac_ip_pair() {
let observation_path = temp_file("neighbor-observations-migrate");
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
let mut neighbors = std::collections::BTreeMap::new();
neighbors.insert(
"mac:aa:bb:cc:dd:ee:ff".to_string(),
ObservedNeighbor {
key: "mac:aa:bb:cc:dd:ee:ff".to_string(),
mac: Some("aa:bb:cc:dd:ee:ff".to_string()),
ip: None,
first_seen_unix: 1,
last_seen_unix: 1,
last_action: "add".to_string(),
},
);
let fixture = LocalObservationStore {
dhcp_clients: Default::default(),
neighbors,
};
tokio::fs::write(
&observation_path,
serde_json::to_string(&fixture).expect("fixture should serialize"),
)
.await
.expect("fixture should write");
observe_neighbor_event(
"update",
Some("aa:bb:cc:dd:ee:ff".parse().expect("mac should parse")),
Some("192.168.1.2".parse().expect("ip should parse")),
)
.await
.expect("observation should write");
let store = load_observation_store()
.await
.expect("observation store should read");
assert!(!store.neighbors.contains_key("mac:aa:bb:cc:dd:ee:ff"));
let row = store
.neighbors
.get("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2")
.expect("coarse key should migrate to pair key");
assert_eq!(row.first_seen_unix, 1);
assert_eq!(row.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
assert_eq!(
row.ip
.expect("ip should be carried into migrated row")
.to_string(),
"192.168.1.2"
);
assert_eq!(row.last_action, "update");
let _ = tokio::fs::remove_file(observation_path).await;
}
} }
+110 -22
View File
@@ -218,14 +218,11 @@ pub async fn observe_neighbor_event(
mac: Option<MacAddr>, mac: Option<MacAddr>,
ip: Option<IpAddr>, ip: Option<IpAddr>,
) -> io::Result<bool> { ) -> io::Result<bool> {
if !matches!(action, "add" | "update" | "old" | "remove") { let action = normalize_neighbor_action(action);
// update and remove not emitted by hotplug if !is_neighbor_observation_action(&action) {
return Ok(false); return Ok(false);
} }
let Some(key) = mac let Some(key) = neighbor_observation_key(mac, ip) else {
.map(|value| format!("mac:{}", value.to_string().to_ascii_lowercase()))
.or_else(|| ip.map(|value| format!("ip:{}", value)))
else {
return Ok(false); return Ok(false);
}; };
@@ -233,35 +230,126 @@ pub async fn observe_neighbor_event(
let mac = mac.map(|value| value.to_string().to_ascii_lowercase()); let mac = mac.map(|value| value.to_string().to_ascii_lowercase());
let mut store = load_observation_store().await.unwrap_or_default(); let mut store = load_observation_store().await.unwrap_or_default();
let mut changed = false; let mut changed = false;
store let alias_keys = neighbor_observation_alias_keys(mac.as_deref(), ip, &key);
let mut row = store
.neighbors .neighbors
.entry(key.clone()) .remove(&key)
.and_modify(|row| { .or_else(|| {
if row.mac != mac alias_keys
.iter()
.find_map(|alias| store.neighbors.remove(alias))
})
.unwrap_or_else(|| {
changed = true;
ObservedNeighbor {
key: key.clone(),
mac: mac.clone(),
ip,
first_seen_unix: now,
last_seen_unix: now,
last_action: action.clone(),
}
});
for alias in alias_keys {
if store.neighbors.remove(&alias).is_some() {
changed = true;
}
}
if row.key != key
|| row.mac != mac
|| row.ip != ip || row.ip != ip
|| row.last_action != action || row.last_action != action
|| row.last_seen_unix != now || row.last_seen_unix != now
{ {
row.key = key.clone();
row.mac = mac.clone(); row.mac = mac.clone();
row.ip = ip; row.ip = ip;
row.last_action = action.to_string(); row.last_action = action.clone();
row.last_seen_unix = now; row.last_seen_unix = now;
changed = true; changed = true;
} }
}) store.neighbors.insert(key, row);
.or_insert_with(|| { if let (Some(mac), Some(ip)) = (mac.as_deref(), ip)
changed = true; && !is_offline_neighbor_action(&action)
ObservedNeighbor { {
key, mark_replaced_neighbor_ips_removed(&mut store, mac, ip, now, &mut changed);
mac,
ip,
first_seen_unix: now,
last_seen_unix: now,
last_action: action.to_string(),
} }
});
if changed { if changed {
save_observation_store(&store).await?; save_observation_store(&store).await?;
} }
Ok(changed) Ok(changed)
} }
fn normalize_neighbor_action(action: &str) -> String {
match action.trim().to_ascii_lowercase().as_str() {
"del" => "remove".into(),
"old" => "update".into(),
other => other.to_string(),
}
}
fn is_neighbor_observation_action(action: &str) -> bool {
matches!(action, "add" | "update" | "remove")
}
fn is_offline_neighbor_action(action: &str) -> bool {
action == "remove"
}
fn neighbor_observation_key(mac: Option<MacAddr>, ip: Option<IpAddr>) -> Option<String> {
match (mac, ip) {
(Some(mac), Some(ip)) => Some(format!(
"mac:{}:ip:{ip}",
mac.to_string().to_ascii_lowercase()
)),
(Some(mac), None) => Some(format!("mac:{}", mac.to_string().to_ascii_lowercase())),
(None, Some(ip)) => Some(format!("ip:{ip}")),
(None, None) => None,
}
}
fn neighbor_observation_alias_keys(
mac: Option<&str>,
ip: Option<IpAddr>,
primary_key: &str,
) -> Vec<String> {
let mut keys = Vec::with_capacity(2);
if let Some(mac) = mac {
keys.push(format!("mac:{mac}"));
}
if let Some(ip) = ip {
keys.push(format!("ip:{ip}"));
}
keys.retain(|key| key != primary_key);
keys
}
fn mark_replaced_neighbor_ips_removed(
store: &mut LocalObservationStore,
mac: &str,
current_ip: IpAddr,
now: u64,
changed: &mut bool,
) {
for row in store.neighbors.values_mut() {
let Some(row_ip) = row.ip else {
continue;
};
if row.mac.as_deref() == Some(mac)
&& row_ip != current_ip
&& same_ip_family(row_ip, current_ip)
&& !is_offline_neighbor_action(&row.last_action)
{
row.last_action = "remove".into();
row.last_seen_unix = now;
*changed = true;
}
}
}
fn same_ip_family(a: IpAddr, b: IpAddr) -> bool {
matches!(
(a, b),
(IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))
)
}