Compare commits
18
Commits
v0.2.2
..
b5c0626250
No files matched your search
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@@ -0,0 +1,12 @@
|
||||
# Completion Checklist
|
||||
|
||||
Before handing back code changes, run the smallest checks covering the changed area:
|
||||
- Rust formatting: `cargo fmt`.
|
||||
- For control-plane SQLx/state/API work: `cargo test -p wakey-control-plane` and `SQLX_OFFLINE=true cargo test -p wakey-control-plane`.
|
||||
- For SQLx query macro changes: run `scripts/prepare_sqlx_sqlite.sh` and include the `.sqlx` metadata changes.
|
||||
- For wakey-linux/core inventory/observation work: `cargo test -p wakey-linux -p wakey-core`.
|
||||
- For agent session/config/protocol work: `cargo test -p wakey-agent`.
|
||||
- For UI work: `pnpm --dir ui typecheck` and `pnpm --dir ui build`; use `pnpm --dir ui format` when touching TS/TSX/CSS formatting.
|
||||
- Run relevant `cargo clippy -p <crate> --all-targets --all-features` when behavior or API shape changes.
|
||||
|
||||
Always check `git status --short` and mention unrelated dirty files or generated metadata in the final summary.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Wakey Project Overview
|
||||
|
||||
Wakey is a Wake-on-LAN and LAN observability workspace for Linux/OpenWrt routers. It includes:
|
||||
- `wakey-core`: shared model and parsing types for devices, neighbors, DHCP, interfaces, wake results.
|
||||
- `wakey-linux`: Linux/OpenWrt adapter layer for DHCP lease files, neighbor lookup, interface data, WoL sending, and local observation storage.
|
||||
- root `wakey` crate: service layer and operator CLI for inventory, leases, devs, wake, and observe.
|
||||
- `wakey-agent`: outbound router daemon. Enrolls with control plane, maintains authenticated websocket, executes relayed commands, syncs observations.
|
||||
- `wakey-control-plane`: Axum control plane, SQLx SQLite state, agent registry, command relay, known devices, observations, audit/alerts, fleet APIs, static UI hosting.
|
||||
- `ipjs`: typed wrappers around Linux `ip -j` data.
|
||||
- `ui`: React/Vite operator UI using shadcn/base-ui style components.
|
||||
|
||||
Current persistence direction: control plane uses SQLx SQLite with migrations in `wakey-control-plane/migrations` and offline query metadata in `.sqlx` plus `wakey-control-plane/.sqlx`. Legacy sled import exists as explicit command.
|
||||
|
||||
Current product direction: fleet-first device UI and backend. Known device identity is manual. Observations/inventory provide suggestions/current state but should not automatically promote or delete durable identifiers.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Style And Conventions
|
||||
|
||||
Rust:
|
||||
- Edition 2024.
|
||||
- Prefer explicit, small service/API functions over large abstractions.
|
||||
- Use `anyhow::Context` for fallible operations in application layers.
|
||||
- Use `tracing` for logs.
|
||||
- Shared domain types live in `wakey-core`; Linux/OpenWrt I/O in `wakey-linux`; root crate owns service/CLI behavior; agent/control-plane should reuse service/domain types where possible.
|
||||
- SQLx query macros are used in control-plane state. Regenerate `.sqlx` metadata after query string changes.
|
||||
- Device identity is manual: observations and inventory may suggest, but should not silently pin, merge, promote, or delete known identifiers.
|
||||
- Hotplug observation actions should stay event-shaped (`add`, `update`, `remove`). Do not store NUD state as the action. NUD belongs to live neighbor/inventory data.
|
||||
|
||||
Frontend:
|
||||
- React + Vite + TypeScript.
|
||||
- Use existing component/style conventions; shadcn/base-ui/lucide are available.
|
||||
- Main operator UI is fleet-first: common path is find device, inspect current IP/MAC/agent, wake, copy, remember/merge.
|
||||
- Admin/debug pages stay available but should not dominate the primary nav.
|
||||
|
||||
Editing:
|
||||
- Keep changes scoped.
|
||||
- Preserve user edits in dirty worktrees.
|
||||
- Use `apply_patch` for manual file edits.
|
||||
- Default to ASCII unless files already use non-ASCII for a reason.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Suggested Commands
|
||||
|
||||
Rust formatting and checks:
|
||||
- `cargo fmt`
|
||||
- `cargo test -p wakey-control-plane`
|
||||
- `SQLX_OFFLINE=true cargo test -p wakey-control-plane`
|
||||
- `cargo test -p wakey-linux -p wakey-core`
|
||||
- `cargo test -p wakey-agent` (may need elevated permissions if tests bind local sockets)
|
||||
- `cargo clippy -p wakey-control-plane --all-targets --all-features`
|
||||
- `cargo clippy -p wakey-linux -p wakey-core --all-targets --all-features`
|
||||
|
||||
SQLx metadata after query macro changes:
|
||||
- `scripts/prepare_sqlx_sqlite.sh`
|
||||
|
||||
UI checks:
|
||||
- `pnpm --dir ui typecheck`
|
||||
- `pnpm --dir ui build`
|
||||
- `pnpm --dir ui format`
|
||||
|
||||
Local run examples:
|
||||
- `cargo run -- inventory <query>`
|
||||
- `cargo run -- leases`
|
||||
- `cargo run -- devs`
|
||||
- `cargo run -- wake --mac aa:bb:cc:dd:ee:ff`
|
||||
- `cargo run -p wakey-agent -- serve --config /etc/wakey-agent/config.toml`
|
||||
- `cargo run -p wakey-control-plane -- serve --config-file /etc/wakey-control-plane/config.toml`
|
||||
|
||||
Useful shell tools:
|
||||
- Prefer `rg` and `rg --files` for search.
|
||||
- Use `git status --short`, `git diff --stat`, and focused `git diff -- <path>` before edits.
|
||||
- Avoid destructive git commands unless explicitly requested.
|
||||
@@ -0,0 +1,155 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "wakey"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# haxe java julia kotlin lua
|
||||
# markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- rust
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
#
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project based on the project name or path.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
|
||||
# for example by saying that the information retrieved from a memory file is no longer correct
|
||||
# or no longer relevant for the project.
|
||||
# * `edit_memory`: Replaces content matching a regular expression in a memory.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_file`: Finds files in the given relative paths
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
|
||||
# * `find_symbol`: Performs a global (or local) search using the language server backend.
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
|
||||
# for clients that do not read the initial instructions when the MCP server is connected.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
|
||||
# is relevant to the current task. You can infer whether the information
|
||||
# is relevant from the memory file name.
|
||||
# You should not read the same memory file multiple times in the same conversation.
|
||||
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
|
||||
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
|
||||
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
|
||||
# For JB, we use a separate tool.
|
||||
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
|
||||
# * `safe_delete_symbol`:
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||
# The memory name should be meaningful.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7247cc488921de2313a602697590502771a231bde7d03cd9ccac55a70971da45"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "72aadefca9851a8becdd2dcce54b646aff095b64cef9837f5e8e4ea8dde71d02"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
|
||||
}
|
||||
Generated
+5
-5
@@ -3701,7 +3701,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3722,7 +3722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3746,7 +3746,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3774,7 +3774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3785,7 +3785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -25,3 +25,11 @@ $remote = $files.ForEach({ "/etc/init.d/$_" })
|
||||
$remote | ForEach-Object {
|
||||
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\"
|
||||
}
|
||||
Regular → Executable
+19
-4
@@ -6,12 +6,27 @@ BIN="/root/.bin/wakey-agent"
|
||||
[ -n "$ACTION" ] || exit 0
|
||||
[ -n "$MACADDR" ] || exit 0
|
||||
|
||||
run_wakey_agent() {
|
||||
if [ -n "$WAKEY_AGENT_CONFIG" ]; then
|
||||
set -- --config "$WAKEY_AGENT_CONFIG" "$@"
|
||||
fi
|
||||
if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
|
||||
set -- -v "$@"
|
||||
fi
|
||||
# [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
|
||||
if command -v logger >/dev/null 2>&1; then
|
||||
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
|
||||
else
|
||||
"$BIN" "$@" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" || true
|
||||
elif [ -n "$IPADDR" ]; then
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
|
||||
elif [ -n "$HOSTNAME" ]; then
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" || true
|
||||
else
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" || true
|
||||
fi
|
||||
Regular → Executable
+19
-4
@@ -5,12 +5,27 @@ BIN="/root/.bin/wakey-agent"
|
||||
[ -n "$BIN" ] || exit 0
|
||||
[ -n "$ACTION" ] || exit 0
|
||||
|
||||
run_wakey_agent() {
|
||||
if [ -n "$WAKEY_AGENT_CONFIG" ]; then
|
||||
set -- --config "$WAKEY_AGENT_CONFIG" "$@"
|
||||
fi
|
||||
if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
|
||||
set -- -v "$@"
|
||||
fi
|
||||
# [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
|
||||
if command -v logger >/dev/null 2>&1; then
|
||||
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
|
||||
else
|
||||
"$BIN" "$@" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
|
||||
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
|
||||
elif [ -n "$MACADDR" ]; then
|
||||
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" || true
|
||||
elif [ -n "$IPADDR" ]; then
|
||||
"$BIN" observe neigh --action "$ACTION" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe neigh --action "$ACTION" --ip "$IPADDR" || true
|
||||
else
|
||||
"$BIN" observe neigh --action "$ACTION" >/dev/null 2>&1 || true
|
||||
run_wakey_agent observe neigh --action "$ACTION" || true
|
||||
fi
|
||||
@@ -104,8 +104,10 @@ main() {
|
||||
# Ensure execute bits on staged files we know should be executable
|
||||
for f in \
|
||||
"$STAGING/etc/init.d/"* \
|
||||
"$STAGING/etc/hotplug.d/"*/* \
|
||||
"$STAGING/etc/ldlda_help/"*.sh \
|
||||
"$STAGING/root/.bin/wakey" \
|
||||
"$STAGING/root/.bin/wakey-agent" \
|
||||
"$STAGING/root/.bin/kill_wakey.sh" \
|
||||
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do
|
||||
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
|
||||
@@ -114,11 +116,14 @@ main() {
|
||||
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
|
||||
for f in \
|
||||
"$STAGING/etc/init.d/"* \
|
||||
"$STAGING/etc/hotplug.d/"*/* \
|
||||
"$STAGING/etc/ldlda_help/"*.sh \
|
||||
"$STAGING/root/.bin/"*.sh; do
|
||||
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
|
||||
done
|
||||
|
||||
chown -R root:root "$STAGING" 2>/dev/null || true
|
||||
|
||||
# Copy staged tree into /
|
||||
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ if (Test-Path $hotplugSrc) {
|
||||
$content = Get-Content -Raw -LiteralPath $_.FullName
|
||||
$content = $content -replace "`r`n", "`n"
|
||||
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||
if (Get-Command chmod -ErrorAction SilentlyContinue) {
|
||||
chmod +x $dest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ if ($Publish) {
|
||||
}
|
||||
|
||||
if ($Tag -and $Version) {
|
||||
git tag -f "v$Version"
|
||||
git tag -f -a "v$Version" -m "Release $Version"
|
||||
git push -f origin "v$Version"
|
||||
Write-Host "Pushed tag: v$Version"
|
||||
|
||||
|
||||
@@ -4,21 +4,30 @@
|
||||
# then packaging the same bundle format and installing it.
|
||||
#
|
||||
# Simplest use:
|
||||
# sudo -E ./scripts/update_wakey_cc_from_repo.sh
|
||||
# ./scripts/update_wakey_cc_from_repo.sh
|
||||
#
|
||||
# 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_REF Git ref/branch/tag (default: main)
|
||||
# WAKEY_CC_TARGET Rust target triple (default from uname)
|
||||
# WAKEY_CC_ROOT Install root (default: current directory)
|
||||
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
|
||||
# WAKEY_CC_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_NO_RESTART If set, skip systemd restart
|
||||
# WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging
|
||||
# SUDO Privilege escalation command/path (default: sudo when needed)
|
||||
# SUDO Privilege escalation command/path for install steps (default: sudo)
|
||||
#
|
||||
# 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
|
||||
|
||||
log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; }
|
||||
@@ -64,14 +73,83 @@ sudo_cmd() {
|
||||
fail 'root privileges are required for install/restart steps; set SUDO or run as root'
|
||||
}
|
||||
|
||||
priv() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
$SUDO_BIN "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_label() {
|
||||
path=$1
|
||||
if command -v restorecon >/dev/null 2>&1; then
|
||||
priv restorecon "$path" || true
|
||||
fi
|
||||
}
|
||||
|
||||
chown_if_requested() {
|
||||
[ -n "${OWNER:-}" ] || return 0
|
||||
priv chown -R "$OWNER:$OWNER" "$@"
|
||||
}
|
||||
|
||||
chmod_user_rw_if_requested() {
|
||||
[ -n "${OWNER:-}" ] || return 0
|
||||
priv chmod -R u+rwX "$@"
|
||||
}
|
||||
|
||||
update_existing_repo() {
|
||||
repo_dir=$1
|
||||
ref=$2
|
||||
|
||||
[ -d "$repo_dir/.git" ] || fail "WAKEY_CC_REPO_DIR is not a git checkout: $repo_dir"
|
||||
|
||||
log "using existing checkout $repo_dir (ref: $ref)"
|
||||
git -C "$repo_dir" fetch --prune origin "$ref" || fail 'git fetch failed'
|
||||
git -C "$repo_dir" checkout "$ref" || fail "git checkout failed: $ref"
|
||||
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
||||
}
|
||||
|
||||
install_executable_force() {
|
||||
src=$1
|
||||
dst=$2
|
||||
dir=$(dirname "$dst")
|
||||
tmp="$dst.new.$$"
|
||||
old="$dst.old.$$"
|
||||
|
||||
priv mkdir -p "$dir"
|
||||
priv rm -f "$tmp"
|
||||
priv cp -f "$src" "$tmp"
|
||||
priv chmod 0755 "$tmp"
|
||||
chown_if_requested "$tmp"
|
||||
if [ -e "$dst" ]; then
|
||||
priv mv -f "$dst" "$old" || true
|
||||
fi
|
||||
priv mv -f "$tmp" "$dst"
|
||||
priv rm -f "$old"
|
||||
restore_label "$dst"
|
||||
}
|
||||
|
||||
main() {
|
||||
REPO_DIR="${WAKEY_CC_REPO_DIR:-}"
|
||||
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
|
||||
REF="${WAKEY_CC_REF:-main}"
|
||||
TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
|
||||
ROOT="${WAKEY_CC_ROOT:-$PWD}"
|
||||
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
|
||||
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)
|
||||
|
||||
if [ -n "$OWNER" ] && ! id "$OWNER" >/dev/null 2>&1; then
|
||||
fail "WAKEY_CC_USER does not exist: $OWNER"
|
||||
fi
|
||||
|
||||
require_cmd git
|
||||
require_cmd cargo
|
||||
require_cmd tar
|
||||
@@ -80,19 +158,34 @@ main() {
|
||||
TMPBASE="${TMPDIR:-/tmp}"
|
||||
WORKDIR="$TMPBASE/wakey-cc-src.$$"
|
||||
STAGING="$TMPBASE/wakey-cc-stage.$$"
|
||||
CLONED_WORKDIR=1
|
||||
|
||||
cleanup() {
|
||||
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
|
||||
rm -rf "$WORKDIR" "$STAGING"
|
||||
[ "$CLONED_WORKDIR" = "0" ] || rm -rf "$WORKDIR"
|
||||
rm -rf "$STAGING"
|
||||
else
|
||||
log "keeping temp dir: $WORKDIR"
|
||||
if [ "$CLONED_WORKDIR" != "0" ]; then
|
||||
chown_if_requested "$WORKDIR"
|
||||
chmod_user_rw_if_requested "$WORKDIR"
|
||||
log "keeping temp dir: $WORKDIR"
|
||||
fi
|
||||
chown_if_requested "$STAGING"
|
||||
chmod_user_rw_if_requested "$STAGING"
|
||||
log "keeping temp dir: $STAGING"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
log "cloning $REPO_URL (ref: $REF)"
|
||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||
fail 'git clone failed'
|
||||
if [ -n "$REPO_DIR" ]; then
|
||||
WORKDIR=$REPO_DIR
|
||||
CLONED_WORKDIR=0
|
||||
update_existing_repo "$WORKDIR" "$REF"
|
||||
else
|
||||
log "cloning $REPO_URL (ref: $REF)"
|
||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||
fail 'git clone failed'
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
@@ -124,26 +217,29 @@ main() {
|
||||
[ -f "$STAGING/bin/wakey-control-plane" ] || fail 'bundle missing bin/wakey-control-plane'
|
||||
[ -f "$STAGING/ui/dist/index.html" ] || fail 'bundle missing ui/dist/index.html'
|
||||
|
||||
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
||||
$SUDO_BIN mkdir -p "$ROOT"
|
||||
$SUDO_BIN cp -a "$STAGING/." "$ROOT/"
|
||||
else
|
||||
mkdir -p "$ROOT"
|
||||
cp -a "$STAGING/." "$ROOT/"
|
||||
fi
|
||||
priv mkdir -p "$ROOT"
|
||||
install_executable_force \
|
||||
"$STAGING/bin/wakey-control-plane" \
|
||||
"$ROOT/bin/wakey-control-plane"
|
||||
priv cp -a "$STAGING/ui" "$ROOT/"
|
||||
priv cp -a "$STAGING/scripts" "$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 systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
|
||||
log "restarting $SERVICE"
|
||||
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
||||
$SUDO_BIN systemctl daemon-reload
|
||||
$SUDO_BIN systemctl restart "$SERVICE"
|
||||
$SUDO_BIN systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
else
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$SERVICE"
|
||||
systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
fi
|
||||
priv systemctl daemon-reload
|
||||
priv systemctl restart "$SERVICE"
|
||||
priv systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
else
|
||||
log "service $SERVICE not installed; skipped restart"
|
||||
fi
|
||||
|
||||
+5
-5
@@ -46,7 +46,7 @@ pub struct LeasesArgs {
|
||||
)]
|
||||
pub include_state: bool,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
#[arg(long, short)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ pub struct WakeArgs {
|
||||
#[arg(long)]
|
||||
pub ip: Option<IpAddr>,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
#[arg(short, long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ pub struct InventoryArgs {
|
||||
#[arg(long = "mac")]
|
||||
pub macs: Vec<macaddr::MacAddr>,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
#[arg(short, long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
@@ -114,10 +114,10 @@ pub struct DevsArgs {
|
||||
/// Optional interface name to show.
|
||||
pub dev: Option<String>,
|
||||
/// Show only interfaces whose operstate is `up`.
|
||||
#[arg(long)]
|
||||
#[arg(short, long)]
|
||||
pub up: bool,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
#[arg(short, long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.3.1",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
Generated
+55
@@ -25,6 +25,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0([email protected])
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
@@ -40,6 +43,9 @@ importers:
|
||||
shadcn:
|
||||
specifier: ^4.2.0
|
||||
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:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
@@ -843,6 +849,7 @@ packages:
|
||||
}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -851,6 +858,7 @@ packages:
|
||||
}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -859,6 +867,7 @@ packages:
|
||||
}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -867,6 +876,7 @@ packages:
|
||||
}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -875,6 +885,7 @@ packages:
|
||||
}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -883,6 +894,7 @@ packages:
|
||||
}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -891,6 +903,7 @@ packages:
|
||||
}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -899,6 +912,7 @@ packages:
|
||||
}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -907,6 +921,7 @@ packages:
|
||||
}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -915,6 +930,7 @@ packages:
|
||||
}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -923,6 +939,7 @@ packages:
|
||||
}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -931,6 +948,7 @@ packages:
|
||||
}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -939,6 +957,7 @@ packages:
|
||||
}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@rollup/[email protected]":
|
||||
resolution:
|
||||
@@ -1060,6 +1079,7 @@ packages:
|
||||
engines: { node: ">= 20" }
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@tailwindcss/[email protected]":
|
||||
resolution:
|
||||
@@ -1069,6 +1089,7 @@ packages:
|
||||
engines: { node: ">= 20" }
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@tailwindcss/[email protected]":
|
||||
resolution:
|
||||
@@ -1078,6 +1099,7 @@ packages:
|
||||
engines: { node: ">= 20" }
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
"@tailwindcss/[email protected]":
|
||||
resolution:
|
||||
@@ -1087,6 +1109,7 @@ packages:
|
||||
engines: { node: ">= 20" }
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
"@tailwindcss/[email protected]":
|
||||
resolution:
|
||||
@@ -2365,6 +2388,7 @@ packages:
|
||||
engines: { node: ">= 12.0.0" }
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
@@ -2374,6 +2398,7 @@ packages:
|
||||
engines: { node: ">= 12.0.0" }
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
@@ -2383,6 +2408,7 @@ packages:
|
||||
engines: { node: ">= 12.0.0" }
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
@@ -2392,6 +2418,7 @@ packages:
|
||||
engines: { node: ">= 12.0.0" }
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
@@ -2581,6 +2608,15 @@ packages:
|
||||
}
|
||||
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]:
|
||||
resolution:
|
||||
{
|
||||
@@ -3086,6 +3122,15 @@ packages:
|
||||
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]:
|
||||
resolution:
|
||||
{
|
||||
@@ -4862,6 +4907,11 @@ snapshots:
|
||||
|
||||
[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]:
|
||||
@@ -5221,6 +5271,11 @@ snapshots:
|
||||
|
||||
[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]: {}
|
||||
|
||||
+27
-6
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
type Agent,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
revokeAgent,
|
||||
setAgentNickname,
|
||||
} from "@/api";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppLayout } from "@/layout/AppLayout";
|
||||
import { AgentsPage } from "@/pages/AgentsPage";
|
||||
import { AlertsPage } from "@/pages/AlertsPage";
|
||||
@@ -20,8 +22,9 @@ import { AuditPage } from "@/pages/AuditPage";
|
||||
import { CommandsPage } from "@/pages/CommandsPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
@@ -33,11 +36,9 @@ export function App() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
|
||||
const [state, setState] = useState<LoadState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function loadAll() {
|
||||
setState("loading");
|
||||
setError("");
|
||||
try {
|
||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||
await Promise.all([
|
||||
@@ -65,13 +66,16 @@ export function App() {
|
||||
setState("ready");
|
||||
} catch (err) {
|
||||
setState("error");
|
||||
setError(String(err));
|
||||
toast.error("Failed to load data", { description: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeAgent(agentId: string): Promise<boolean> {
|
||||
const result = await revokeAgent(agentId);
|
||||
await loadAll();
|
||||
if (result.revoked) {
|
||||
toast.success(`Revoked ${agentId}`);
|
||||
}
|
||||
return result.revoked;
|
||||
}
|
||||
|
||||
@@ -81,6 +85,11 @@ export function App() {
|
||||
): Promise<boolean> {
|
||||
const result = await setAgentNickname(agentId, nickname);
|
||||
await loadAll();
|
||||
if (result.updated) {
|
||||
toast.success(`Nickname updated for ${agentId}`, {
|
||||
description: nickname || "(cleared)",
|
||||
});
|
||||
}
|
||||
return result.updated;
|
||||
}
|
||||
|
||||
@@ -126,7 +135,6 @@ export function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <pre className="error">{error}</pre>}
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route
|
||||
@@ -140,6 +148,17 @@ export function App() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wake"
|
||||
element={
|
||||
<WakeToolsPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onAfterWake={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
@@ -147,12 +166,12 @@ export function App() {
|
||||
agents={agents}
|
||||
alerts={alerts}
|
||||
transitions={history}
|
||||
audit={audit}
|
||||
loading={state === "loading"}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="observations" element={<ObservationsPage />} />
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
@@ -196,9 +215,11 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
<Route path="observations" element={<ObservationsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+136
@@ -122,6 +122,63 @@ export type AgentDeviceObservationEvent = {
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type FleetDeviceAgent = {
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
connected: boolean;
|
||||
last_seen_unix: number;
|
||||
};
|
||||
|
||||
export type FleetWakeRoute = {
|
||||
route_id: string;
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
connected: boolean;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
source: string;
|
||||
last_seen_unix: number;
|
||||
wakeable: boolean;
|
||||
};
|
||||
|
||||
export type FleetDevice = {
|
||||
device_key: string;
|
||||
display_name: string;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
pinned: boolean;
|
||||
ips: string[];
|
||||
macs: string[];
|
||||
hostnames: string[];
|
||||
agents: FleetDeviceAgent[];
|
||||
sources: string[];
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
presence: string;
|
||||
route_candidates: FleetWakeRoute[];
|
||||
recommended_route: FleetWakeRoute | null;
|
||||
};
|
||||
|
||||
export type RefreshFleetDevicesResponse = {
|
||||
total_accepted: number;
|
||||
agents: {
|
||||
agent_id: string;
|
||||
status: string;
|
||||
accepted: number;
|
||||
error: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type WakeFleetDeviceResponse = {
|
||||
route: FleetWakeRoute;
|
||||
command: {
|
||||
request_id: string;
|
||||
status: string;
|
||||
result?: unknown;
|
||||
error?: { code: string; message: string; retryable?: boolean | null };
|
||||
};
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
@@ -208,6 +265,59 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
||||
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||
}
|
||||
|
||||
export function fetchFleetDevices(opts?: {
|
||||
query?: string;
|
||||
presence?: string;
|
||||
known?: string;
|
||||
agentId?: string;
|
||||
visibility?: "operator" | "all";
|
||||
limit?: number;
|
||||
}): Promise<FleetDevice[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.query) params.set("query", opts.query);
|
||||
if (opts?.presence && opts.presence !== "all") {
|
||||
params.set("presence", opts.presence);
|
||||
}
|
||||
if (opts?.known && opts.known !== "all") params.set("known", opts.known);
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
if (opts?.visibility) params.set("visibility", opts.visibility);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<FleetDevice[]>(
|
||||
`/api/v1/control/fleet/devices?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function refreshFleetDevices(input?: {
|
||||
agentIds?: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<RefreshFleetDevicesResponse> {
|
||||
return request<RefreshFleetDevicesResponse>(
|
||||
"/api/v1/control/fleet/devices/refresh",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
agent_ids: input?.agentIds ?? [],
|
||||
timeout_ms: input?.timeoutMs ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function wakeFleetDevice(input: {
|
||||
deviceKey: string;
|
||||
routeId?: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<WakeFleetDeviceResponse> {
|
||||
return request<WakeFleetDeviceResponse>("/api/v1/control/fleet/wake", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
device_key: input.deviceKey,
|
||||
route_id: input.routeId ?? null,
|
||||
timeout_ms: input.timeoutMs ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchObservations(opts?: {
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
@@ -270,6 +380,32 @@ export function attachObservationIdentifier(
|
||||
);
|
||||
}
|
||||
|
||||
export function attachDeviceIdentifier(
|
||||
deviceId: string,
|
||||
identifier: { kind: string; value: string },
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(identifier),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeKnownDevice(
|
||||
targetDeviceId: string,
|
||||
sourceDeviceId: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(targetDeviceId)}/merge`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source_device_id: sourceDeviceId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
agentId: string,
|
||||
kind: CommandKind,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Agent } from "@/api";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
value: string;
|
||||
onChange: (agentId: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function displayAgentLabel(agent: Agent): string {
|
||||
const nickname = agent.nickname?.trim();
|
||||
return nickname ? nickname : agent.agent_id;
|
||||
}
|
||||
|
||||
export function AgentSelector({
|
||||
agents,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: Props) {
|
||||
const selected = agents.find((a) => a.agent_id === value);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
if (v) onChange(v);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={className ?? "w-full min-w-0"}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-start">
|
||||
{selected ? (
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${selected.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{value
|
||||
? selected
|
||||
? displayAgentLabel(selected)
|
||||
: value
|
||||
: "Select agent"}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{selected.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem
|
||||
key={agent.agent_id}
|
||||
value={agent.agent_id}
|
||||
className="pe-10"
|
||||
disabled={!agent.connected}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{displayAgentLabel(agent)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -63,7 +63,7 @@ function DialogContent({
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 end-2"
|
||||
className="absolute top-2 inset-e-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
TriangleAlertIcon,
|
||||
OctagonXIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pe-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-inset-s-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-inset-e-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
+222
-72
@@ -1,96 +1,246 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const navItems = [
|
||||
["/", "Devices"],
|
||||
["/observations", "Observed"],
|
||||
["/commands", "Wake Tools"],
|
||||
["/dashboard", "Fleet Health"],
|
||||
["/agents", "Agents"],
|
||||
["/audit", "Audit"],
|
||||
["/alerts", "Alerts"],
|
||||
["/tokens", "Tokens"],
|
||||
] as const;
|
||||
const SIDEBAR_KEY = "wakey-sidebar-collapsed";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
type NavSection = {
|
||||
label: string;
|
||||
items: readonly {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
end?: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
const THEME_KEY = "wakey-ui-theme";
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{ to: "/", label: "Devices", icon: Monitor, end: true },
|
||||
{ to: "/wake", label: "Wake Tools", icon: Zap },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Monitoring",
|
||||
items: [
|
||||
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||
{ to: "/observations", label: "Observations", icon: Eye },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Admin",
|
||||
items: [
|
||||
{ to: "/agents", label: "Agents", icon: Bot },
|
||||
{ to: "/audit", label: "Audit", icon: FileText },
|
||||
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolveInitialTheme(): Theme {
|
||||
const stored = window.localStorage.getItem(THEME_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
function usePageTitle() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
const flat = navSections.flatMap((s) => s.items);
|
||||
const match = flat.find((item) =>
|
||||
item.end
|
||||
? pathname === "/ui" || pathname === "/ui/" || pathname === "/"
|
||||
: pathname.startsWith(item.to) && item.to !== "/",
|
||||
);
|
||||
document.title = match ? `${match.label} — Wakey` : "Wakey Operator UI";
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
window.localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
|
||||
}, [collapsed]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Wakey Operator UI
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Find devices fast and wake them reliably
|
||||
</p>
|
||||
<div className="app-shell">
|
||||
<aside
|
||||
className={[
|
||||
"sidebar",
|
||||
collapsed ? "sidebar--collapsed" : "",
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out",
|
||||
"md:sticky md:top-0 md:z-30 md:shadow-none",
|
||||
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-collapsed={collapsed}
|
||||
>
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-logo">
|
||||
<Zap className="size-5 text-primary" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-brand-text">
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
Wakey
|
||||
</span>
|
||||
<span className="text-[0.65rem] leading-none text-muted-foreground">
|
||||
Operator UI
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{navSections.map((section) => (
|
||||
<div key={section.label} className="sidebar-section">
|
||||
{!collapsed && (
|
||||
<div className="sidebar-section-label">{section.label}</div>
|
||||
)}
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const link = (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"sidebar-link",
|
||||
isActive ? "sidebar-link--active" : "",
|
||||
collapsed ? "sidebar-link--collapsed" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
>
|
||||
<Icon className="sidebar-link-icon" />
|
||||
{!collapsed && (
|
||||
<span className="sidebar-link-label">{item.label}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.to}>
|
||||
<TooltipTrigger>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return link;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="sidebar-footer-btn"
|
||||
aria-label={isDark ? "Switch to light" : "Switch to dark"}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Moon className="size-4" aria-hidden />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className="sidebar-link-label">
|
||||
{isDark ? "Light mode" : "Dark mode"}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{isDark ? "Switch to light" : "Switch to dark"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="sidebar-footer-btn"
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeft className="size-4" aria-hidden />
|
||||
) : (
|
||||
<PanelLeftClose className="size-4" aria-hidden />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className="sidebar-link-label">Collapse</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{collapsed ? "Expand" : "Collapse"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile backdrop */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||
onClick={() => setCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile floating button when sidebar is collapsed/hidden */}
|
||||
{collapsed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="fixed right-3 top-3 z-50 size-9 rounded-full p-0 sm:static sm:size-auto sm:rounded-md sm:px-3 sm:py-2"
|
||||
aria-label={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
||||
title={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
||||
onClick={() =>
|
||||
setTheme((current) => (current === "dark" ? "light" : "dark"))
|
||||
}
|
||||
className="fixed bottom-3 left-3 z-40 md:hidden"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setCollapsed(false)}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Moon className="size-4" aria-hidden />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{theme === "dark" ? "Switch to light" : "Switch to dark"}
|
||||
</span>
|
||||
<PanelLeft className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<nav className="mb-3 flex flex-wrap gap-2">
|
||||
{navItems.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "border-primary/60 bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<main className="app-main @container">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+12
-1
@@ -1,13 +1,24 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { App } from "@/App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename="/ui">
|
||||
<App />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
storageKey="wakey-ui-theme"
|
||||
enableSystem
|
||||
>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
+73
-70
@@ -2,7 +2,11 @@ import type { Agent } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Bot, Inbox } from "lucide-react";
|
||||
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -23,29 +27,17 @@ export function AgentsPage({
|
||||
onSetAgentNickname,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
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) {
|
||||
if (!window.confirm(`Revoke agent credentials for ${agentId}?`)) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const revoked = await onRevokeAgent(agentId);
|
||||
setStatus(
|
||||
revoked ? `Revoked ${agentId}` : `${agentId} was already absent`,
|
||||
);
|
||||
await onRevokeAgent(agentId);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Revoke failed", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -54,17 +46,10 @@ export function AgentsPage({
|
||||
async function onSaveNickname(agent: Agent) {
|
||||
const draft = drafts[agent.agent_id] ?? agent.nickname ?? "";
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const updated = await onSetAgentNickname(agent.agent_id, draft);
|
||||
setStatus(
|
||||
updated
|
||||
? `Updated nickname for ${agent.agent_id}`
|
||||
: `${agent.agent_id} not found`,
|
||||
);
|
||||
await onSetAgentNickname(agent.agent_id, draft);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to update nickname", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -73,29 +58,61 @@ export function AgentsPage({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="size-5 text-primary" />
|
||||
Agents
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2">
|
||||
{agents.map((agent) => (
|
||||
<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}
|
||||
>
|
||||
<Button
|
||||
variant={
|
||||
selectedAgentId === agent.agent_id ? "secondary" : "outline"
|
||||
}
|
||||
className="flex h-auto flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
|
||||
onClick={() => onSelectAgent(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="min-w-0 truncate">{displayLabel(agent)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="grid min-w-52 gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-1 text-left"
|
||||
onClick={() => onSelectAgent(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${agent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{displayAgentLabel(agent)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={agent.connected ? "secondary" : "outline"}
|
||||
className="text-[0.6rem] px-1.5 py-0"
|
||||
>
|
||||
{agent.connected ? "connected" : "offline"}
|
||||
</Badge>
|
||||
</span>
|
||||
<code className="text-[0.65rem] text-muted-foreground/70 font-mono truncate max-w-full">
|
||||
{agent.agent_id}
|
||||
</code>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void onRevoke(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={drafts[agent.agent_id] ?? agent.nickname ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -106,45 +123,31 @@ export function AgentsPage({
|
||||
}
|
||||
placeholder="nickname (optional)"
|
||||
disabled={busy}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onSaveNickname(agent)}
|
||||
disabled={busy}
|
||||
>
|
||||
Save Name
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void onRevoke(agent.agent_id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onSaveNickname(agent)}
|
||||
disabled={busy}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!agents.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No agents enrolled
|
||||
<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
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Issue a token and enroll an agent to get started
|
||||
</p>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 {
|
||||
Select,
|
||||
@@ -11,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentSelector } from "@/components/AgentSelector";
|
||||
import { runCommand, type Agent, type CommandKind } from "@/api";
|
||||
|
||||
type Props = {
|
||||
@@ -26,11 +35,6 @@ export function CommandsPage({
|
||||
onSelectAgent,
|
||||
onAfterCommand,
|
||||
}: Props) {
|
||||
function displayAgentLabel(agent: Agent): string {
|
||||
const nickname = agent.nickname?.trim();
|
||||
return nickname ? nickname : agent.agent_id;
|
||||
}
|
||||
|
||||
const [kind, setKind] = useState<CommandKind>("devs");
|
||||
const [query, setQuery] = useState("");
|
||||
const [output, setOutput] = useState("Select agent and run a command");
|
||||
@@ -44,9 +48,11 @@ export function CommandsPage({
|
||||
try {
|
||||
const out = await runCommand(selectedAgentId, kind, query.trim());
|
||||
setOutput(JSON.stringify(out, null, 2));
|
||||
toast.success(`Command "${kind}" completed`);
|
||||
if (onAfterCommand) await onAfterCommand();
|
||||
} catch (err) {
|
||||
setOutput(String(err));
|
||||
toast.error("Command failed", { description: String(err) });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
@@ -55,44 +61,23 @@ export function CommandsPage({
|
||||
return (
|
||||
<Card>
|
||||
<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>
|
||||
<CardContent>
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
value={selectedAgentId}
|
||||
onValueChange={(value) => {
|
||||
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>
|
||||
onChange={onSelectAgent}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
|
||||
+253
-20
@@ -1,4 +1,15 @@
|
||||
import type { Agent, Alert, AlertTransition } from "@/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, Bell, Bot, Key, RefreshCw, ArrowRight } from "lucide-react";
|
||||
|
||||
import {
|
||||
type Agent,
|
||||
type Alert,
|
||||
type AlertTransition,
|
||||
type AuditEvent,
|
||||
type EnrollTokenStatus,
|
||||
fetchEnrollTokens,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,66 +18,288 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
alerts: Alert[];
|
||||
transitions: AlertTransition[];
|
||||
audit: AuditEvent[];
|
||||
loading: boolean;
|
||||
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({
|
||||
agents,
|
||||
alerts,
|
||||
transitions,
|
||||
audit,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const connected = agents.filter((a) => a.connected).length;
|
||||
const total = agents.length;
|
||||
|
||||
const severityBuckets = alerts.reduce<Record<string, number>>((acc, a) => {
|
||||
acc[a.severity] = (acc[a.severity] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||
useEffect(() => {
|
||||
void fetchEnrollTokens()
|
||||
.then(setTokens)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const activeTokens = tokens.filter((t) => !t.expired).length;
|
||||
|
||||
const recentAudit = audit.slice(0, 8);
|
||||
|
||||
if (loading && !agents.length) {
|
||||
return (
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="mt-2 h-8 w-12" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="sm:col-span-2 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Fleet Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Agents</CardDescription>
|
||||
<CardTitle className="text-2xl">{agents.length}</CardTitle>
|
||||
<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="pb-2">
|
||||
<CardDescription>Connected</CardDescription>
|
||||
<CardTitle className="text-2xl">{connected}</CardTitle>
|
||||
<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="pb-2">
|
||||
<CardDescription>Active Alerts</CardDescription>
|
||||
<CardTitle className="text-2xl">{alerts.length}</CardTitle>
|
||||
<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="pb-2">
|
||||
<CardDescription>Transitions</CardDescription>
|
||||
<CardTitle className="text-2xl">{transitions.length}</CardTitle>
|
||||
<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">
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<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"
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use tabs to run commands, inspect audits, and monitor alerts in real
|
||||
time.
|
||||
</p>
|
||||
{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>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
+355
-457
@@ -1,9 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { runCommand, type Agent } from "@/api";
|
||||
import {
|
||||
fetchFleetDevices,
|
||||
fetchKnownDevices,
|
||||
refreshFleetDevices,
|
||||
wakeFleetDevice,
|
||||
type Agent,
|
||||
type FleetDevice,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -11,26 +27,24 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
|
||||
import { DeviceDetailsDialog } from "@/pages/devices/DeviceDetailsDialog";
|
||||
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
|
||||
import type {
|
||||
DeviceRow,
|
||||
PresenceFilter,
|
||||
SortDir,
|
||||
WakeEvent,
|
||||
} from "@/pages/devices/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
chooseWakeTarget,
|
||||
loadHistory,
|
||||
parseInventoryRows,
|
||||
parseWakeSummary,
|
||||
PRESENCE_FILTERS,
|
||||
saveHistory,
|
||||
sorters,
|
||||
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/devices/utils";
|
||||
import "@/pages/devices/devices.css";
|
||||
} from "@/pages/fleet/utils";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -39,501 +53,385 @@ type Props = {
|
||||
onAfterWake?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function DevicesPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
onAfterWake,
|
||||
}: Props) {
|
||||
function displayAgentLabel(agent: Agent): string {
|
||||
const nickname = agent.nickname?.trim();
|
||||
return nickname ? nickname : agent.agent_id;
|
||||
}
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agent_id === selectedAgentId,
|
||||
);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Parse sort state from URL
|
||||
function getSortFromUrl(): { key: SortKey; dir: SortDir } {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const key = params.get("sort") as SortKey;
|
||||
const dir = params.get("dir") as SortDir;
|
||||
if (key && dir && key in sorters && (dir === "asc" || dir === "desc")) {
|
||||
return { key, dir };
|
||||
}
|
||||
return { key: "name", dir: "asc" };
|
||||
}
|
||||
|
||||
function getPresenceFromUrl(): PresenceFilter {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const raw = params.get("presence");
|
||||
return PRESENCE_FILTERS.includes(raw as PresenceFilter)
|
||||
? (raw as PresenceFilter)
|
||||
: "all";
|
||||
}
|
||||
|
||||
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(
|
||||
getSortFromUrl(),
|
||||
);
|
||||
const [presenceFilter, setPresenceFilter] =
|
||||
useState<PresenceFilter>(getPresenceFromUrl());
|
||||
|
||||
// Keep view state in sync with URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("sort", sort.key);
|
||||
params.set("dir", sort.dir);
|
||||
if (presenceFilter === "all") {
|
||||
params.delete("presence");
|
||||
} else {
|
||||
params.set("presence", presenceFilter);
|
||||
}
|
||||
const nextSearch = params.toString();
|
||||
if (nextSearch !== location.search.slice(1)) {
|
||||
navigate({ search: nextSearch }, { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sort.key, sort.dir, presenceFilter, location.search]);
|
||||
|
||||
// Update state if URL changes (e.g., back/forward nav)
|
||||
useEffect(() => {
|
||||
setSort(getSortFromUrl());
|
||||
setPresenceFilter(getPresenceFromUrl());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
const [rows, setRows] = useState<DeviceRow[]>([]);
|
||||
export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [presence, setPresence] = useState("all");
|
||||
const [known, setKnown] = useState<KnownFilter>("all");
|
||||
const [agentId, setAgentId] = useState("all");
|
||||
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({
|
||||
key: "last_seen",
|
||||
dir: "desc",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [wakeBusyId, setWakeBusyId] = useState("");
|
||||
const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
|
||||
const [quickWake, setQuickWake] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [detailsDevice, setDetailsDevice] = useState<DeviceRow | null>(null);
|
||||
const [copyStatus, setCopyStatus] = useState("");
|
||||
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
||||
function appendWakeEvent(event: WakeEvent) {
|
||||
setRecentWakes((prev) => {
|
||||
const next = [event, ...prev].slice(0, 20);
|
||||
saveHistory(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRecentWakes(loadHistory());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgentId) {
|
||||
const firstConnected = agents.find((agent) => agent.connected);
|
||||
if (firstConnected) onSelectAgent(firstConnected.agent_id);
|
||||
}
|
||||
}, [selectedAgentId, agents, onSelectAgent]);
|
||||
|
||||
async function loadInventory() {
|
||||
if (!selectedAgentId) return;
|
||||
async function loadFleet() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await runCommand(selectedAgentId, "inventory", "");
|
||||
if (!response || typeof response !== "object") {
|
||||
setRows([]);
|
||||
} else {
|
||||
const envelope = response as Record<string, unknown>;
|
||||
setRows(parseInventoryRows(envelope.result));
|
||||
}
|
||||
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||
fetchFleetDevices({
|
||||
query,
|
||||
presence,
|
||||
known,
|
||||
agentId: agentId === "all" ? "" : agentId,
|
||||
limit: 500,
|
||||
}),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
setDevices(nextDevices);
|
||||
setKnownDevices(nextKnownDevices);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
setRows([]);
|
||||
toast.error("Failed to load fleet", { description: String(err) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wakeDevice(device: DeviceRow) {
|
||||
const target = chooseWakeTarget(device);
|
||||
await wakeTarget(target, device.id);
|
||||
}
|
||||
|
||||
async function wakeTarget(
|
||||
target: string,
|
||||
busyId: string,
|
||||
opts: { refresh: boolean; notify: boolean } = {
|
||||
refresh: true,
|
||||
notify: true,
|
||||
},
|
||||
) {
|
||||
if (!target || !selectedAgentId) return;
|
||||
setWakeBusyId(busyId);
|
||||
async function refreshAllConnected() {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const response = await runCommand(selectedAgentId, "wake", target);
|
||||
const summary = parseWakeSummary(response);
|
||||
appendWakeEvent({
|
||||
ts: Date.now(),
|
||||
target,
|
||||
agentId: selectedAgentId,
|
||||
outcome: summary.outcome,
|
||||
requestId: summary.requestId,
|
||||
detail: summary.detail,
|
||||
});
|
||||
if (opts.refresh) await loadInventory();
|
||||
if (opts.notify && onAfterWake) await onAfterWake();
|
||||
const result = await refreshFleetDevices();
|
||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||
const msg = failed.length
|
||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
||||
toast.success(msg);
|
||||
await loadFleet();
|
||||
} catch (err) {
|
||||
appendWakeEvent({
|
||||
ts: Date.now(),
|
||||
target,
|
||||
agentId: selectedAgentId,
|
||||
outcome: "error",
|
||||
requestId: "",
|
||||
detail: String(err),
|
||||
});
|
||||
toast.error("Fleet refresh failed", { description: String(err) });
|
||||
} finally {
|
||||
setWakeBusyId("");
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wakeSelectedDevices() {
|
||||
if (!selectedAgentId || bulkWakeBusy) return;
|
||||
const selectedRows = filtered.filter((row) => selectedIds.includes(row.id));
|
||||
if (!selectedRows.length) return;
|
||||
|
||||
setBulkWakeBusy(true);
|
||||
async function wakeDevice(device: FleetDevice, routeId?: string | null) {
|
||||
if (!device.recommended_route && !routeId) return;
|
||||
setWakeBusyKey(device.device_key);
|
||||
try {
|
||||
for (const row of selectedRows) {
|
||||
const target = chooseWakeTarget(row);
|
||||
if (!target) continue;
|
||||
await wakeTarget(target, `bulk:${row.id}`, {
|
||||
refresh: false,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
await loadInventory();
|
||||
const result = await wakeFleetDevice({
|
||||
deviceKey: device.device_key,
|
||||
routeId,
|
||||
});
|
||||
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
||||
toast.success(msg);
|
||||
if (onAfterWake) await onAfterWake();
|
||||
await loadFleet();
|
||||
} catch (err) {
|
||||
toast.error("Wake failed", { description: String(err) });
|
||||
} finally {
|
||||
setBulkWakeBusy(false);
|
||||
setWakeBusyId("");
|
||||
setWakeBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyValue(label: string, value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
async function copyWithFallback(text: string) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) throw new Error("fallback-copy-failed");
|
||||
}
|
||||
|
||||
const text = value.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(trimmed);
|
||||
} else {
|
||||
await copyWithFallback(trimmed);
|
||||
}
|
||||
setCopyStatus(`Copied ${label}`);
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`Copied ${label}`);
|
||||
} catch {
|
||||
try {
|
||||
await copyWithFallback(trimmed);
|
||||
setCopyStatus(`Copied ${label}`);
|
||||
} catch {
|
||||
setCopyStatus(`Copy failed for ${label}`);
|
||||
}
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.style.position = "fixed";
|
||||
area.style.opacity = "0";
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(area);
|
||||
toast.success(`Copied ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadInventory();
|
||||
}, [selectedAgentId]);
|
||||
void loadFleet();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presence, known, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.filter((id) => rows.some((row) => row.id === id)),
|
||||
);
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copyStatus) return;
|
||||
const timer = window.setTimeout(() => setCopyStatus(""), 1800);
|
||||
const timer = window.setTimeout(() => void loadFleet(), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copyStatus]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
let result = rows;
|
||||
if (presenceFilter !== "all") {
|
||||
result = result.filter((row) => row.presence === presenceFilter);
|
||||
}
|
||||
if (q) {
|
||||
result = result.filter(
|
||||
(row) =>
|
||||
row.name.toLowerCase().includes(q) ||
|
||||
row.presence.toLowerCase().includes(q) ||
|
||||
row.ips.some((v) => v.toLowerCase().includes(q)) ||
|
||||
row.macs.some((v) => v.toLowerCase().includes(q)) ||
|
||||
row.interfaces.some((v) => v.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
// Sort
|
||||
if (sort.key === "name") {
|
||||
result = [...result].sort((a, b) => {
|
||||
if (a.isUnnamed !== b.isUnnamed) return a.isUnnamed ? 1 : -1;
|
||||
const cmp = a.name.localeCompare(b.name);
|
||||
return sort.dir === "desc" ? -cmp : cmp;
|
||||
});
|
||||
} else {
|
||||
const sorter = sorters[sort.key];
|
||||
result = [...result].sort(sorter);
|
||||
if (sort.dir === "desc") result.reverse();
|
||||
}
|
||||
return result;
|
||||
}, [rows, query, sort, presenceFilter]);
|
||||
|
||||
const selectedVisibleCount = filtered.filter((row) =>
|
||||
selectedIds.includes(row.id),
|
||||
).length;
|
||||
const allVisibleSelected =
|
||||
filtered.length > 0 && selectedVisibleCount === filtered.length;
|
||||
|
||||
function toggleRowSelection(id: string, checked: boolean) {
|
||||
setSelectedIds((prev) => {
|
||||
if (checked) return prev.includes(id) ? prev : [...prev, id];
|
||||
return prev.filter((v) => v !== id);
|
||||
const sortedDevices = useMemo(() => {
|
||||
const rows = [...devices];
|
||||
rows.sort((a, b) => {
|
||||
const dir = sort.dir === "asc" ? 1 : -1;
|
||||
if (sort.key === "presence") {
|
||||
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
|
||||
}
|
||||
if (sort.key === "last_seen") {
|
||||
return dir * ((a.last_seen_unix ?? 0) - (b.last_seen_unix ?? 0));
|
||||
}
|
||||
if (sort.key === "agents")
|
||||
return dir * (a.agents.length - b.agents.length);
|
||||
if (sort.key === "ip") {
|
||||
return dir * (a.ips[0] ?? "").localeCompare(b.ips[0] ?? "");
|
||||
}
|
||||
if (sort.key === "mac") {
|
||||
return dir * (a.macs[0] ?? "").localeCompare(b.macs[0] ?? "");
|
||||
}
|
||||
return dir * a.display_name.localeCompare(b.display_name);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [devices, sort]);
|
||||
|
||||
function toggleAllVisible(checked: boolean) {
|
||||
if (!checked) {
|
||||
setSelectedIds((prev) =>
|
||||
prev.filter((id) => !filtered.some((row) => row.id === id)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const row of filtered) next.add(row.id);
|
||||
return Array.from(next);
|
||||
});
|
||||
}
|
||||
const connectedCount = agents.filter((agent) => agent.connected).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(20rem,1fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Devices</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadInventory()}
|
||||
disabled={loading || !selectedAgentId}
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
<section className="grid gap-3">
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Devices</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{devices.length} fleet rows from stored observations and known
|
||||
devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadFleet()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{loading ? "Refreshing" : "Refresh view"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void refreshAllConnected()}
|
||||
disabled={refreshing || connectedCount === 0}
|
||||
>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{refreshing
|
||||
? "Collecting"
|
||||
: `Collect fleet (${connectedCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="mt-2 grid gap-2 md:grid-cols-3">
|
||||
<label className="grid min-w-0 gap-1 text-sm text-muted-foreground">
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Search</span>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="name, ip, mac, agent"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<FilterSelect
|
||||
label="Presence"
|
||||
value={presence}
|
||||
values={presenceFilters}
|
||||
onChange={setPresence}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="Identity"
|
||||
value={known}
|
||||
values={knownFilters}
|
||||
onChange={(value) => setKnown(value as KnownFilter)}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
value={selectedAgentId}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value) onSelectAgent(value);
|
||||
}}
|
||||
value={agentId}
|
||||
onValueChange={(value) => setAgentId(value ?? "all")}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 text-start">
|
||||
{selectedAgent ? (
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${selectedAgent.connected ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedAgentId
|
||||
? selectedAgent
|
||||
? displayAgentLabel(selectedAgent)
|
||||
: selectedAgentId
|
||||
: "Select connected agent"}
|
||||
</span>
|
||||
{selectedAgent ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{selectedAgent.connected ? "connected" : "offline"}
|
||||
</span>
|
||||
) : null}
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{agentId === "all"
|
||||
? "all agents"
|
||||
: agentLabel(
|
||||
agentId,
|
||||
agents.find((agent) => agent.agent_id === agentId)
|
||||
?.nickname,
|
||||
)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectItem value="all">all agents</SelectItem>
|
||||
{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 key={agent.agent_id} value={agent.agent_id}>
|
||||
{agentLabel(agent.agent_id, agent.nickname)}
|
||||
{agent.connected ? " · connected" : " · offline"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid min-w-0 gap-1 text-sm text-muted-foreground md:col-span-2">
|
||||
<span>Search</span>
|
||||
<Input
|
||||
className="w-full min-w-0"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="name, ip, mac, interface"
|
||||
/>
|
||||
</label>
|
||||
<FilterSelect
|
||||
label="Sort"
|
||||
value={`${sort.key}:${sort.dir}`}
|
||||
values={[
|
||||
"last_seen:desc",
|
||||
"name:asc",
|
||||
"presence:desc",
|
||||
"agents:desc",
|
||||
"ip:asc",
|
||||
"mac:asc",
|
||||
]}
|
||||
onChange={(value) => {
|
||||
const [key, dir] = value.split(":") as [SortKey, SortDir];
|
||||
setSort({ key, dir });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="quick-wake">
|
||||
<label>
|
||||
Quick Wake (name, IP, or MAC)
|
||||
<Input
|
||||
value={quickWake}
|
||||
onChange={(e) => setQuickWake(e.target.value)}
|
||||
placeholder="bedroom-pc or aa:bb:cc:dd:ee:ff"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
onClick={() => void wakeTarget(quickWake.trim(), "quick")}
|
||||
disabled={
|
||||
!selectedAgentId || !quickWake.trim() || wakeBusyId === "quick"
|
||||
}
|
||||
>
|
||||
{wakeBusyId === "quick" ? "Waking..." : "Wake target"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="presence-filters"
|
||||
role="group"
|
||||
aria-label="Filter by presence"
|
||||
>
|
||||
{PRESENCE_FILTERS.map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={presenceFilter === value ? "secondary" : "outline"}
|
||||
size="xs"
|
||||
onClick={() => setPresenceFilter(value)}
|
||||
type="button"
|
||||
>
|
||||
{value === "all" ? "all" : value.replace("_", " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bulk-actions">
|
||||
<label className="select-visible">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={(e) => toggleAllVisible(e.target.checked)}
|
||||
disabled={!filtered.length}
|
||||
/>
|
||||
Select visible
|
||||
</label>
|
||||
<Button
|
||||
onClick={() => void wakeSelectedDevices()}
|
||||
disabled={
|
||||
!selectedVisibleCount || !selectedAgentId || bulkWakeBusy
|
||||
}
|
||||
type="button"
|
||||
size="sm"
|
||||
>
|
||||
{bulkWakeBusy
|
||||
? "Waking selected..."
|
||||
: `Wake selected (${selectedVisibleCount})`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={!selectedIds.length}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
{copyStatus && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{copyStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filtered.length} of {rows.length}
|
||||
</p>
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
{/* Skeleton loading */}
|
||||
{loading && !devices.length ? (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<span>Device</span>
|
||||
<span>Presence</span>
|
||||
<span>IPs</span>
|
||||
<span>MACs</span>
|
||||
<span>Agents</span>
|
||||
<span>Last seen</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
{sortedDevices.map((device) => (
|
||||
<FleetDeviceRow
|
||||
key={device.device_key}
|
||||
device={device}
|
||||
busy={wakeBusyKey === device.device_key}
|
||||
onWake={() => void wakeDevice(device)}
|
||||
onDetails={() => setDetails(device)}
|
||||
onCopy={(label, value) => void copyValue(label, value)}
|
||||
/>
|
||||
))}
|
||||
{!sortedDevices.length && (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-card py-12 text-center">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<DeviceInventoryTable
|
||||
rows={filtered}
|
||||
selectedIds={selectedIds}
|
||||
sort={sort}
|
||||
allVisibleSelected={allVisibleSelected}
|
||||
wakeBusyId={wakeBusyId}
|
||||
selectedAgentId={selectedAgentId}
|
||||
bulkWakeBusy={bulkWakeBusy}
|
||||
onToggleAllVisible={toggleAllVisible}
|
||||
onToggleRow={toggleRowSelection}
|
||||
onSortChange={setSort}
|
||||
onWakeDevice={(device) => void wakeDevice(device)}
|
||||
onCopyValue={(label, value) => void copyValue(label, value)}
|
||||
onOpenDetails={setDetailsDevice}
|
||||
/>
|
||||
<DeviceDetailsDialog
|
||||
device={detailsDevice}
|
||||
open={Boolean(detailsDevice)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailsDevice(null);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WakeHistoryCard
|
||||
events={recentWakes}
|
||||
onClear={() => {
|
||||
setRecentWakes([]);
|
||||
saveHistory([]);
|
||||
<FleetDeviceDetailsDialog
|
||||
device={details}
|
||||
knownDevices={knownDevices}
|
||||
open={Boolean(details)}
|
||||
busy={wakeBusyKey === details?.device_key}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetails(null);
|
||||
}}
|
||||
onWake={(routeId) => {
|
||||
if (details) void wakeDevice(details, routeId);
|
||||
}}
|
||||
onCopy={(label, value) => void copyValue(label, value)}
|
||||
onChanged={() => void loadFleet()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FleetDeviceRow({
|
||||
device,
|
||||
busy,
|
||||
onWake,
|
||||
onDetails,
|
||||
onCopy,
|
||||
}: {
|
||||
device: FleetDevice;
|
||||
busy: boolean;
|
||||
onWake: () => void;
|
||||
onDetails: () => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-card px-3 py-3 text-sm xl:grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] xl:items-center xl:gap-2">
|
||||
<button className="min-w-0 text-left" type="button" onClick={onDetails}>
|
||||
<div className="truncate font-medium">{device.display_name}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{device.known_device ? (
|
||||
<Badge variant="secondary">known</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">unknown</Badge>
|
||||
)}
|
||||
{device.pinned && <Badge variant="outline">pinned</Badge>}
|
||||
{device.sources.map((source) => (
|
||||
<Badge key={source} variant="outline">
|
||||
{source}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
<span>
|
||||
<PresenceBadge presence={device.presence} />
|
||||
</span>
|
||||
<span className="min-w-0 text-muted-foreground xl:truncate">
|
||||
<MobileLabel label="IPs" />
|
||||
{summarize(device.ips)}
|
||||
</span>
|
||||
<span className="min-w-0 text-muted-foreground xl:truncate">
|
||||
<MobileLabel label="MACs" />
|
||||
{summarize(device.macs)}
|
||||
</span>
|
||||
<span className="min-w-0 text-muted-foreground xl:truncate">
|
||||
<MobileLabel label="Agents" />
|
||||
{summarize(
|
||||
device.agents.map((agent) =>
|
||||
agentLabel(agent.agent_id, agent.nickname),
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<MobileLabel label="Last seen" />
|
||||
{formatSeen(device.last_seen_unix)}
|
||||
</span>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onWake}
|
||||
disabled={busy || !device.recommended_route}
|
||||
>
|
||||
<Zap className="size-4" aria-hidden />
|
||||
{busy ? "Waking" : "Wake"}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex size-9 items-center justify-center rounded-md border bg-background">
|
||||
<MoreHorizontal className="size-4" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={onDetails}>Details</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopy("IP", device.ips[0] ?? "")}
|
||||
data-disabled={!device.ips.length}
|
||||
>
|
||||
Copy first IP
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopy("MAC", device.macs[0] ?? "")}
|
||||
data-disabled={!device.macs.length}
|
||||
>
|
||||
Copy first MAC
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+170
-57
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Key, Copy, Inbox } from "lucide-react";
|
||||
|
||||
import {
|
||||
type EnrollTokenStatus,
|
||||
@@ -8,19 +10,36 @@ import {
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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";
|
||||
|
||||
function formatUnix(unix: number): string {
|
||||
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() {
|
||||
const [tokens, setTokens] = useState<EnrollTokenStatus[]>([]);
|
||||
const [ttlSeconds, setTtlSeconds] = useState("86400");
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [ttlSeconds, setTtlSeconds] = useState(86400);
|
||||
const [customTtl, setCustomTtl] = useState("");
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastIssued, setLastIssued] = useState<{
|
||||
token: string;
|
||||
expires: string;
|
||||
} | null>(null);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => tokens.filter((t) => !t.expired).length,
|
||||
@@ -29,12 +48,11 @@ export function TokensPage() {
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await fetchEnrollTokens();
|
||||
setTokens(next);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to load tokens", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -42,17 +60,23 @@ export function TokensPage() {
|
||||
|
||||
async function onIssue() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const ttl = Number.parseInt(ttlSeconds, 10);
|
||||
const issued = await issueEnrollToken(Number.isFinite(ttl) ? ttl : 86400);
|
||||
setStatus(
|
||||
`Issued ${issued.enroll_token} (expires ${formatUnix(issued.expires_at_unix)})`,
|
||||
const effectiveTtl = useCustom
|
||||
? Number.parseInt(customTtl, 10) || 86400
|
||||
: ttlSeconds;
|
||||
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();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to issue token", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -60,30 +84,50 @@ export function TokensPage() {
|
||||
|
||||
async function onRevoke(token: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const result = await revokeEnrollToken(token);
|
||||
setStatus(
|
||||
result.revoked ? `Revoked ${token}` : `${token} was already absent`,
|
||||
);
|
||||
if (result.revoked) {
|
||||
toast.success(`Revoked ${token}`);
|
||||
} else {
|
||||
toast.info(`${token} was already absent`);
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
toast.error("Failed to revoke token", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const controlPlaneUrl =
|
||||
window.location.origin.replace(/:\d+$/, "") || "https://cp.example.com";
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -94,17 +138,16 @@ export function TokensPage() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCount} active, {tokens.length} shown
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||
key={token.enroll_token}
|
||||
>
|
||||
<div>
|
||||
<strong>{token.enroll_token}</strong>
|
||||
<div className="min-w-0">
|
||||
<strong className="block truncate text-sm">
|
||||
{token.enroll_token}
|
||||
</strong>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
expires: {formatUnix(token.expires_at_unix)}
|
||||
</div>
|
||||
@@ -125,43 +168,113 @@ export function TokensPage() {
|
||||
</div>
|
||||
))}
|
||||
{!tokens.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No tokens found
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Issue Token</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>TTL seconds</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ttlSeconds}
|
||||
onChange={(e) => setTtlSeconds(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||
Issue
|
||||
</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>
|
||||
</Card>
|
||||
<div className="grid gap-4 content-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Issue Token</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new enrollment token for agents
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div>
|
||||
<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
|
||||
type="number"
|
||||
min={1}
|
||||
value={customTtl}
|
||||
onChange={(e) => setCustomTtl(e.target.value)}
|
||||
placeholder="86400"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => void onIssue()} disabled={busy}>
|
||||
Issue Token
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Install command after issuing */}
|
||||
{lastIssued && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Agent Installation</CardTitle>
|
||||
<CardDescription>
|
||||
Token: <code className="text-xs">{lastIssued.token}</code>
|
||||
<br />
|
||||
Expires: {lastIssued.expires}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run this on the target device to enroll:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="overflow-auto rounded-md border bg-muted/60 p-3 pr-10 font-mono text-xs">
|
||||
{`wakey-agent enroll \\
|
||||
--server-url ${controlPlaneUrl} \\
|
||||
--token ${lastIssued.token}`}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1.5 top-1.5 size-7 p-0"
|
||||
onClick={() =>
|
||||
void copyToClipboard(
|
||||
`wakey-agent enroll --server-url ${controlPlaneUrl} --token ${lastIssued.token}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Zap, Inbox } from "lucide-react";
|
||||
|
||||
import { runCommand, type Agent } from "@/api";
|
||||
import { AgentSelector } from "@/components/AgentSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type WakeEvent = {
|
||||
ts: number;
|
||||
target: string;
|
||||
agentId: string;
|
||||
outcome: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onAfterWake?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function WakeToolsPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
onAfterWake,
|
||||
}: Props) {
|
||||
const [target, setTarget] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
|
||||
|
||||
async function handleWake(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed || !selectedAgentId || busy) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await runCommand(selectedAgentId, "wake", trimmed);
|
||||
const detail = JSON.stringify(response);
|
||||
const ok = detail.includes('"ok"') || detail.includes('"success"');
|
||||
setRecentWakes((prev) =>
|
||||
[
|
||||
{
|
||||
ts: Date.now(),
|
||||
target: trimmed,
|
||||
agentId: selectedAgentId,
|
||||
outcome: ok ? "ok" : "fail",
|
||||
detail,
|
||||
},
|
||||
...prev,
|
||||
].slice(0, 20),
|
||||
);
|
||||
if (ok) {
|
||||
toast.success(`Woke ${trimmed}`);
|
||||
} else {
|
||||
toast.error(`Wake failed: ${trimmed}`, { description: detail });
|
||||
}
|
||||
if (onAfterWake) await onAfterWake();
|
||||
setTarget("");
|
||||
} catch (err) {
|
||||
setRecentWakes((prev) =>
|
||||
[
|
||||
{
|
||||
ts: Date.now(),
|
||||
target: trimmed,
|
||||
agentId: selectedAgentId,
|
||||
outcome: "error",
|
||||
detail: String(err),
|
||||
},
|
||||
...prev,
|
||||
].slice(0, 20),
|
||||
);
|
||||
toast.error("Wake failed", { description: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="size-5 text-primary" />
|
||||
Wake Target
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Send a Wake-on-LAN packet by hostname, IP, or MAC address
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Agent</span>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<form onSubmit={handleWake} className="grid gap-3">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Target</span>
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedAgentId || !target.trim() || busy}
|
||||
className="gap-2"
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
{busy ? "Sending..." : "Wake"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Recent Wakes</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRecentWakes([])}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentWakes.length > 0 ? (
|
||||
<div className="grid gap-0">
|
||||
{recentWakes.map((event, idx) => (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${idx}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{event.target}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
event.outcome === "ok" ? "secondary" : "destructive"
|
||||
}
|
||||
className="text-[0.6rem] px-1.5 py-0"
|
||||
>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[0.65rem] text-muted-foreground/70">
|
||||
{new Date(event.ts).toLocaleString()} via {event.agentId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Inbox className="size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No wake actions yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { DeviceRow } from "@/pages/devices/types";
|
||||
|
||||
type Props = {
|
||||
device: DeviceRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function summarizeAll(values: string[]): string {
|
||||
if (!values.length) return "-";
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
export function DeviceDetailsDialog({ device, open, onOpenChange }: Props) {
|
||||
if (!device) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl p-0">
|
||||
<div className="grid gap-4 p-4 text-sm">
|
||||
<DialogHeader className="pr-8">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">{device.name}</DialogTitle>
|
||||
<DialogDescription>Device details</DialogDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{device.presence}</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 font-medium">IP addresses</p>
|
||||
<p className="text-muted-foreground">{summarizeAll(device.ips)}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="mb-1 font-medium">MAC addresses</p>
|
||||
<p className="text-muted-foreground">{summarizeAll(device.macs)}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="mb-1 font-medium">Interfaces</p>
|
||||
<p className="text-muted-foreground">
|
||||
{summarizeAll(device.interfaces)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DeviceRow, SortDir } from "@/pages/devices/types";
|
||||
import {
|
||||
chooseWakeTarget,
|
||||
nextSort,
|
||||
summarize,
|
||||
type SortKey,
|
||||
} from "@/pages/devices/utils";
|
||||
|
||||
type Props = {
|
||||
rows: DeviceRow[];
|
||||
selectedIds: string[];
|
||||
sort: { key: SortKey; dir: SortDir };
|
||||
allVisibleSelected: boolean;
|
||||
wakeBusyId: string;
|
||||
selectedAgentId: string;
|
||||
bulkWakeBusy: boolean;
|
||||
onToggleAllVisible: (checked: boolean) => void;
|
||||
onToggleRow: (id: string, checked: boolean) => void;
|
||||
onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
|
||||
onWakeDevice: (device: DeviceRow) => void;
|
||||
onCopyValue: (label: string, value: string) => void;
|
||||
onOpenDetails: (device: DeviceRow) => void;
|
||||
};
|
||||
|
||||
function sortArrow(active: boolean, dir: SortDir) {
|
||||
if (!active) return "";
|
||||
return dir === "asc" ? "▲" : "▼";
|
||||
}
|
||||
|
||||
export function DeviceInventoryTable({
|
||||
rows,
|
||||
selectedIds,
|
||||
sort,
|
||||
allVisibleSelected,
|
||||
wakeBusyId,
|
||||
selectedAgentId,
|
||||
bulkWakeBusy,
|
||||
onToggleAllVisible,
|
||||
onToggleRow,
|
||||
onSortChange,
|
||||
onWakeDevice,
|
||||
onCopyValue,
|
||||
onOpenDetails,
|
||||
}: Props) {
|
||||
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("button, input, a, [role='menuitem']"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-list grid gap-2">
|
||||
<div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||
<span className="device-cell device-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={(e) => onToggleAllVisible(e.target.checked)}
|
||||
disabled={!rows.length}
|
||||
aria-label="Select all visible devices"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="sortable-col device-cell"
|
||||
onClick={() => onSortChange(nextSort(sort, "name"))}
|
||||
>
|
||||
Name {sortArrow(sort.key === "name", sort.dir)}
|
||||
</span>
|
||||
<span
|
||||
className="sortable-col device-cell"
|
||||
onClick={() => onSortChange(nextSort(sort, "ip"))}
|
||||
>
|
||||
IP {sortArrow(sort.key === "ip", sort.dir)}
|
||||
</span>
|
||||
<span
|
||||
className="sortable-col device-cell"
|
||||
onClick={() => onSortChange(nextSort(sort, "mac"))}
|
||||
>
|
||||
MAC {sortArrow(sort.key === "mac", sort.dir)}
|
||||
</span>
|
||||
<span
|
||||
className="sortable-col device-cell"
|
||||
onClick={() => onSortChange(nextSort(sort, "presence"))}
|
||||
>
|
||||
Presence {sortArrow(sort.key === "presence", sort.dir)}
|
||||
</span>
|
||||
<span className="device-cell device-action">Actions</span>
|
||||
</div>
|
||||
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
className="device-row cursor-pointer rounded-md border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent/30"
|
||||
key={row.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
if (shouldIgnoreRowClick(event.target)) return;
|
||||
onOpenDetails(row);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (shouldIgnoreRowClick(event.target)) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpenDetails(row);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="device-cell device-select" data-label="Pick">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(row.id)}
|
||||
onChange={(e) => onToggleRow(row.id, e.target.checked)}
|
||||
aria-label={`Select ${row.name}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="device-cell" data-label="Name" title={row.name}>
|
||||
{row.name}
|
||||
</span>
|
||||
<span
|
||||
className="device-cell text-muted-foreground"
|
||||
data-label="IP"
|
||||
title={row.ips.join(", ") || "-"}
|
||||
>
|
||||
{summarize(row.ips)}
|
||||
</span>
|
||||
<span
|
||||
className="device-cell text-muted-foreground"
|
||||
data-label="MAC"
|
||||
title={row.macs.join(", ") || "-"}
|
||||
>
|
||||
{summarize(row.macs)}
|
||||
</span>
|
||||
<span className="device-cell" data-label="Presence">
|
||||
<Badge variant="outline">{row.presence}</Badge>
|
||||
</span>
|
||||
<span className="device-cell device-action" data-label="">
|
||||
<Button
|
||||
onClick={() => onWakeDevice(row)}
|
||||
disabled={
|
||||
wakeBusyId === row.id || !selectedAgentId || bulkWakeBusy
|
||||
}
|
||||
size="sm"
|
||||
className="device-btn"
|
||||
>
|
||||
{wakeBusyId === row.id ? "Waking..." : "Wake"}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"device-copy-trigger",
|
||||
)}
|
||||
>
|
||||
Copy
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopyValue("target", chooseWakeTarget(row))}
|
||||
>
|
||||
Copy target
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyValue("name", row.name)}>
|
||||
Copy name
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopyValue("ip", row.ips[0] || "")}
|
||||
>
|
||||
Copy IP
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCopyValue("mac", row.macs[0] || "")}
|
||||
>
|
||||
Copy MAC
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!rows.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No devices found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { WakeEvent } from "@/pages/devices/types";
|
||||
|
||||
type Props = {
|
||||
events: WakeEvent[];
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export function WakeHistoryCard({ events, onClear }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Recent Wake Actions</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
{events.map((event, idx) => (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border bg-card px-3 py-2"
|
||||
key={`${event.ts}-${event.target}-${idx}`}
|
||||
>
|
||||
<div>
|
||||
<strong>{event.target}</strong>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(event.ts).toLocaleString()} on {event.agentId}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{event.detail}
|
||||
</div>
|
||||
{event.requestId && (
|
||||
<Link
|
||||
className="linkish text-xs text-muted-foreground"
|
||||
to={`/audit?q=${encodeURIComponent(event.requestId)}`}
|
||||
>
|
||||
View related audit ({event.requestId})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={event.outcome === "ok" ? "secondary" : "destructive"}
|
||||
>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{!events.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No wake actions yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
.quick-wake {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.quick-wake label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.presence-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.select-visible {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
margin-top: 0.8rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
|
||||
minmax(100px, 0.85fr) minmax(170px, max-content);
|
||||
align-items: center;
|
||||
column-gap: 0.4rem;
|
||||
row-gap: 0.55rem;
|
||||
min-width: 860px;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.device-cell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortable-col {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.device-action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.device-btn {
|
||||
min-width: 4.25rem;
|
||||
}
|
||||
|
||||
.device-copy-trigger {
|
||||
min-width: 6.2rem;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.device-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"select action"
|
||||
"name name"
|
||||
"presence presence"
|
||||
"ip ip"
|
||||
"mac mac";
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-row .device-cell {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label]::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Name"] {
|
||||
grid-area: name;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Pick"] {
|
||||
grid-area: select;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Pick"]::before {
|
||||
display: none;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="Presence"] {
|
||||
grid-area: presence;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="IP"] {
|
||||
grid-area: ip;
|
||||
}
|
||||
|
||||
.device-row .device-cell[data-label="MAC"] {
|
||||
grid-area: mac;
|
||||
}
|
||||
|
||||
.device-row .device-action {
|
||||
grid-area: action;
|
||||
justify-self: end;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-wake {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export type DeviceRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
isUnnamed: boolean;
|
||||
ips: string[];
|
||||
macs: string[];
|
||||
interfaces: string[];
|
||||
presence: string;
|
||||
};
|
||||
|
||||
export type WakeEvent = {
|
||||
ts: number;
|
||||
target: string;
|
||||
agentId: string;
|
||||
outcome: string;
|
||||
requestId: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
export type PresenceFilter = "all" | "online" | "likely_online" | "unknown";
|
||||
@@ -1,174 +0,0 @@
|
||||
import type {
|
||||
PresenceFilter,
|
||||
SortDir,
|
||||
DeviceRow,
|
||||
WakeEvent,
|
||||
} from "@/pages/devices/types";
|
||||
|
||||
export const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
|
||||
|
||||
export const PRESENCE_FILTERS: PresenceFilter[] = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
export const sorters = {
|
||||
name: (a: DeviceRow, b: DeviceRow) => a.name.localeCompare(b.name),
|
||||
ip: (a: DeviceRow, b: DeviceRow) =>
|
||||
(a.ips[0] || "").localeCompare(b.ips[0] || ""),
|
||||
mac: (a: DeviceRow, b: DeviceRow) =>
|
||||
(a.macs[0] || "").localeCompare(b.macs[0] || ""),
|
||||
presence: (a: DeviceRow, b: DeviceRow) =>
|
||||
a.presence.localeCompare(b.presence),
|
||||
};
|
||||
|
||||
export type SortKey = keyof typeof sorters;
|
||||
|
||||
export function parseInventoryRows(payload: unknown): DeviceRow[] {
|
||||
if (!payload || typeof payload !== "object") return [];
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (obj.kind !== "inventory") return [];
|
||||
if (!obj.devices || !Array.isArray(obj.devices)) return [];
|
||||
|
||||
return obj.devices
|
||||
.map((raw, idx) => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const device = raw as Record<string, unknown>;
|
||||
const names = Array.isArray(device.names)
|
||||
? (device.names.filter((v) => typeof v === "string") as string[])
|
||||
: [];
|
||||
const ips = Array.isArray(device.ips)
|
||||
? (device.ips.filter((v) => typeof v === "string") as string[])
|
||||
: [];
|
||||
const macs = Array.isArray(device.macs)
|
||||
? (device.macs.filter((v) => typeof v === "string") as string[])
|
||||
: [];
|
||||
const interfaces = Array.isArray(device.interfaces)
|
||||
? (device.interfaces.filter((v) => typeof v === "string") as string[])
|
||||
: [];
|
||||
const presence =
|
||||
typeof device.presence === "string" ? device.presence : "unknown";
|
||||
const isUnnamed = names.length === 0;
|
||||
|
||||
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
|
||||
return {
|
||||
id,
|
||||
name: names[0] || "(unnamed)",
|
||||
isUnnamed,
|
||||
ips,
|
||||
macs,
|
||||
interfaces,
|
||||
presence,
|
||||
};
|
||||
})
|
||||
.filter((v): v is DeviceRow => Boolean(v));
|
||||
}
|
||||
|
||||
export function parseWakeSummary(response: unknown): {
|
||||
outcome: string;
|
||||
requestId: string;
|
||||
detail: string;
|
||||
} {
|
||||
if (!response || typeof response !== "object") {
|
||||
return { outcome: "error", requestId: "", detail: "invalid wake response" };
|
||||
}
|
||||
const envelope = response as Record<string, unknown>;
|
||||
const requestId =
|
||||
typeof envelope.request_id === "string" ? envelope.request_id : "";
|
||||
const status =
|
||||
typeof envelope.status === "string" ? envelope.status : "error";
|
||||
|
||||
if (status !== "ok") {
|
||||
const error = envelope.error as Record<string, unknown> | undefined;
|
||||
const detail =
|
||||
typeof error?.message === "string" ? error.message : "wake failed";
|
||||
return { outcome: "error", requestId, detail };
|
||||
}
|
||||
|
||||
const result = envelope.result as Record<string, unknown> | undefined;
|
||||
if (result?.kind !== "wake") {
|
||||
return { outcome: "ok", requestId, detail: "wake dispatched" };
|
||||
}
|
||||
|
||||
const entries = Array.isArray(result.result) ? result.result : [];
|
||||
const targetOutcomes = entries
|
||||
.map((row) => {
|
||||
if (!row || typeof row !== "object") return "";
|
||||
const rowObj = row as Record<string, unknown>;
|
||||
const statusRaw = rowObj.status;
|
||||
const status =
|
||||
typeof statusRaw === "string"
|
||||
? statusRaw
|
||||
: typeof (statusRaw as Record<string, unknown> | undefined)?.kind ===
|
||||
"string"
|
||||
? String((statusRaw as Record<string, unknown>).kind)
|
||||
: "unknown";
|
||||
const ip = typeof rowObj.ip === "string" ? rowObj.ip : "?";
|
||||
const mac = typeof rowObj.mac === "string" ? rowObj.mac : "?";
|
||||
return `${status}(${ip}/${mac})`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const detail = targetOutcomes.length
|
||||
? targetOutcomes.join(", ")
|
||||
: "wake dispatched";
|
||||
const failed = targetOutcomes.some(
|
||||
(s) =>
|
||||
s.startsWith("incomplete") ||
|
||||
s.startsWith("nonexistent_address") ||
|
||||
s.startsWith("wrong_size"),
|
||||
);
|
||||
return { outcome: failed ? "error" : "ok", requestId, detail };
|
||||
}
|
||||
|
||||
export function chooseWakeTarget(device: DeviceRow): string {
|
||||
return device.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
|
||||
}
|
||||
|
||||
export function summarize(values: string[]): string {
|
||||
if (!values.length) return "-";
|
||||
if (values.length === 1) return values[0];
|
||||
return `${values[0]} (+${values.length - 1})`;
|
||||
}
|
||||
|
||||
export function loadHistory(): WakeEvent[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((row): row is WakeEvent => {
|
||||
if (!row || typeof row !== "object") return false;
|
||||
const event = row as Record<string, unknown>;
|
||||
return (
|
||||
typeof event.ts === "number" &&
|
||||
typeof event.target === "string" &&
|
||||
typeof event.agentId === "string" &&
|
||||
typeof event.outcome === "string" &&
|
||||
typeof event.requestId === "string" &&
|
||||
typeof event.detail === "string"
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHistory(history: WakeEvent[]) {
|
||||
window.localStorage.setItem(
|
||||
WAKE_HISTORY_KEY,
|
||||
JSON.stringify(history.slice(0, 20)),
|
||||
);
|
||||
}
|
||||
|
||||
export function nextSort(
|
||||
current: { key: SortKey; dir: SortDir },
|
||||
key: SortKey,
|
||||
): { key: SortKey; dir: SortDir } {
|
||||
return {
|
||||
key,
|
||||
dir: current.key === key && current.dir === "asc" ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function PresenceBadge({ presence }: { presence: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`presence-badge--${presence}`}>
|
||||
<span className={`presence-dot presence-dot--${presence}`} aria-hidden />
|
||||
{presence.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="mb-1 block text-xs font-medium text-muted-foreground xl:hidden">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
values: readonly string[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||
<SelectTrigger>
|
||||
<span>{value.replace("_", " ")}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{values.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailBlock({
|
||||
label,
|
||||
values,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
values: string[];
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{values.length ? (
|
||||
values.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="flex min-w-0 items-center justify-between gap-2 rounded px-1 py-0.5 text-left hover:bg-accent"
|
||||
onClick={() => onCopy(label, value)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{value}</span>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
|
||||
import {
|
||||
attachDeviceIdentifier,
|
||||
createKnownDevice,
|
||||
mergeKnownDevice,
|
||||
type FleetDevice,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
||||
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
|
||||
|
||||
type Props = {
|
||||
device: FleetDevice | null;
|
||||
knownDevices: KnownDevice[];
|
||||
open: boolean;
|
||||
busy: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWake: (routeId?: string | null) => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
onChanged: () => void;
|
||||
};
|
||||
|
||||
export function FleetDeviceDetailsDialog({
|
||||
device,
|
||||
knownDevices,
|
||||
open,
|
||||
busy,
|
||||
onOpenChange,
|
||||
onWake,
|
||||
onCopy,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(device?.display_name ?? "");
|
||||
setTargetDeviceId("");
|
||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||
setError("");
|
||||
}, [device]);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const currentDevice = device;
|
||||
const identifiers = identifiersFor(currentDevice);
|
||||
const otherKnownDevices = knownDevices.filter(
|
||||
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
||||
);
|
||||
|
||||
async function createRemembered() {
|
||||
setError("");
|
||||
try {
|
||||
await createKnownDevice({
|
||||
display_name: displayName.trim() || currentDevice.display_name,
|
||||
pinned: true,
|
||||
identifiers,
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function attachToExisting() {
|
||||
if (!targetDeviceId) return;
|
||||
setError("");
|
||||
try {
|
||||
for (const identifier of identifiers) {
|
||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeIntoExisting() {
|
||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||
setError("");
|
||||
try {
|
||||
await mergeKnownDevice(
|
||||
targetDeviceId,
|
||||
currentDevice.known_device.device_id,
|
||||
);
|
||||
onChanged();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRoute =
|
||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
||||
device.recommended_route;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">
|
||||
{device.display_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{device.known_device
|
||||
? "Remembered fleet device"
|
||||
: "Observed fleet device"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<PresenceBadge presence={device.presence} />
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<DetailBlock label="IPs" values={device.ips} onCopy={onCopy} />
|
||||
<DetailBlock label="MACs" values={device.macs} onCopy={onCopy} />
|
||||
<DetailBlock
|
||||
label="Hostnames"
|
||||
values={device.hostnames}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<DetailBlock
|
||||
label="Agents"
|
||||
values={device.agents.map((agent) =>
|
||||
agentLabel(agent.agent_id, agent.nickname),
|
||||
)}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Wake route</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onWake(selectedRoute?.route_id ?? null)}
|
||||
disabled={busy || !selectedRoute?.wakeable}
|
||||
>
|
||||
<Zap className="size-4" aria-hidden />
|
||||
{busy ? "Waking" : "Wake"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={routeId}
|
||||
onValueChange={(value) => setRouteId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{selectedRoute
|
||||
? routeLabel(selectedRoute)
|
||||
: "No connected MAC-backed route"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{device.route_candidates.map((route) => (
|
||||
<SelectItem key={route.route_id} value={route.route_id}>
|
||||
{routeLabel(route)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-sm font-medium">Remember / merge</h3>
|
||||
{!device.known_device && (
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void createRemembered()}
|
||||
disabled={!identifiers.length}
|
||||
>
|
||||
Remember device
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(known) => known.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select known device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{otherKnownDevices.map((known) => (
|
||||
<SelectItem key={known.device_id} value={known.device_id}>
|
||||
{known.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void attachToExisting()}
|
||||
disabled={!targetDeviceId || !identifiers.length}
|
||||
>
|
||||
Attach IDs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void mergeIntoExisting()}
|
||||
disabled={!targetDeviceId || !device.known_device}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proposed identifiers:{" "}
|
||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
||||
"-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FleetDevice, FleetWakeRoute } from "@/api";
|
||||
|
||||
export type SortKey =
|
||||
| "name"
|
||||
| "presence"
|
||||
| "last_seen"
|
||||
| "agents"
|
||||
| "ip"
|
||||
| "mac";
|
||||
export type SortDir = "asc" | "desc";
|
||||
export type KnownFilter = "all" | "known" | "unknown";
|
||||
|
||||
export const presenceFilters = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
"offline",
|
||||
] as const;
|
||||
|
||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||
|
||||
export function summarize(values: string[]): string {
|
||||
if (!values.length) return "-";
|
||||
if (values.length <= 2) return values.join(", ");
|
||||
return `${values[0]}, ${values[1]} (+${values.length - 2})`;
|
||||
}
|
||||
|
||||
export function formatSeen(value: number | null): string {
|
||||
if (!value) return "-";
|
||||
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function presenceRank(presence: string): number {
|
||||
if (presence === "online") return 4;
|
||||
if (presence === "likely_online") return 3;
|
||||
if (presence === "unknown") return 2;
|
||||
if (presence === "offline") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function identifiersFor(
|
||||
device: FleetDevice,
|
||||
): { kind: string; value: string }[] {
|
||||
return [
|
||||
...device.macs.map((value) => ({ kind: "mac", value })),
|
||||
...device.ips.map((value) => ({ kind: "ip", value })),
|
||||
];
|
||||
}
|
||||
|
||||
export function agentLabel(agentId: string, nickname?: string | null): string {
|
||||
const trimmed = nickname?.trim();
|
||||
return trimmed ? trimmed : agentId;
|
||||
}
|
||||
|
||||
export function routeLabel(route: FleetWakeRoute): string {
|
||||
const status = route.connected ? "connected" : "offline";
|
||||
const target = route.mac
|
||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||
: (route.ip ?? "-");
|
||||
return `${agentLabel(route.agent_id, route.nickname)} · ${target} · ${route.source} · ${status}`;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function ObservationHistoryDialog({
|
||||
)}
|
||||
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||
<div className="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>State</span>
|
||||
<span>Source</span>
|
||||
@@ -80,7 +80,7 @@ export function ObservationHistoryDialog({
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
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">
|
||||
{formatSeen(event.ts_unix)}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ObservationTable({
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="grid gap-2 overflow-x-auto">
|
||||
<div className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||
<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>Network</span>
|
||||
<span>Agent</span>
|
||||
@@ -49,7 +49,7 @@ export function ObservationTable({
|
||||
return (
|
||||
<div
|
||||
key={observation.observation_key}
|
||||
className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
||||
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="truncate font-medium">
|
||||
|
||||
+272
-66
@@ -8,36 +8,42 @@
|
||||
:root {
|
||||
--card: oklch(1 0 0);
|
||||
--muted: oklch(0.96 0.004 145);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.23 0.02 155);
|
||||
--card-foreground: oklch(0.23 0.02 155);
|
||||
--background: oklch(0.985 0.002 200);
|
||||
--foreground: oklch(0.18 0.02 240);
|
||||
--card-foreground: oklch(0.18 0.02 240);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 155);
|
||||
--primary: oklch(0.56 0.12 156);
|
||||
--primary-foreground: oklch(0.985 0.003 95);
|
||||
--secondary: oklch(0.965 0.01 155);
|
||||
--popover-foreground: oklch(0.18 0.02 240);
|
||||
--primary: oklch(0.55 0.14 155);
|
||||
--primary-foreground: oklch(0.985 0.005 155);
|
||||
--secondary: oklch(0.96 0.01 155);
|
||||
--secondary-foreground: oklch(0.3 0.035 156);
|
||||
--muted-foreground: oklch(0.5 0.02 155);
|
||||
--accent: oklch(0.945 0.015 156);
|
||||
--muted-foreground: oklch(0.48 0.02 240);
|
||||
--accent: oklch(0.945 0.018 155);
|
||||
--accent-foreground: oklch(0.3 0.035 156);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.9 0.01 155);
|
||||
--input: oklch(0.9 0.01 155);
|
||||
--ring: oklch(0.62 0.08 156);
|
||||
--chart-1: oklch(0.62 0.12 156);
|
||||
--border: oklch(0.91 0.008 240);
|
||||
--input: oklch(0.91 0.008 240);
|
||||
--ring: oklch(0.55 0.14 155);
|
||||
--chart-1: oklch(0.55 0.14 155);
|
||||
--chart-2: oklch(0.56 0.11 188);
|
||||
--chart-3: oklch(0.67 0.12 120);
|
||||
--chart-4: oklch(0.72 0.15 85);
|
||||
--chart-5: oklch(0.58 0.18 35);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.23 0.02 155);
|
||||
--sidebar-primary: oklch(0.56 0.12 156);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.003 95);
|
||||
--sidebar-accent: oklch(0.945 0.015 156);
|
||||
--sidebar-accent-foreground: oklch(0.3 0.035 156);
|
||||
--sidebar-border: oklch(0.9 0.01 155);
|
||||
--sidebar-ring: oklch(0.62 0.08 156);
|
||||
--sidebar: oklch(0.975 0.005 155);
|
||||
--sidebar-foreground: oklch(0.18 0.02 240);
|
||||
--sidebar-primary: oklch(0.55 0.14 155);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.005 155);
|
||||
--sidebar-accent: oklch(0.94 0.02 155);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.03 155);
|
||||
--sidebar-border: oklch(0.92 0.008 240);
|
||||
--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 {
|
||||
@@ -84,67 +90,267 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--muted: oklch(0.25 0.01 156);
|
||||
--background: oklch(0.19 0.02 156);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.24 0.015 156);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.24 0.015 156);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.68 0.11 156);
|
||||
--primary-foreground: oklch(0.17 0.02 156);
|
||||
--secondary: oklch(0.28 0.02 156);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted-foreground: oklch(0.75 0.02 156);
|
||||
--accent: oklch(0.3 0.02 156);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.015 240);
|
||||
--background: oklch(0.14 0.02 250);
|
||||
--foreground: oklch(0.93 0.005 240);
|
||||
--card: oklch(0.18 0.018 245);
|
||||
--card-foreground: oklch(0.93 0.005 240);
|
||||
--popover: oklch(0.18 0.018 245);
|
||||
--popover-foreground: oklch(0.93 0.005 240);
|
||||
--primary: oklch(0.7 0.14 155);
|
||||
--primary-foreground: oklch(0.14 0.02 155);
|
||||
--secondary: oklch(0.24 0.02 240);
|
||||
--secondary-foreground: oklch(0.93 0.005 240);
|
||||
--muted-foreground: oklch(0.65 0.015 240);
|
||||
--accent: oklch(0.24 0.025 245);
|
||||
--accent-foreground: oklch(0.93 0.005 240);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.65 0.08 156);
|
||||
--chart-1: oklch(0.68 0.11 156);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.7 0.14 155);
|
||||
--chart-1: oklch(0.7 0.14 155);
|
||||
--chart-2: oklch(0.63 0.11 188);
|
||||
--chart-3: oklch(0.73 0.12 120);
|
||||
--chart-4: oklch(0.78 0.14 85);
|
||||
--chart-5: oklch(0.67 0.16 35);
|
||||
--sidebar: oklch(0.24 0.015 156);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.68 0.11 156);
|
||||
--sidebar-primary-foreground: oklch(0.17 0.02 156);
|
||||
--sidebar-accent: oklch(0.3 0.02 156);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.65 0.08 156);
|
||||
--sidebar: oklch(0.16 0.02 248);
|
||||
--sidebar-foreground: oklch(0.93 0.005 240);
|
||||
--sidebar-primary: oklch(0.7 0.14 155);
|
||||
--sidebar-primary-foreground: oklch(0.14 0.02 155);
|
||||
--sidebar-accent: oklch(0.22 0.025 245);
|
||||
--sidebar-accent-foreground: oklch(0.93 0.005 240);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@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;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sidebar shell ────────────────────────────────────────────────────── */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 15rem;
|
||||
height: 100dvh;
|
||||
padding: 0.75rem;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
width: 3.5rem;
|
||||
padding: 0.75rem 0.45rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background:
|
||||
radial-gradient(
|
||||
1200px 700px at 15% -10%,
|
||||
color-mix(in oklab, var(--primary) 8%, var(--background)) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 600px at 85% 0%,
|
||||
color-mix(in oklab, var(--accent) 6%, var(--background)) 0%,
|
||||
transparent 58%
|
||||
),
|
||||
var(--background);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.3rem 0.35rem 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
padding: 0.5rem 0.55rem 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 450;
|
||||
color: var(--sidebar-foreground);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
||||
.sidebar-link--active {
|
||||
background: color-mix(in oklab, var(--sidebar-primary) 14%, transparent);
|
||||
color: var(--sidebar-primary);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.sidebar-link--collapsed {
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-link-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sidebar-link--active .sidebar-link-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-link-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--sidebar-border);
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.sidebar-footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 450;
|
||||
color: var(--sidebar-foreground);
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar-footer-btn {
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||
|
||||
.presence-dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.presence-dot--online {
|
||||
background: var(--presence-online);
|
||||
}
|
||||
.presence-dot--likely_online {
|
||||
background: var(--presence-likely);
|
||||
}
|
||||
.presence-dot--unknown {
|
||||
background: var(--presence-unknown);
|
||||
}
|
||||
.presence-dot--offline {
|
||||
background: var(--presence-offline);
|
||||
}
|
||||
|
||||
.presence-badge--online {
|
||||
border-color: color-mix(in oklab, var(--presence-online) 40%, transparent);
|
||||
color: var(--presence-online);
|
||||
}
|
||||
.presence-badge--likely_online {
|
||||
border-color: color-mix(in oklab, var(--presence-likely) 40%, transparent);
|
||||
color: var(--presence-likely);
|
||||
}
|
||||
.presence-badge--unknown {
|
||||
border-color: var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.presence-badge--offline {
|
||||
border-color: color-mix(in oklab, var(--presence-offline) 40%, transparent);
|
||||
color: var(--presence-offline);
|
||||
}
|
||||
+9
-13
@@ -6,15 +6,15 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function sourcemapEnabled(): boolean {
|
||||
const explicit = process.env.UI_SOURCEMAP?.toLowerCase();
|
||||
const debugEnabled = (): boolean => {
|
||||
const explicit = process.env.DEBUG?.toLowerCase();
|
||||
if (explicit) {
|
||||
return explicit === "1" || explicit === "true" || explicit === "yes";
|
||||
}
|
||||
|
||||
const rustLog = (process.env.RUST_LOG || "").toLowerCase();
|
||||
return rustLog.includes("debug") || rustLog.includes("trace");
|
||||
}
|
||||
};
|
||||
|
||||
const ReactCompilerConfig = {
|
||||
target: "18",
|
||||
@@ -23,15 +23,11 @@ const ReactCompilerConfig = {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(
|
||||
process.env.UI_REACT_COMPILER === "1"
|
||||
? {
|
||||
babel: {
|
||||
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
react({
|
||||
babel: {
|
||||
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
base: "/ui/",
|
||||
@@ -41,6 +37,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: sourcemapEnabled(),
|
||||
sourcemap: debugEnabled(),
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ pub enum Command {
|
||||
#[derive(Args)]
|
||||
pub struct ServeArgs {
|
||||
/// 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,
|
||||
|
||||
/// Path to pid file for reload signaling.
|
||||
@@ -80,11 +80,11 @@ pub struct EnrollArgs {
|
||||
#[derive(Args)]
|
||||
pub struct InitConfigArgs {
|
||||
/// 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>,
|
||||
|
||||
/// 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>,
|
||||
|
||||
/// Print config to stdout.
|
||||
@@ -118,14 +118,14 @@ pub struct ReloadArgs {
|
||||
#[derive(Args)]
|
||||
pub struct SyncObservationsArgs {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveArgs {
|
||||
/// Path to the agent config file. If present, local path settings are passed through.
|
||||
#[arg(long, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||
#[arg(long, short, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||
pub config: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
|
||||
@@ -124,12 +124,21 @@ async fn main() -> Result<()> {
|
||||
fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
let mut cmd = std::process::Command::new(resolve_wakey_binary());
|
||||
cmd.arg("observe");
|
||||
let config_path = args.config.clone();
|
||||
if let Ok(config) = config::load_config(&args.config) {
|
||||
config::apply_local_path_env_to_command(&mut cmd, &config);
|
||||
}
|
||||
|
||||
match args.command {
|
||||
ObserveCommand::Dhcp(args) => {
|
||||
::tracing::debug!(
|
||||
action = %args.action,
|
||||
mac = %args.mac,
|
||||
ip = ?args.ip,
|
||||
hostname = ?args.hostname,
|
||||
config = %config_path.display(),
|
||||
"forwarding dhcp observation to wakey"
|
||||
);
|
||||
cmd.arg("dhcp")
|
||||
.arg("--action")
|
||||
.arg(args.action)
|
||||
@@ -143,6 +152,13 @@ fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
ObserveCommand::Neigh(args) => {
|
||||
::tracing::debug!(
|
||||
action = %args.action,
|
||||
mac = ?args.mac,
|
||||
ip = ?args.ip,
|
||||
config = %config_path.display(),
|
||||
"forwarding neighbor observation to wakey"
|
||||
);
|
||||
cmd.arg("neigh").arg("--action").arg(args.action);
|
||||
if let Some(mac) = args.mac {
|
||||
cmd.arg("--mac").arg(mac);
|
||||
@@ -154,6 +170,7 @@ fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
let status = cmd.status()?;
|
||||
::tracing::debug!(%status, "wakey observe exited");
|
||||
if !status.success() {
|
||||
anyhow::bail!("wakey observe exited with {status}");
|
||||
}
|
||||
|
||||
@@ -130,6 +130,17 @@ pub struct WakeRequest {
|
||||
pub ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentObservation {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AgentCommand {
|
||||
@@ -161,6 +172,10 @@ pub enum ClientMessage {
|
||||
Heartbeat {
|
||||
agent_id: String,
|
||||
},
|
||||
Observations {
|
||||
agent_id: String,
|
||||
observations: Vec<AgentObservation>,
|
||||
},
|
||||
Result {
|
||||
request_id: RequestId,
|
||||
result: CommandResult,
|
||||
@@ -178,6 +193,7 @@ pub enum ServerMessage {
|
||||
request_id: RequestId,
|
||||
command: AgentCommand,
|
||||
},
|
||||
SyncObservations,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -215,4 +231,24 @@ mod tests {
|
||||
assert!(json.contains("\"type\":\"result\""));
|
||||
assert!(json.contains("\"kind\":\"devs\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_message_serializes() {
|
||||
let msg = ClientMessage::Observations {
|
||||
agent_id: "agent-a".into(),
|
||||
observations: vec![AgentObservation {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".into()),
|
||||
ip: Some("192.168.1.10".parse().expect("ip")),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(json.contains("\"type\":\"observations\""));
|
||||
assert!(json.contains("\"kind\":\"dhcp\""));
|
||||
}
|
||||
}
|
||||
+55
-32
@@ -9,7 +9,7 @@ use tracing::{debug, error, info, info_span, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::dispatch::dispatch_command;
|
||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||
use crate::protocol::{AgentCommand, AgentObservation, ClientMessage, ErrorPayload, ServerMessage};
|
||||
|
||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
let mut backoff = config.reconnect_base_ms.max(100);
|
||||
@@ -85,13 +85,13 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
.await?;
|
||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let mut heartbeat = interval(Duration::from_secs(30));
|
||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
let mut observation_sync = interval(Duration::from_secs(
|
||||
config.observation_sync_interval_seconds.max(1),
|
||||
));
|
||||
observation_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
observation_sync.reset();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -102,7 +102,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
debug!(agent_id = %config.agent_id, "heartbeat sent");
|
||||
}
|
||||
_ = observation_sync.tick() => {
|
||||
if let Err(err) = send_agent_observations(&http_client, config).await {
|
||||
if let Err(err) = send_agent_observations_ws(&mut sink, config).await {
|
||||
warn!(agent_id = %config.agent_id, error = %err, "failed to sync local observations");
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(message) => {
|
||||
handle_server_message(config, &mut sink, message).await?;
|
||||
handle_server_message(config, &mut sink, &mut observation_sync, message).await?;
|
||||
}
|
||||
Err(err) => {
|
||||
// Allow the server to introduce extra frame types without
|
||||
@@ -150,18 +150,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
struct UploadAgentObservationsRequest {
|
||||
agent_id: String,
|
||||
agent_token: String,
|
||||
observations: Vec<AgentObservationRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AgentObservationRequest {
|
||||
kind: String,
|
||||
action: String,
|
||||
mac: Option<String>,
|
||||
ip: Option<IpAddr>,
|
||||
hostname: Option<String>,
|
||||
first_seen_unix: u64,
|
||||
last_seen_unix: u64,
|
||||
observations: Vec<AgentObservation>,
|
||||
}
|
||||
|
||||
pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
||||
@@ -170,10 +159,7 @@ pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
||||
}
|
||||
|
||||
async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig) -> Result<usize> {
|
||||
let observations =
|
||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
||||
.await
|
||||
.context("failed to read local observations")?;
|
||||
let observations = load_agent_observations(config).await?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -182,18 +168,7 @@ async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig)
|
||||
let payload = UploadAgentObservationsRequest {
|
||||
agent_id: config.agent_id.clone(),
|
||||
agent_token: config.agent_token.clone(),
|
||||
observations: observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentObservationRequest {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect(),
|
||||
observations,
|
||||
};
|
||||
|
||||
let response = client
|
||||
@@ -218,6 +193,47 @@ async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig)
|
||||
Ok(payload.observations.len())
|
||||
}
|
||||
|
||||
async fn send_agent_observations_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let observations = load_agent_observations(config).await?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = observations.len();
|
||||
send_json(
|
||||
sink,
|
||||
&ClientMessage::Observations {
|
||||
agent_id: config.agent_id.clone(),
|
||||
observations,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
debug!(agent_id = %config.agent_id, observations = count, "sent observations over websocket");
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn load_agent_observations(config: &AgentConfig) -> Result<Vec<AgentObservation>> {
|
||||
let observations =
|
||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
||||
.await
|
||||
.context("failed to read local observations")?;
|
||||
Ok(observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentObservation {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
let cap = max_ms.max(current_ms);
|
||||
current_ms.saturating_mul(2).min(cap)
|
||||
@@ -226,6 +242,7 @@ pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
async fn handle_server_message<S>(
|
||||
config: &AgentConfig,
|
||||
sink: &mut S,
|
||||
observation_sync: &mut tokio::time::Interval,
|
||||
message: ServerMessage,
|
||||
) -> Result<()>
|
||||
where
|
||||
@@ -261,6 +278,11 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerMessage::SyncObservations => {
|
||||
info!("received observation sync request from control-plane");
|
||||
send_agent_observations_ws(sink, config).await?;
|
||||
observation_sync.reset();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,6 +358,7 @@ fn client_message_kind(message: &ClientMessage) -> &'static str {
|
||||
ClientMessage::Hello { .. } => "hello",
|
||||
ClientMessage::Auth { .. } => "auth",
|
||||
ClientMessage::Heartbeat { .. } => "heartbeat",
|
||||
ClientMessage::Observations { .. } => "observations",
|
||||
ClientMessage::Result { .. } => "result",
|
||||
ClientMessage::Error { .. } => "error",
|
||||
}
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7247cc488921de2313a602697590502771a231bde7d03cd9ccac55a70971da45"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "72aadefca9851a8becdd2dcce54b646aff095b64cef9837f5e8e4ea8dde71d02"
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -58,14 +58,26 @@ pub async fn run_command(
|
||||
AxumPath(agent_id): AxumPath<String>,
|
||||
Json(req): Json<RelayCommandRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match relay_agent_command(&state, &agent_id, req.command, req.timeout_ms).await {
|
||||
Ok(response) => Ok((StatusCode::OK, Json(response))),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn relay_agent_command(
|
||||
state: &AppState,
|
||||
agent_id: &str,
|
||||
command: AgentCommand,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<RelayCommandResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let request_id_string = format!("req-{}", Uuid::new_v4());
|
||||
let command = command_kind(&req.command);
|
||||
let command_kind = command_kind(&command);
|
||||
let started = Instant::now();
|
||||
let span = info_span!(
|
||||
"relay_command",
|
||||
agent_id = %agent_id,
|
||||
request_id = %request_id_string,
|
||||
command = %command,
|
||||
command = %command_kind,
|
||||
);
|
||||
let _span_guard = span.enter();
|
||||
|
||||
@@ -79,7 +91,7 @@ pub async fn run_command(
|
||||
|
||||
let tx = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.get(&agent_id).map(|session| session.tx.clone())
|
||||
sessions.get(agent_id).map(|session| session.tx.clone())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
warn!("command rejected: agent not connected");
|
||||
@@ -103,13 +115,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_dispatch".into(),
|
||||
outcome: "sent".into(),
|
||||
latency_ms: None,
|
||||
message: "dispatched command to connected agent".into(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -118,7 +130,7 @@ pub async fn run_command(
|
||||
|
||||
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
||||
request_id,
|
||||
command: req.command,
|
||||
command,
|
||||
})) {
|
||||
state.pending.lock().await.remove(&request_id_string);
|
||||
warn!(error = %err, "failed sending command to agent session");
|
||||
@@ -127,13 +139,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_dispatch".into(),
|
||||
outcome: "send_failed".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: err.to_string(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -147,7 +159,7 @@ pub async fn run_command(
|
||||
}
|
||||
|
||||
let timeout = std::time::Duration::from_millis(
|
||||
req.timeout_ms
|
||||
timeout_ms
|
||||
.unwrap_or(state.command_timeout.as_millis() as u64)
|
||||
.max(1),
|
||||
);
|
||||
@@ -160,13 +172,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "ok".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: "agent command completed".into(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -186,13 +198,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "error".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: error.message.clone(),
|
||||
metadata: serde_json::json!({ "command": command, "code": error.code }),
|
||||
metadata: serde_json::json!({ "command": command_kind, "code": error.code }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -212,13 +224,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "response_dropped".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: "agent response channel dropped".into(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -241,13 +253,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "timeout".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: "agent command timed out".into(),
|
||||
metadata: serde_json::json!({ "command": command, "timeout_ms": timeout.as_millis() as u64 }),
|
||||
metadata: serde_json::json!({ "command": command_kind, "timeout_ms": timeout.as_millis() as u64 }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -261,7 +273,7 @@ pub async fn run_command(
|
||||
}
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn command_kind(command: &AgentCommand) -> &'static str {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
mod devices;
|
||||
mod enroll;
|
||||
mod fleet;
|
||||
mod observations;
|
||||
mod stats;
|
||||
|
||||
pub use devices::{
|
||||
attach_device_identifier, attach_observation_identifier, create_known_device,
|
||||
forget_known_device, list_known_devices,
|
||||
detach_device_identifier, forget_known_device, list_known_devices, merge_known_device,
|
||||
};
|
||||
pub use enroll::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, revoke_enroll_token,
|
||||
set_agent_nickname,
|
||||
};
|
||||
pub use fleet::{list_fleet_devices, refresh_fleet_devices, wake_fleet_device};
|
||||
pub use observations::{
|
||||
list_agent_observation_history, list_agent_observations, upload_agent_observations,
|
||||
list_agent_observation_history, list_agent_observations, request_agent_observation_sync,
|
||||
upload_agent_observations,
|
||||
};
|
||||
pub use stats::{StateStatsResponse, state_stats};
|
||||
@@ -56,6 +56,11 @@ pub struct ForgetKnownDeviceResponse {
|
||||
pub forgotten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergeKnownDeviceRequest {
|
||||
pub source_device_id: String,
|
||||
}
|
||||
|
||||
pub async fn create_known_device(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateKnownDeviceRequest>,
|
||||
@@ -193,6 +198,59 @@ 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(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<MergeKnownDeviceRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.merge_known_devices(&device_id, &req.source_device_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"target or source known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to merge known devices");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"merge_known_device_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
|
||||
KnownDeviceResponse {
|
||||
device_id: device.device_id,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::ServerMessage;
|
||||
|
||||
use crate::api::json_error;
|
||||
use crate::runtime::AppState;
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
};
|
||||
@@ -35,6 +36,12 @@ pub struct UploadAgentObservationsResponse {
|
||||
pub accepted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestAgentObservationSyncResponse {
|
||||
pub agent_id: String,
|
||||
pub requested: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObservationsQuery {
|
||||
pub agent_id: Option<String>,
|
||||
@@ -130,6 +137,43 @@ pub async fn list_agent_observations(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_agent_observation_sync(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(agent_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let tx = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.get(&agent_id).map(|session| session.tx.clone())
|
||||
};
|
||||
let Some(tx) = tx else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(RequestAgentObservationSyncResponse {
|
||||
agent_id,
|
||||
requested: false,
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
match tx.send(SessionEvent::Message(ServerMessage::SyncObservations)) {
|
||||
Ok(()) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(RequestAgentObservationSyncResponse {
|
||||
agent_id,
|
||||
requested: true,
|
||||
}),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to request agent observation sync");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_observation_sync_request_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListObservationHistoryQuery>,
|
||||
|
||||
@@ -12,10 +12,11 @@ pub use commands::{list_agents, run_command};
|
||||
pub use control::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
StateStatsResponse, attach_device_identifier, attach_observation_identifier,
|
||||
create_known_device, enroll, forget_known_device, healthz, issue_enroll_token,
|
||||
list_agent_observation_history, list_agent_observations, list_enroll_tokens,
|
||||
list_known_devices, revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats,
|
||||
upload_agent_observations,
|
||||
create_known_device, detach_device_identifier, enroll, forget_known_device, healthz,
|
||||
issue_enroll_token, list_agent_observation_history, list_agent_observations,
|
||||
list_enroll_tokens, list_fleet_devices, list_known_devices, merge_known_device,
|
||||
refresh_fleet_devices, request_agent_observation_sync, revoke_agent, revoke_enroll_token,
|
||||
set_agent_nickname, state_stats, upload_agent_observations, wake_fleet_device,
|
||||
};
|
||||
|
||||
pub fn json_error(
|
||||
|
||||
@@ -87,9 +87,18 @@ pub struct ServeArgs {
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
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>,
|
||||
|
||||
#[arg(long, short, visible_alias = "from")]
|
||||
pub from_config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, conflicts_with = "config_file")]
|
||||
pub stdout: bool,
|
||||
|
||||
@@ -126,8 +135,8 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub telemetry_service_name: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
pub telemetry_json_logs: bool,
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
|
||||
pub telemetry_json_logs: Option<bool>,
|
||||
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
@@ -135,7 +144,7 @@ pub struct InitConfigArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct IssueEnrollTokenArgs {
|
||||
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||
#[arg(long, visible_alias = "config", default_value = DEFAULT_CONFIG_FILE)]
|
||||
pub config_file: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
@@ -156,7 +165,7 @@ pub struct IssueEnrollTokenArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ListEnrollTokensArgs {
|
||||
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||
#[arg(long, visible_alias = "config", default_value = DEFAULT_CONFIG_FILE)]
|
||||
pub config_file: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
@@ -238,12 +247,13 @@ pub struct StateStatsArgs {
|
||||
pub target: AdminTargetArgs,
|
||||
}
|
||||
|
||||
// not the greatest thing ive used, this and the migration code gets deleted shortly anyways
|
||||
#[derive(Args)]
|
||||
pub struct ImportSledStateArgs {
|
||||
#[arg(long)]
|
||||
#[arg(long, visible_alias = "from")]
|
||||
pub from_sled_state: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
#[arg(long, visible_alias = "to")]
|
||||
pub to_state_file: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::{InitConfigArgs, ServeArgs};
|
||||
use crate::config::resolve::{normalize_public_url, resolve_path};
|
||||
use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path};
|
||||
use crate::config::types::{WritableConfig, WritableTelemetry};
|
||||
|
||||
pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
@@ -21,56 +21,104 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
);
|
||||
}
|
||||
|
||||
let bind = args.bind.unwrap_or_else(|| {
|
||||
"0.0.0.0:8080"
|
||||
.parse()
|
||||
.expect("static default bind should parse")
|
||||
});
|
||||
let base = if let Some(from_config) = &args.from_config {
|
||||
if !from_config.exists() {
|
||||
anyhow::bail!("base config {} does not exist", from_config.display());
|
||||
}
|
||||
load_file_config(from_config)?
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let base_telemetry = base.telemetry.unwrap_or_default();
|
||||
|
||||
let bind = match args.bind {
|
||||
Some(bind) => bind,
|
||||
None => match base.bind {
|
||||
Some(ref bind) => bind.parse().with_context(|| {
|
||||
format!(
|
||||
"invalid bind address `{bind}` in base config {}",
|
||||
args.from_config
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "<defaults>".to_string())
|
||||
)
|
||||
})?,
|
||||
None => "0.0.0.0:8080"
|
||||
.parse()
|
||||
.expect("static default bind should parse"),
|
||||
},
|
||||
};
|
||||
let public_url = normalize_public_url(
|
||||
args.public_url
|
||||
.as_deref()
|
||||
.or(base.public_url.as_deref())
|
||||
.unwrap_or("http://127.0.0.1:8080"),
|
||||
);
|
||||
|
||||
let data_dir = args
|
||||
.data_dir
|
||||
.clone()
|
||||
.or(base.data_dir)
|
||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||
|
||||
let state_file_raw = args
|
||||
.state_file
|
||||
.clone()
|
||||
.or(base.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
|
||||
let pid_file_raw = args
|
||||
.pid_file
|
||||
.clone()
|
||||
.or(base.pid_file)
|
||||
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
|
||||
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||
|
||||
let ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
.clone()
|
||||
.or(base.ui_dist_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("ui/dist"));
|
||||
|
||||
let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() {
|
||||
base.bootstrap_enroll_tokens.unwrap_or_default()
|
||||
} else {
|
||||
args.bootstrap_enroll_tokens.clone()
|
||||
};
|
||||
|
||||
let body = WritableConfig {
|
||||
data_dir,
|
||||
bind: bind.to_string(),
|
||||
public_url,
|
||||
state_file,
|
||||
command_timeout_ms: args.command_timeout_ms.unwrap_or(30_000).max(1),
|
||||
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds.unwrap_or(86_400).max(1),
|
||||
command_timeout_ms: args
|
||||
.command_timeout_ms
|
||||
.or(base.command_timeout_ms)
|
||||
.unwrap_or(30_000)
|
||||
.max(1),
|
||||
enroll_token_ttl_seconds: args
|
||||
.enroll_token_ttl_seconds
|
||||
.or(base.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.max(1),
|
||||
pid_file,
|
||||
ui_dist_dir,
|
||||
bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(),
|
||||
bootstrap_enroll_tokens,
|
||||
telemetry: WritableTelemetry {
|
||||
otlp_endpoint: args.telemetry_otlp_endpoint.clone(),
|
||||
otlp_endpoint: args
|
||||
.telemetry_otlp_endpoint
|
||||
.clone()
|
||||
.or(base_telemetry.otlp_endpoint),
|
||||
service_name: args
|
||||
.telemetry_service_name
|
||||
.clone()
|
||||
.or(base_telemetry.service_name)
|
||||
.unwrap_or_else(|| "wakey-control-plane".to_string()),
|
||||
json_logs: args.telemetry_json_logs,
|
||||
json_logs: args
|
||||
.telemetry_json_logs
|
||||
.or(base_telemetry.json_logs)
|
||||
.unwrap_or(false),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,12 +156,93 @@ pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
|
||||
command_timeout_ms: args.command_timeout_ms,
|
||||
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds,
|
||||
bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(),
|
||||
from_config: None,
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: false,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
};
|
||||
|
||||
write_init_config(&init)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::write_init_config;
|
||||
use crate::cli::InitConfigArgs;
|
||||
|
||||
fn temp_test_dir(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"wakey-cc-init-config-{name}-{}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_config_from_config_preserves_base_and_applies_overrides() {
|
||||
let dir = temp_test_dir("from-config");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let base = dir.join("base.toml");
|
||||
let out = dir.join("out.toml");
|
||||
std::fs::write(
|
||||
&base,
|
||||
r#"
|
||||
data_dir = "/tmp/wakey-base"
|
||||
bind = "127.0.0.1:9090"
|
||||
public_url = "https://old.example/"
|
||||
state_file = "old.sqlite3"
|
||||
command_timeout_ms = 1234
|
||||
enroll_token_ttl_seconds = 5678
|
||||
pid_file = "old.pid"
|
||||
ui_dist_dir = "/opt/wakey/ui"
|
||||
bootstrap_enroll_tokens = ["old-token"]
|
||||
|
||||
[telemetry]
|
||||
otlp_endpoint = "http://otel.example"
|
||||
service_name = "old-service"
|
||||
json_logs = true
|
||||
"#,
|
||||
)
|
||||
.expect("write base config");
|
||||
|
||||
write_init_config(&InitConfigArgs {
|
||||
config_file: Some(out.clone()),
|
||||
from_config: Some(base),
|
||||
stdout: false,
|
||||
data_dir: None,
|
||||
bind: None,
|
||||
public_url: Some("https://new.example/".to_string()),
|
||||
state_file: None,
|
||||
pid_file: None,
|
||||
ui_dist_dir: None,
|
||||
command_timeout_ms: Some(999),
|
||||
enroll_token_ttl_seconds: None,
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
})
|
||||
.expect("write derived config");
|
||||
|
||||
let rendered = std::fs::read_to_string(&out).expect("read derived config");
|
||||
let parsed: toml::Value = toml::from_str(&rendered).expect("parse derived config");
|
||||
assert_eq!(parsed["public_url"].as_str(), Some("https://new.example"));
|
||||
assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090"));
|
||||
assert_eq!(parsed["command_timeout_ms"].as_integer(), Some(999));
|
||||
assert_eq!(parsed["enroll_token_ttl_seconds"].as_integer(), Some(5678));
|
||||
assert_eq!(
|
||||
parsed["state_file"].as_str(),
|
||||
Some("/tmp/wakey-base/old.sqlite3")
|
||||
);
|
||||
assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
parsed["telemetry"]["service_name"].as_str(),
|
||||
Some("old-service")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,15 @@ fn control_api_routes() -> Router<AppState> {
|
||||
axum::routing::delete(api::revoke_enroll_token),
|
||||
)
|
||||
.route("/api/v1/control/state-stats", get(api::state_stats))
|
||||
.route(
|
||||
"/api/v1/control/fleet/devices",
|
||||
get(api::list_fleet_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/fleet/devices/refresh",
|
||||
post(api::refresh_fleet_devices),
|
||||
)
|
||||
.route("/api/v1/control/fleet/wake", post(api::wake_fleet_device))
|
||||
.route(
|
||||
"/api/v1/control/observations",
|
||||
get(api::list_agent_observations),
|
||||
@@ -101,6 +110,10 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/observations/history",
|
||||
get(api::list_agent_observation_history),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/agents/{agent_id}/observations/sync",
|
||||
post(api::request_agent_observation_sync),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices",
|
||||
get(api::list_known_devices).post(api::create_known_device),
|
||||
@@ -109,10 +122,18 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/devices/{device_id}",
|
||||
axum::routing::delete(api::forget_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/merge",
|
||||
post(api::merge_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers",
|
||||
post(api::attach_device_identifier),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers/{identifier_key}",
|
||||
axum::routing::delete(api::detach_device_identifier),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers/from-observation",
|
||||
post(api::attach_observation_identifier),
|
||||
|
||||
@@ -3,7 +3,7 @@ mod types;
|
||||
|
||||
pub use store::Store;
|
||||
pub use types::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
AlertState, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifierInput, KnownDevice,
|
||||
KnownDeviceInput,
|
||||
AgentDeviceObservation, AgentDeviceObservationEvent, AgentDeviceObservationInput,
|
||||
AgentDeviceObservationView, AlertState, AuditEvent, AuditEventFilter, AuditEventInput,
|
||||
DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput, KnownDeviceSummary,
|
||||
};
|
||||
@@ -337,6 +337,105 @@ mod tests {
|
||||
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]
|
||||
async fn merge_known_devices_moves_identifiers_and_deletes_source() {
|
||||
let (store, dir) = make_store().await;
|
||||
let target = 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:01".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("target should create");
|
||||
let source = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda duplicate".into(),
|
||||
pinned: false,
|
||||
notes: None,
|
||||
identifiers: vec![DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:02".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("source should create");
|
||||
|
||||
let merged = store
|
||||
.merge_known_devices(&target.device_id, &source.device_id)
|
||||
.await
|
||||
.expect("merge should succeed")
|
||||
.expect("target should remain");
|
||||
|
||||
assert_eq!(merged.device_id, target.device_id);
|
||||
assert_eq!(merged.identifiers.len(), 2);
|
||||
assert!(
|
||||
merged
|
||||
.identifiers
|
||||
.iter()
|
||||
.any(|identifier| identifier.value == "aa:bb:cc:dd:ee:02")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_known_device(&source.device_id)
|
||||
.await
|
||||
.expect("source lookup should work")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_observations_upsert_current_state_and_events() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
@@ -73,6 +73,75 @@ impl Store {
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn merge_known_devices(
|
||||
&self,
|
||||
target_device_id: &str,
|
||||
source_device_id: &str,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
if target_device_id == source_device_id {
|
||||
return self.get_known_device(target_device_id).await;
|
||||
}
|
||||
|
||||
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 known device merge transaction")?;
|
||||
|
||||
let target_exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
target_device_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.context("failed checking target known device existence")?;
|
||||
if target_exists == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let source_exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
source_device_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.context("failed checking source known device existence")?;
|
||||
if source_exists == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||
target_device_id,
|
||||
source_device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed moving source identifiers to target device")?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM known_devices WHERE device_id = ?1",
|
||||
source_device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting merged source known device")?;
|
||||
sqlx::query!(
|
||||
"UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
|
||||
now_i64,
|
||||
target_device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed updating merged target known device timestamp")?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing known device merge transaction")?;
|
||||
self.get_known_device(target_device_id).await
|
||||
}
|
||||
|
||||
pub async fn attach_device_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
@@ -127,6 +196,51 @@ impl Store {
|
||||
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))]
|
||||
pub async fn lookup_known_device_by_identifier(
|
||||
&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 ip = normalize_optional_text(input.ip.as_deref());
|
||||
let hostname = normalize_optional_text(input.hostname.as_deref());
|
||||
let identifier = mac
|
||||
.as_ref()
|
||||
.map(|value| format!("mac:{value}"))
|
||||
.or_else(|| ip.as_ref().map(|value| format!("ip:{value}")))
|
||||
let identifier = observation_identifier(&kind, mac.as_deref(), ip.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("observation requires mac or ip"))?;
|
||||
let observation_key = format!("agent:{agent_id}:{kind}:{identifier}");
|
||||
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(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
device_id: &str,
|
||||
|
||||
@@ -211,6 +211,8 @@ impl Store {
|
||||
r#"SELECT events.event_id as "event_id!",
|
||||
('agent:' || events.agent_id || ':' || events.kind || ':' ||
|
||||
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.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
ELSE ''
|
||||
@@ -239,6 +241,8 @@ impl Store {
|
||||
AND (?4 IS NULL OR events.ip = ?4)
|
||||
AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||
|
||||
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.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
ELSE ''
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::time::Instant;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, info_span, warn};
|
||||
use uuid::Uuid;
|
||||
use wakey_agent::protocol::{ErrorPayload, RequestId};
|
||||
use wakey_agent::protocol::{AgentObservation, ErrorPayload, RequestId, ServerMessage};
|
||||
|
||||
use crate::runtime::{AgentReply, AgentSession, AppState, SessionEvent};
|
||||
use crate::state::AuditEventInput;
|
||||
@@ -26,6 +26,10 @@ enum IncomingClientMessage {
|
||||
Heartbeat {
|
||||
agent_id: String,
|
||||
},
|
||||
Observations {
|
||||
agent_id: String,
|
||||
observations: Vec<AgentObservation>,
|
||||
},
|
||||
Result {
|
||||
request_id: RequestId,
|
||||
result: serde_json::Value,
|
||||
@@ -238,6 +242,7 @@ async fn process_agent_text(
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for auth success");
|
||||
}
|
||||
let _ = tx.send(SessionEvent::Message(ServerMessage::SyncObservations));
|
||||
}
|
||||
IncomingClientMessage::Heartbeat { agent_id } => {
|
||||
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||
@@ -246,6 +251,40 @@ async fn process_agent_text(
|
||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||
debug!(agent_id = %agent_id, "heartbeat received");
|
||||
}
|
||||
IncomingClientMessage::Observations {
|
||||
agent_id,
|
||||
observations,
|
||||
} => {
|
||||
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||
anyhow::bail!("observations for unauthenticated or mismatched agent");
|
||||
}
|
||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||
let inputs = observations
|
||||
.into_iter()
|
||||
.map(|observation| crate::state::AgentDeviceObservationInput {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip.map(|ip| ip.to_string()),
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect();
|
||||
match state
|
||||
.store
|
||||
.upsert_agent_observations(&agent_id, inputs)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
debug!(agent_id = %agent_id, accepted, "agent websocket observations accepted");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(agent_id = %agent_id, error = %err, "failed to store websocket observations");
|
||||
anyhow::bail!("failed to store observations: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
IncomingClientMessage::Result { request_id, result } => {
|
||||
let agent_id = authed_agent_id
|
||||
.as_deref()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-core"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-linux"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -136,4 +136,104 @@ mod tests {
|
||||
let _ = tokio::fs::remove_file(observation_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;
|
||||
}
|
||||
}
|
||||
@@ -218,14 +218,11 @@ pub async fn observe_neighbor_event(
|
||||
mac: Option<MacAddr>,
|
||||
ip: Option<IpAddr>,
|
||||
) -> io::Result<bool> {
|
||||
if !matches!(action, "add" | "update" | "old" | "remove") {
|
||||
// update and remove not emitted by hotplug
|
||||
let action = normalize_neighbor_action(action);
|
||||
if !is_neighbor_observation_action(&action) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(key) = mac
|
||||
.map(|value| format!("mac:{}", value.to_string().to_ascii_lowercase()))
|
||||
.or_else(|| ip.map(|value| format!("ip:{}", value)))
|
||||
else {
|
||||
let Some(key) = neighbor_observation_key(mac, ip) else {
|
||||
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 mut store = load_observation_store().await.unwrap_or_default();
|
||||
let mut changed = false;
|
||||
store
|
||||
let alias_keys = neighbor_observation_alias_keys(mac.as_deref(), ip, &key);
|
||||
let mut row = store
|
||||
.neighbors
|
||||
.entry(key.clone())
|
||||
.and_modify(|row| {
|
||||
if row.mac != mac
|
||||
|| row.ip != ip
|
||||
|| row.last_action != action
|
||||
|| row.last_seen_unix != now
|
||||
{
|
||||
row.mac = mac.clone();
|
||||
row.ip = ip;
|
||||
row.last_action = action.to_string();
|
||||
row.last_seen_unix = now;
|
||||
changed = true;
|
||||
}
|
||||
.remove(&key)
|
||||
.or_else(|| {
|
||||
alias_keys
|
||||
.iter()
|
||||
.find_map(|alias| store.neighbors.remove(alias))
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
.unwrap_or_else(|| {
|
||||
changed = true;
|
||||
ObservedNeighbor {
|
||||
key,
|
||||
mac,
|
||||
key: key.clone(),
|
||||
mac: mac.clone(),
|
||||
ip,
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
last_action: action.to_string(),
|
||||
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.last_action != action
|
||||
|| row.last_seen_unix != now
|
||||
{
|
||||
row.key = key.clone();
|
||||
row.mac = mac.clone();
|
||||
row.ip = ip;
|
||||
row.last_action = action.clone();
|
||||
row.last_seen_unix = now;
|
||||
changed = true;
|
||||
}
|
||||
store.neighbors.insert(key, row);
|
||||
if let (Some(mac), Some(ip)) = (mac.as_deref(), ip)
|
||||
&& !is_offline_neighbor_action(&action)
|
||||
{
|
||||
mark_replaced_neighbor_ips_removed(&mut store, mac, ip, now, &mut changed);
|
||||
}
|
||||
if changed {
|
||||
save_observation_store(&store).await?;
|
||||
}
|
||||
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(_))
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user