Compare commits
89
Commits
64cf5f9e69
...
v0.3.7
No files matched your search
+10
-1
@@ -1 +1,10 @@
|
||||
SQLX_OFFLINE=true
|
||||
# Live mode (local dev): query! macros validate every query against the real
|
||||
# schema on each build, so the .sqlx cache is only needed for DB-less builds.
|
||||
# Run scripts/prepare_sqlx_sqlite.sh once first — it creates and migrates the DB.
|
||||
# Use an absolute path so builds work from any working directory.
|
||||
# Replace the placeholder below with the DATABASE_URL printed by the script.
|
||||
DATABASE_URL=sqlite:///absolute/path/to/wakey-prepare.sqlite3
|
||||
|
||||
# Offline mode (CI / packaging): force the committed .sqlx cache.
|
||||
# Takes precedence over DATABASE_URL when set.
|
||||
#SQLX_OFFLINE=true
|
||||
@@ -18,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Ensure toolchain and targets
|
||||
shell: pwsh
|
||||
|
||||
@@ -11,6 +11,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -16,6 +16,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -20,3 +20,6 @@ target/
|
||||
.env
|
||||
|
||||
.codex
|
||||
|
||||
# sqlx prepare DB (created by scripts/prepare_sqlx_sqlite.sh)
|
||||
wakey-prepare.sqlite3*
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/vt100"]
|
||||
path = vendor/vt100
|
||||
url = https://github.com/ldlda/vt100-rust-1
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09db61a070a351a6255e21d5949285ccb19fca7f8f29a6c18dddac5495257a45"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2cd47bbce9b9ae2ebf39f31b12722bb8d19ef6d90ac634731932a4cefbde03b7"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "device_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3f24dfb51be2c3051dc8a8f732bd3e3fe9ca6b2649e1202d3725693b8011f269"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4f4329b0a47a00a3b881a1bc5cf8bb9855351714ff827ccff917b3e623191504"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "55340d50a29e0a2d324ed946b6fa6503fbd4ea0674bb7ec24e3426c5b0e5832f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6ce45cc94cbfdd948aad5fb986f9b5976c79bb63759bc9ecff1a7873e2d70137"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE known_devices SET display_name = ?1, updated_at_unix = ?2 WHERE device_id = ?3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "897b794a7f94af42b545d74a5b859850fc9935f40385061b029fab5892a2aded"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)\n ON CONFLICT (agent_id, device_key) DO UPDATE SET\n presence = excluded.presence,\n display_name = excluded.display_name,\n last_seen_unix = excluded.last_seen_unix",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a29f58cb7dd1b1bc510a0d04ecd1dbc7250824ba089dbc36ed9ac2e6dc920fd7"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b324587b94ee2e98dc8eff5f6735303a2784b74f49068ce8e946cedcd8d12c4e"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd1666cea8840df04ddcdc8a0c42b9b7771f9bf63c45613c4b6f8b86be479d70"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 12
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM audit_events WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ca612fee8c80f815b33dbc088cc7e0a7552f1f4e38390959e14ea5cc765bf2c7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_devices WHERE last_seen_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cc4b885c31f965013ccaa6f8ae33f6c8d68898654fc0873682c8212339088306"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM alert_transitions WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce850a32c8702595bbc3207bf86d53209c48be145562192b3a8f84903d39eac1"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d442949dce7a4b9a8af97dc34fc34608029564740ad327c525c7f1466bc18a11"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc7056b7421dcc83648cbd360273ebcce1156ce0f7da2d39f054025ab9b66b6b"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e83a43217a3bfaacd2f5e76df1fd05447558646fb2b47963751a7ab53b3a99b6"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f5a815d4d6e3d088aba3cd01607764d89afaae6108aad0d17e93b1bfc9f7e772"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Wakey
|
||||
|
||||
Wakey models devices discovered on a local network and the control-plane view used to wake and manage them across agents.
|
||||
|
||||
## Language
|
||||
|
||||
**Device**:
|
||||
An operator-facing aggregate that represents one discovered network identity candidate.
|
||||
_Avoid_: Host, machine, route
|
||||
|
||||
**Device Summary**:
|
||||
The derived names, MACs, IPs, and presence shown on a device aggregate.
|
||||
_Avoid_: Source of truth, route data
|
||||
|
||||
**Endpoint**:
|
||||
One observed network contact point for a device, including the agent/source, optional MAC, optional IP, presence, and observation time.
|
||||
_Avoid_: Raw observation, route, IP row
|
||||
|
||||
**Endpoint Source**:
|
||||
The typed origin of an endpoint, such as neighbor data, DHCP lease data, or a hotplug hook.
|
||||
_Avoid_: String kind, source label
|
||||
|
||||
**Observation Fact**:
|
||||
Debug evidence copied from local observation inputs before it is interpreted into endpoints.
|
||||
_Avoid_: Endpoint, identifier, source of truth
|
||||
|
||||
**Presence**:
|
||||
The current reachability interpretation for a device or endpoint.
|
||||
_Avoid_: Endpoint state, status
|
||||
|
||||
**Wake Route**:
|
||||
An endpoint that has enough information and agent availability to attempt waking a device.
|
||||
_Avoid_: Device, endpoint when not wakeable
|
||||
|
||||
**Known Device**:
|
||||
A device identity manually curated by the operator.
|
||||
_Avoid_: Auto-pinned device, saved observation
|
||||
|
||||
**Identifier**:
|
||||
A manually approved MAC or IP value that links an observed device or endpoint to a known device.
|
||||
_Avoid_: Observation key, route id
|
||||
|
||||
**Endpoint Key**:
|
||||
The typed identity of endpoint evidence, based on source and optional MAC/IP.
|
||||
_Avoid_: Stringly typed route id
|
||||
|
||||
**Agent Endpoint Key**:
|
||||
An endpoint key scoped to the agent that observed it.
|
||||
_Avoid_: Concatenated agent/source/MAC/IP string
|
||||
|
||||
**Agent**:
|
||||
A fleet member that observes local network state and sends device snapshots to the control plane.
|
||||
_Avoid_: Router when referring to the software actor
|
||||
|
||||
**Agent Host**:
|
||||
The machine running an agent, including host identity and runtime metadata.
|
||||
_Avoid_: Device when referring to the agent machine
|
||||
|
||||
**Agent Capability**:
|
||||
Something an agent host can observe or do on the network.
|
||||
_Avoid_: Router flag
|
||||
|
||||
**Terminal Capability**:
|
||||
An explicitly enabled agent capability that allows the agent host to create remote PTY sessions.
|
||||
_Avoid_: Command runner, implicit root access
|
||||
|
||||
**Terminal Session**:
|
||||
One operator attachment to one PTY process hosted by an agent.
|
||||
_Avoid_: Command, SSH session, stored shell
|
||||
|
||||
**Terminal Relay**:
|
||||
The control plane's in-memory pairing of one operator terminal socket with one outbound agent terminal socket.
|
||||
_Avoid_: Terminal session storage, terminal emulator
|
||||
|
||||
**Interface Telemetry**:
|
||||
Current operational measurements for a network interface visible to an agent.
|
||||
_Avoid_: Endpoint state, device presence
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Device** has zero or more **Endpoints**.
|
||||
- A **Device Summary** is derived from a **Device** and its **Endpoints**.
|
||||
- Local source rows are grouped into **Devices** by endpoint-derived **DeviceId**, not by exact **Endpoint Key**.
|
||||
- Within one complete local snapshot, MAC+IP endpoints may absorb IP-only endpoints with the same IP; this merge is not remembered across snapshots unless the operator creates an **Identifier**.
|
||||
- An **Endpoint** belongs to exactly one **Agent**.
|
||||
- An **Endpoint** has one **Endpoint Key**.
|
||||
- An **Agent Endpoint Key** combines an **Agent** with an **Endpoint Key**.
|
||||
- An **Endpoint** may have both MAC and IP, MAC only, or IP only.
|
||||
- An **Endpoint** has exactly one **Endpoint Source**.
|
||||
- An **Endpoint** has one **Presence**.
|
||||
- An **Endpoint** may name the network interface where it was observed.
|
||||
- An **Observation Fact** may explain why an **Endpoint** exists, but does not define identity or wakeability.
|
||||
- A **Wake Route** is derived from exactly one **Endpoint**.
|
||||
- A **Wake Route** may carry the interface from its source **Endpoint**.
|
||||
- A **Known Device** has zero or more **Identifiers**.
|
||||
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
|
||||
- An **Agent** runs on one **Agent Host**.
|
||||
- An **Agent Capability** describes what an **Agent Host** can observe or do.
|
||||
- **Terminal Capability** is an **Agent Capability** and must be explicitly enabled before an agent can host a **Terminal Session**.
|
||||
- A **Terminal Session** owns one PTY and is independent of one-shot agent commands.
|
||||
- A **Terminal Relay** pairs exactly one operator socket with exactly one agent socket for a **Terminal Session**.
|
||||
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "This **Device** has three IPs. Which one should the UI wake?"
|
||||
> **Domain expert:** "Do not infer that from the **Device** summary. Use the **Wake Routes** derived from its **Endpoints**."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "route" was used for both observed network contact points and wakeable paths — resolved: use **Endpoint** for observed contact points and **Wake Route** only when the endpoint can be used for wake.
|
||||
- "device" was used for both an identity aggregate and each MAC/IP pair — resolved: use **Device** for the aggregate and **Endpoint** for each observed MAC/IP/source/state tuple.
|
||||
- `Device.ips` and `Device.macs` are true summary values, but they are not authoritative for wake, freshness, or per-IP state decisions — resolved: use **Endpoint** for those decisions.
|
||||
- MAC-only and IP-only observations are still **Endpoints** — resolved: **Wake Routes** require a MAC and a connected **Agent**, while IP is optional route detail.
|
||||
- Endpoint-specific states such as "removed" or "stale" are not separate concepts yet — resolved: use **Presence** until a distinction appears that presence cannot express cleanly.
|
||||
- Endpoint origin is not a free string — resolved: use **Endpoint Source** for domain logic and reserve raw strings for debug facts only.
|
||||
- Local observation file rows map naturally to **Observation Facts** — resolved: keep them for debugging, but do not use them as the operational model.
|
||||
- Offline **Endpoints** remain part of the API model — resolved: UI may collapse them from the primary view, but the API should still expose them for details, copy, and debugging.
|
||||
- **Device Summary** IPs come only from concrete live-ish sources such as neighbor table rows and current DHCP lease rows — resolved: hotplug hook memory creates **Endpoints** and **Observation Facts**, but does not directly inflate summary IPs.
|
||||
- Router-ness is not a boolean domain concept — resolved: model **Agent Capabilities** and **Agent Host** metadata instead.
|
||||
- **Presence** ordering should treat **Unknown** as non-voting/no evidence, weaker than **Offline** — resolved: concrete offline evidence should not be overridden by unknown hook or DHCP ambiguity.
|
||||
- **Device** presence is derived from all **Endpoints**, not only summary-eligible endpoints.
|
||||
- Current DHCP leases are summary-eligible **Endpoints** with **Unknown** presence; they may support wake when a MAC is present, but they do not prove reachability.
|
||||
- DHCP hook events, including removal, indicate **Unknown** because they describe lease churn rather than reachability.
|
||||
- Neighbor table and neighbor hook events may indicate **Offline** when they report failed or removed reachability.
|
||||
- **Presence** and wakeability are separate: an **Offline** endpoint may still produce a **Wake Route** if it has a MAC and a connected **Agent**.
|
||||
- **Endpoint** does not store wakeability; wakeability is derived from endpoint data plus **Agent** runtime and capability context.
|
||||
- Preferred **Wake Routes** are ordered by connected agent, wakeability, having an IP, presence quality, recency, then source quality.
|
||||
- **Endpoint** evidence preserves source-specific rows; equivalent **Wake Routes** may ignore source when they target the same agent, MAC, and IP.
|
||||
- The control plane stores **Endpoints** as structured data so fleet search, identity matching, and wake route selection do not depend on parsing opaque device JSON.
|
||||
- Summary MAC/IP collections are derived from **Endpoints**; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
|
||||
- API arrays should be domain-sorted by default; UI may sort top-level fleet rows for presentation, but route ranking and recommended route selection are backend-owned.
|
||||
- The main fleet API embeds **Endpoints** in each **Device** response; a separate endpoint API is unnecessary until payload size or access patterns require it.
|
||||
- The control plane stores current per-agent endpoint snapshots, not endpoint event history.
|
||||
- Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement; default retention is 7 days unless configured otherwise.
|
||||
- Live-source **Endpoints** use snapshot time as their observed time; hook-derived **Endpoints** use the hook row's recorded last-seen time.
|
||||
- **DeviceId**, **Endpoint Key**, and **Agent Endpoint Key** are typed concepts; string keys are boundary serialization only.
|
||||
- **DeviceId** is derived from a device's **Endpoints**: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent.
|
||||
- Hostnames are endpoint evidence/display data, not part of **Endpoint Key** identity.
|
||||
- Interface names are endpoint metadata, not part of **Endpoint Key** identity; multi-segment routers may require scoped endpoint identity later if the same source/MAC/IP has different meaning on different interfaces.
|
||||
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
|
||||
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
|
||||
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
|
||||
- **Terminal Sessions** are not agent commands — resolved: command dispatch remains request/result, while terminals use dedicated streaming sockets.
|
||||
- **Terminal Relay** is ephemeral control-plane state — resolved: terminal bytes and scrollback are not stored or audited.
|
||||
- Terminal input, output, and terminal-generated control sequences are byte streams; only lifecycle and resize operations are structured terminal protocol messages.
|
||||
Generated
+255
-194
@@ -97,6 +97,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -136,6 +142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -233,12 +240,6 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -475,24 +476,6 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -514,10 +497,10 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"document-features",
|
||||
"parking_lot 0.12.5",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"winapi",
|
||||
]
|
||||
@@ -747,16 +730,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -813,7 +786,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"lock_api",
|
||||
"parking_lot 0.12.5",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -862,15 +835,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1050,6 +1014,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-cache-semantics"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0603b99309a1e404c2c0945650c0f775b50bb72ac90ae570cb93f9ff05d9efe7"
|
||||
dependencies = [
|
||||
"http",
|
||||
"reqwest",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
@@ -1068,6 +1043,22 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||
|
||||
[[package]]
|
||||
name = "humantime-serde"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c"
|
||||
dependencies = [
|
||||
"humantime",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -1309,15 +1300,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1421,6 +1403,24 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature",
|
||||
"simple_asn1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1470,7 +1470,7 @@ version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
@@ -1617,7 +1617,7 @@ version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"libc",
|
||||
"log",
|
||||
"netlink-packet-core",
|
||||
@@ -1656,7 +1656,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1668,7 +1668,7 @@ version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1683,6 +1683,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -1773,9 +1783,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.31.0"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
|
||||
checksum = "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1786,42 +1796,39 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.31.0"
|
||||
name = "opentelemetry-appender-tracing"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
||||
checksum = "1f01f1c53c46ce80e5fd8cb7a646f13766fd8f315a8a5b75b9bad59a08d2c5c7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"http",
|
||||
"opentelemetry",
|
||||
"reqwest 0.12.28",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-otlp"
|
||||
version = "0.31.1"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
|
||||
checksum = "0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a"
|
||||
dependencies = [
|
||||
"http",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
"opentelemetry-proto",
|
||||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"reqwest 0.12.28",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"tonic-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.31.0"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
|
||||
checksum = "d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
@@ -1832,19 +1839,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.31.0"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
|
||||
checksum = "372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"portable-atomic",
|
||||
"rand 0.9.4",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1859,17 +1865,6 @@ version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
|
||||
dependencies = [
|
||||
"instant",
|
||||
"lock_api",
|
||||
"parking_lot_core 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1877,21 +1872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core 0.9.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"instant",
|
||||
"libc",
|
||||
"redox_syscall 0.2.16",
|
||||
"smallvec",
|
||||
"winapi",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1913,6 +1894,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -1987,6 +1978,12 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2030,6 +2027,27 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "procfs"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"procfs-core",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "procfs-core"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.3"
|
||||
@@ -2053,6 +2071,25 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
|
||||
dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pty-process"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2189,22 +2226,13 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2213,7 +2241,7 @@ version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2253,40 +2281,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
@@ -2334,7 +2328,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -2394,7 +2388,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -2473,7 +2467,7 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2557,7 +2551,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -2696,7 +2690,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot 0.12.5",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
@@ -2769,28 +2763,24 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "sled"
|
||||
version = "0.34.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"fs2",
|
||||
"fxhash",
|
||||
"libc",
|
||||
"log",
|
||||
"parking_lot 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -2923,7 +2913,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"crc",
|
||||
@@ -2966,7 +2956,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
@@ -3344,8 +3334,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -3354,6 +3346,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -3374,6 +3367,17 @@ dependencies = [
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-types"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost-types",
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -3399,7 +3403,7 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -3489,9 +3493,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-opentelemetry"
|
||||
version = "0.32.1"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
|
||||
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"opentelemetry",
|
||||
@@ -3633,6 +3637,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -3698,9 +3708,19 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3721,85 +3741,112 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"http",
|
||||
"humantime-serde",
|
||||
"macaddr",
|
||||
"nix 0.31.2",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"wakey",
|
||||
"wakey-core",
|
||||
"wakey-linux",
|
||||
"wakey-vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"http-cache-semantics",
|
||||
"humantime-serde",
|
||||
"jsonwebtoken",
|
||||
"macaddr",
|
||||
"nix 0.31.2",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sled",
|
||||
"sqlx",
|
||||
"time",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"wakey-agent",
|
||||
"wakey-core",
|
||||
"wakey-linux",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"humantime",
|
||||
"macaddr",
|
||||
"opentelemetry",
|
||||
"opentelemetry-appender-tracing",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry-proto",
|
||||
"opentelemetry_sdk",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
"lda-ipjs",
|
||||
"macaddr",
|
||||
"nix 0.31.2",
|
||||
"procfs",
|
||||
"pty-process",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wakey-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-vt100"
|
||||
version = "0.16.2-wakey.2"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -3932,7 +3979,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
@@ -4437,7 +4484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4542,6 +4589,20 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -31,6 +31,7 @@ strip = true
|
||||
|
||||
[workspace]
|
||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||
exclude = ["vendor/vt100"]
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
|
||||
@@ -146,7 +146,7 @@ Relative paths in config are resolved under `data_dir`.
|
||||
Legacy sled state can be imported explicitly:
|
||||
|
||||
```sh
|
||||
wakey-control-plane import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /var/lib/wakey-control-plane/state.sqlite3
|
||||
wakey-control-plane migrate-sqlite-state --from /var/lib/wakey-control-plane/legacy.sqlite3 --to /var/lib/wakey-control-plane/state.sqlite3
|
||||
```
|
||||
|
||||
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
||||
@@ -207,10 +207,23 @@ Control-plane admin API includes token management endpoints:
|
||||
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
||||
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
||||
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
|
||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots, recent transitions, and evaluator health)
|
||||
- `GET /api/v1/control/terminals/events/ws` (websocket session-list invalidations)
|
||||
- `DELETE /api/v1/control/agents/{agent_id}`
|
||||
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
|
||||
|
||||
Alert evaluation runs once per control plane every five seconds. The alerts GET
|
||||
returns `503 alerts_unavailable` until a successful evaluation, after evaluation
|
||||
failure, or when its snapshot is stale. The existing alerts stream also sends
|
||||
`alerts_health` messages with `available` and an optional `reason`; the UI keeps
|
||||
the last results visible with a stale warning until recovery. Custom alert-rule
|
||||
query overrides are rejected with `400 unsupported_alert_override`.
|
||||
|
||||
Terminal session events carry only `sessions_changed` invalidations. Clients
|
||||
refetch the session list after changes or reconnection and retain a 15-second
|
||||
fallback refresh. Events do not grant session ownership; attachment still uses
|
||||
the existing server-side locking and scoped credentials.
|
||||
|
||||
If commands still appear silent, verify both processes are running with `-v`
|
||||
and that `RUST_LOG` is not overriding to a stricter level.
|
||||
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
# Caddy template for wakey-control-plane with Cloudflare Access.
|
||||
#
|
||||
# Security model:
|
||||
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
|
||||
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
||||
# - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay WS
|
||||
# - Private admin surface: root, /ui/* and /api/v1/control/* (verified Access JWT)
|
||||
# Configure CC's [access] section and loopback bind before enabling this template.
|
||||
# Never expose /internal/auth/verify through this virtual host.
|
||||
#
|
||||
# Replace cp.example.com with your public control-plane domain.
|
||||
# Replace wakey.ldlda.com and the loopback upstream port for your deployment.
|
||||
|
||||
wakey.ldlda.com {
|
||||
encode zstd gzip
|
||||
|
||||
# Public agent-facing endpoints.
|
||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws /api/v1/agent/terminals/*
|
||||
handle @public {
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
}
|
||||
|
||||
# Admin surface requires Cloudflare Access headers.
|
||||
@admin path /ui* /api/v1/control/*
|
||||
@cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+
|
||||
# Applies to HTTP requests and operator WebSocket upgrades, not WS frames.
|
||||
@admin path / /ui* /api/v1/control/*
|
||||
|
||||
handle @admin {
|
||||
handle @cf_access {
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
forward_auth 127.0.0.1:6767 {
|
||||
uri /internal/auth/verify
|
||||
}
|
||||
respond "forbidden" 403
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
}
|
||||
|
||||
# Deny unknown paths by default.
|
||||
|
||||
@@ -12,6 +12,7 @@ ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-cont
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
Environment=RUST_LOG=info
|
||||
EnvironmentFile=-/etc/wakey-control-plane/environment
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,164 @@
|
||||
# Core Endpoints and Wake Routes
|
||||
|
||||
Wakey will model observed network contact points as **Endpoints** in `wakey-core`, and derive **Wake Routes** from those endpoints instead of reconstructing routes from flattened device summary MAC/IP arrays. `Device.ips` and `Device.macs` remain true summary fields for operator display and compatibility, but endpoint data is authoritative for wake, freshness, source, per-IP state, and route selection.
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The previous model stored a `Device` aggregate with summary `macs`, `ips`, `presence`, and raw observation facts. The control plane then built fleet wake routes from combinations like first MAC plus first IP. That lost source, presence, interface, and MAC/IP-pair evidence, especially when hook memory contained removed IPs or when several sources described the same address differently.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a typed `DeviceEndpoint` concept in `wakey-core`. A device snapshot sent by an agent contains devices with endpoints already attached. The control plane stores structured endpoint rows and derives fleet summaries and wake routes from those rows.
|
||||
|
||||
Endpoint shape:
|
||||
|
||||
```rust
|
||||
DeviceEndpoint {
|
||||
key: EndpointKey,
|
||||
hostname: Option<String>,
|
||||
interface: Option<String>,
|
||||
presence: Presence,
|
||||
first_seen_unix: Option<u64>,
|
||||
last_seen_unix: Option<u64>,
|
||||
}
|
||||
|
||||
EndpointKey {
|
||||
source: EndpointSource,
|
||||
mac: Option<MacAddr>,
|
||||
ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
AgentEndpointKey {
|
||||
agent_id: String,
|
||||
endpoint: EndpointKey,
|
||||
}
|
||||
```
|
||||
|
||||
`EndpointKey` must contain at least one of MAC or IP. Hostname and interface are endpoint evidence/metadata, not identity. `AgentEndpointKey` may use `serde(flatten)` for wire shape, but hash values are never durable storage keys. String keys are boundary serialization only.
|
||||
|
||||
## Source and Presence Rules
|
||||
|
||||
Endpoint sources are typed domain variants, not free strings:
|
||||
|
||||
```text
|
||||
Neighbor
|
||||
DhcpLease
|
||||
HookNeighbor
|
||||
HookDhcp
|
||||
```
|
||||
|
||||
Presence answers “is this reachable?”, not “can this be woken?” The presence order is:
|
||||
|
||||
```text
|
||||
Unknown < Offline < LikelyOnline < Online
|
||||
```
|
||||
|
||||
Source mapping:
|
||||
|
||||
```text
|
||||
Neighbor reachable/permanent -> Online
|
||||
Neighbor stale -> LikelyOnline
|
||||
Neighbor failed/remove-like evidence -> Offline
|
||||
Other neighbor states -> Unknown
|
||||
|
||||
Current DHCP lease -> Unknown
|
||||
Hook DHCP add/update/remove -> Unknown
|
||||
Hook neighbor add/update/old -> LikelyOnline
|
||||
Hook neighbor remove -> Offline
|
||||
```
|
||||
|
||||
Current DHCP leases are summary-eligible and may support wake if they include a MAC, but they do not prove reachability. Hook memory creates endpoints and observation facts, but does not directly inflate summary IPs.
|
||||
|
||||
## Summary, Presence, and UI
|
||||
|
||||
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
|
||||
|
||||
Fleet display labels prefer the saved device name, then a hostname, then a MAC,
|
||||
then an IP. Generated address labels use `Device: <address>` without presence
|
||||
prefixes; the badge carries presence. An inactive IP is only a naming fallback
|
||||
when no name or MAC exists, never evidence of a current address or a wake route.
|
||||
Offline neighbor evidence does not establish that a DHCP address has expired or
|
||||
been reassigned. If a concrete source observes that endpoint again, its eligible
|
||||
IP returns to the summary; manually remembered identifiers are not removed by this.
|
||||
|
||||
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
|
||||
|
||||
The control plane derives stored snapshot presence and fleet presence from the
|
||||
maximum endpoint signal whenever endpoints exist. A cached summary must not
|
||||
override endpoint evidence; endpoint-less legacy rows fall back to their stored
|
||||
summary. Presence describes the last observation, not guaranteed current
|
||||
reachability. Agent disconnection does not rewrite endpoint presence or expire
|
||||
the retained snapshot.
|
||||
|
||||
The main fleet API embeds endpoints inside each fleet device. API responses expose all endpoints, including offline and unknown endpoints. The UI may collapse offline/unknown endpoints in the primary row, but details must expose them for copy/debug/history.
|
||||
|
||||
## Wakeability and Wake Route Ordering
|
||||
|
||||
Wakeability is derived, not stored on `DeviceEndpoint`.
|
||||
|
||||
An endpoint is wakeable when:
|
||||
|
||||
```text
|
||||
endpoint has a MAC
|
||||
agent is connected
|
||||
agent has wake capability
|
||||
endpoint source is allowed for wake route derivation
|
||||
```
|
||||
|
||||
For now, a connected wakey-agent is assumed to have wake capability. Presence does not gate wakeability: an `Offline` endpoint may still produce a wake route, because waking powered-off devices is the core use case.
|
||||
|
||||
Preferred wake route order:
|
||||
|
||||
```text
|
||||
connected agent
|
||||
wakeable route
|
||||
has IP
|
||||
presence quality
|
||||
newest last_seen
|
||||
source quality
|
||||
```
|
||||
|
||||
Presence quality for route ranking follows:
|
||||
|
||||
```text
|
||||
Online > LikelyOnline > Offline > Unknown
|
||||
```
|
||||
|
||||
Source quality follows:
|
||||
|
||||
```text
|
||||
Neighbor > DhcpLease > HookNeighbor > HookDhcp
|
||||
```
|
||||
|
||||
Equivalent wake routes may ignore endpoint source when they target the same agent, MAC, and IP. Endpoint evidence preserves source-specific rows; route grouping may deduplicate equivalent targets.
|
||||
|
||||
## Device and Identity Rules
|
||||
|
||||
`DeviceId` remains the typed observed identity enum:
|
||||
|
||||
```rust
|
||||
DeviceId::Mac(MacAddr)
|
||||
DeviceId::Ip(IpAddr)
|
||||
```
|
||||
|
||||
`DeviceId` is derived from endpoints: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent. Local source rows are grouped into devices by endpoint-derived `DeviceId`, not exact `EndpointKey`.
|
||||
|
||||
Within a single complete local snapshot, a MAC+IP endpoint may absorb an IP-only endpoint with the same IP. This merge is not remembered across snapshots unless the operator creates a known-device identifier.
|
||||
|
||||
Known-device identifiers are source-independent ownership claims over MAC or permanent IP values. Wakey does not infer whether an IP is permanent, static, or reserved; the UI should warn the operator when attaching an IP identifier.
|
||||
|
||||
## Control Plane Storage
|
||||
|
||||
The control plane stores current per-agent endpoint snapshots as structured columns, not opaque `Device` JSON and not endpoint event history. Summary MAC/IP collections are derived from endpoints; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
|
||||
|
||||
Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement. The default retention is 7 days unless configured otherwise. Live-source endpoints use snapshot time as their observed time; hook-derived endpoints use the hook row's recorded last-seen time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Wake route construction moves away from `macs.first()` / `ips.first()` guessing.
|
||||
- The agent/core layer owns source interpretation because it still has local context.
|
||||
- The control plane can search, filter, match known identifiers, and rank wake routes from structured endpoint rows.
|
||||
- UI can show clean summaries while keeping complete endpoint evidence in details.
|
||||
- This preserves “wake an offline machine by MAC” without pretending that offline means not wakeable.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Remote Terminal Sessions
|
||||
|
||||
Wakey will provide opt-in remote terminal access by pairing a browser terminal WebSocket with a dedicated outbound agent terminal WebSocket. The existing agent WebSocket remains the control channel and does not carry terminal byte streams.
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The control plane already maintains an authenticated outbound WebSocket from each connected agent. Its command protocol is intentionally request/result shaped: the control plane sends one command, the agent executes it, and one result completes the pending request.
|
||||
|
||||
An interactive terminal has different semantics. It is long-lived, bidirectional, byte-oriented, and must handle window resizing, process exit, disconnect cleanup, and backpressure. Treating it as an ordinary command would either block the agent session loop or turn the main agent WebSocket into a general stream multiplexer. It would also allow terminal output to compete with heartbeats, snapshots, and wake commands.
|
||||
|
||||
Agents commonly run behind NAT and must not expose an inbound listener. Terminal connections therefore remain agent-initiated and outbound.
|
||||
|
||||
## Decision
|
||||
|
||||
Use one dedicated agent WebSocket and one dedicated operator WebSocket for each terminal session. The control plane holds an in-memory terminal relay that pairs those sockets.
|
||||
|
||||
Session establishment follows this sequence:
|
||||
|
||||
1. An authenticated operator requests a terminal for a connected, terminal-capable agent.
|
||||
2. The control plane creates an ephemeral terminal ID plus short-lived, single-use attachment credentials.
|
||||
3. The control plane sends an open-terminal control message over the existing authenticated agent WebSocket.
|
||||
4. The agent opens a new outbound terminal WebSocket to the control plane and authenticates it for that terminal ID.
|
||||
5. The browser opens the corresponding protected operator terminal WebSocket.
|
||||
6. The control plane pairs the sockets and relays terminal frames while an operator is attached.
|
||||
|
||||
The main agent WebSocket carries terminal creation and cancellation control only. It does not carry PTY input or output.
|
||||
|
||||
## PTY Ownership
|
||||
|
||||
The agent owns the PTY, shell process, process group, and all process cleanup. The control plane never spawns or emulates a shell.
|
||||
|
||||
Use `pty-process` with its async feature as the initial PTY abstraction because it provides:
|
||||
|
||||
- Tokio `AsyncRead` and `AsyncWrite` integration;
|
||||
- owned read and write halves for concurrent tasks;
|
||||
- terminal resizing;
|
||||
- child session-leader and controlling-terminal setup; and
|
||||
- a small implementation built on `rustix`.
|
||||
|
||||
The PTY wrapper is cross-compiled for `armv7-unknown-linux-musleabihf` and its input, output, resize, and exit test has passed on the target router. That focused test remains part of the remote compatibility workflow. Wakey does not call `forkpty` or add its own unsafe PTY implementation.
|
||||
|
||||
The executable, working directory, environment, UID, and GID are agent-controlled configuration. The browser cannot supply an arbitrary executable or process environment.
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
WebSocket frame type separates terminal data from terminal control:
|
||||
|
||||
- Binary frames carry raw PTY input and output bytes.
|
||||
- Text JSON frames carry lifecycle, error, and resize messages.
|
||||
|
||||
The terminal control vocabulary is deliberately small:
|
||||
|
||||
```text
|
||||
operator -> agent: resize, close
|
||||
control plane -> agent: snapshot
|
||||
agent -> operator: ready, exited, error
|
||||
```
|
||||
|
||||
Initial terminal dimensions are part of session attachment. Resize carries rows and columns; pixel dimensions may be added later if a demonstrated program requires them.
|
||||
|
||||
Terminal behavior such as Ctrl-C, Ctrl-Z, Ctrl-D, cursor keys, mouse reporting, bracketed paste, colors, and terminal-title changes remains in the byte stream. The control plane does not parse ANSI escape sequences. The browser terminal emulator renders output and owns connected-session scrollback. The agent also feeds PTY output into a `vt100` parser so it can reconstruct the current visible screen and terminal input modes after detachment. Agent PTYs advertise `TERM=xterm-256color` and `COLORTERM=truecolor` because xterm.js is the actual frontend; applications may therefore negotiate xterm mouse reporting without a Wakey-specific mouse protocol.
|
||||
|
||||
WebSockets already provide ordered, reliable delivery, so terminal frames do not carry sequence numbers in the initial protocol.
|
||||
|
||||
## Lifecycle and Cleanup
|
||||
|
||||
Terminal sessions allow only one attached operator. Their lifetime belongs to the agent process, not to a browser route or a particular control-plane connection.
|
||||
|
||||
When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane does not retain terminal output.
|
||||
|
||||
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
|
||||
|
||||
The protected `/api/v1/control/terminals/events/ws` stream sends `sessions_changed` invalidations when the volatile session catalog or attachment ownership changes. These events contain no terminal bytes or attachment credentials: browsers refetch the ordinary session list, coalescing bursts and repeating a fetch if another event arrives during it. Subscribing and reconnecting also invalidate the list. The 15-second refresh remains a recovery fallback; the stream is a freshness hint, never an authorization or locking mechanism.
|
||||
|
||||
The React page composes three custom hooks rather than owning every resource itself. `useXterm` owns the single renderer, its addons, fitting, fonts, and display operations. `useTerminalSessions` owns the catalog subscription, coalesced refreshes, and fallback timer. `useTerminalSession` owns active attachment, input, restoration, and session operations. The attachment hook waits for renderer readiness; stable renderer operations prevent ordinary state updates from rebuilding the terminal or reconnecting its transport. Each hook cleans up its own resources on unmount, which detaches the browser without deleting the agent-owned PTY.
|
||||
|
||||
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 1,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session. The connected browser independently retains up to 10,000 rows because that memory belongs to the operator device rather than the agent host.
|
||||
|
||||
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
|
||||
|
||||
The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into the agent-owned terminal parser while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite.
|
||||
|
||||
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
|
||||
|
||||
The agent configuration sets `terminal.session_ttl` (for example, `"12h"`) for newly created PTYs. The default is 12 hours; `"0s"` disables automatic expiry. Legacy `session_ttl_seconds` is accepted with a deprecation warning. The wire protocol still uses integer seconds, so fractional-second TTLs are rejected. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped.
|
||||
|
||||
An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design.
|
||||
|
||||
The agent reports a normal exit status or terminating signal when available. The UI must distinguish an exited shell from a transport failure.
|
||||
|
||||
## Backpressure and Resource Limits
|
||||
|
||||
Terminal transport must not use unbounded queues.
|
||||
|
||||
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 1,000-row reconnect scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||
|
||||
While a relay is attached, saturation applies lossless backpressure instead of dropping terminal bytes or disconnecting the relay. The agent retains the frame that discovered saturation and pauses further PTY reads until bounded relay capacity returns. Input, resize, close, lifecycle, and reconnect handling remain active while output is backpressured. Reconnect snapshots use the same ordered output path and duplicate snapshot requests are coalesced while one is pending. This is required for terminal protocols such as sixel, where dropping one output frame corrupts the rest of the stream.
|
||||
|
||||
When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and snapshot traffic responsive while commands produce sustained output.
|
||||
|
||||
The implementation also enforces:
|
||||
|
||||
- maximum terminal frame size;
|
||||
- per-agent concurrent-session limits;
|
||||
- idle and absolute session timeouts;
|
||||
- bounded parsed terminal state;
|
||||
- bounded control-plane relay buffers; and
|
||||
- cleanup when any relay task exits unexpectedly.
|
||||
|
||||
Exact limits are configuration and may be tuned from router testing. The bounded behavior is an architectural requirement.
|
||||
|
||||
## Security and Audit
|
||||
|
||||
Remote terminal access is root-equivalent on agents that run as root. It is therefore an explicit agent capability and is disabled by default.
|
||||
|
||||
Operator terminal endpoints belong to the protected control API surface. Session identifiers are not credentials. Agent and operator attachment credentials are short-lived, scoped to one terminal session, and single-use. The operator WebSocket must enforce the same-origin/protected-control assumptions used when the session was created.
|
||||
|
||||
Audit records include terminal request, open, ready, disconnect, close, timeout, and exit metadata. Wakey never stores or audits terminal input, terminal output, command history, or scrollback.
|
||||
|
||||
Terminal capability and session state are separate from device inventory, known-device identity, wake routes, and the debug Command Runner.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
Persistence across agent restart or machine reboot is not supported. A later design would need an external process owner, reconnectable local IPC, and explicit secret handling; persisted metadata alone cannot recover a PTY.
|
||||
|
||||
File transfer, multi-operator attachment, terminal recording, shell-history storage, and arbitrary process launch are also deferred.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Multiplex terminal bytes over the main agent WebSocket
|
||||
|
||||
Rejected for the initial version. It requires refactoring the current request/result session into a general concurrent writer and stream router, adds head-of-line coupling with fleet traffic, and requires terminal-byte framing within a shared connection.
|
||||
|
||||
### Model a terminal as a long-running agent command
|
||||
|
||||
Rejected. Commands have one result and a timeout; terminals have continuous bidirectional traffic and independent lifecycle.
|
||||
|
||||
### Accept an inbound connection on the agent
|
||||
|
||||
Rejected. Agents run behind NAT and should not expose a new network service.
|
||||
|
||||
### Use `portable-pty`
|
||||
|
||||
Viable, but not selected initially. Its cross-platform abstraction is broader than the Linux agent requires and its reader/writer API would need blocking-task bridges. It remains a fallback if `pty-process` does not build or behave correctly on the target router.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Each active terminal consumes two additional WebSockets and one PTY process on the agent.
|
||||
- Terminal traffic cannot delay main agent heartbeat, snapshot, or command messages.
|
||||
- Control-plane terminal state remains ephemeral; agents rebuild it from their in-memory live-session inventory after a control-plane restart.
|
||||
- The agent session protocol gains terminal-open control and capability advertisement but does not become a terminal-data multiplexer.
|
||||
- Browser and agent terminal transports can be tested independently before adding xterm.js.
|
||||
- Browser navigation and control-plane restart do not terminate an agent-owned PTY.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Configuration Durations
|
||||
|
||||
Agent and control-plane TOML duration settings use strings with explicit units.
|
||||
Runtime code receives `std::time::Duration`; API and WebSocket numeric fields
|
||||
such as `timeout_ms`, `session_ttl_seconds`, and `latency_ms` are unchanged.
|
||||
|
||||
## Agent
|
||||
|
||||
```toml
|
||||
reconnect_base = "1s"
|
||||
reconnect_max = "30s"
|
||||
observation_sync_interval = "60s"
|
||||
observation_retention = "7d"
|
||||
|
||||
[terminal]
|
||||
session_ttl = "12h"
|
||||
```
|
||||
|
||||
`observation_retention` prunes the agent's local hotplug memory during inventory
|
||||
collection. It does not delete the control plane's last snapshot for an agent.
|
||||
`"0d"` disables this local pruning. `terminal.session_ttl = "0s"` disables PTY
|
||||
expiry. Reconnect and sync timers must be positive and fit the platform timer.
|
||||
Terminal TTL and hook retention must resolve to whole seconds because their
|
||||
protocol/storage timestamps use seconds. Reconnect and sync intervals can use
|
||||
subsecond values.
|
||||
Reconnect scheduling retains its existing 100ms minimum backoff.
|
||||
|
||||
## Control Plane
|
||||
|
||||
```toml
|
||||
command_timeout = "30s"
|
||||
enroll_token_ttl = "1d"
|
||||
history_retention = "30d"
|
||||
device_retention = "60d"
|
||||
```
|
||||
|
||||
`command_timeout`, `enroll_token_ttl`, and `history_retention` must be positive.
|
||||
Enrollment TTL and history retention must resolve to whole seconds. Command
|
||||
timeout can use subsecond values.
|
||||
`history_retention` deletes old audit events and alert transitions, not device
|
||||
snapshots or saved identities.
|
||||
|
||||
`device_retention` expires agent-collected device rows and their child endpoints,
|
||||
addresses, names and facts. The default is `"60d"`; `"0d"` disables expiry.
|
||||
It must resolve to whole seconds. Age is measured from the last CC-received
|
||||
snapshot that included the device, not endpoint timestamps or online presence.
|
||||
An offline device included in new snapshots therefore stays retained. Saved
|
||||
devices and their manually attached identifiers are never deleted by this cleanup.
|
||||
Complete snapshots still remove devices omitted by that agent immediately;
|
||||
retention handles old rows left behind when an agent stops syncing.
|
||||
Expired rows are removed by the Unix daemon's existing maintenance loop every
|
||||
five minutes, including an initial tick at startup. Restart CC to apply changes
|
||||
to this setting; the state-only reload does not reload configuration.
|
||||
|
||||
## Legacy Files
|
||||
|
||||
| Old Key | New Key | Numeric Legacy Unit |
|
||||
| --- | --- | --- |
|
||||
| Agent `reconnect_base_ms` | `reconnect_base` | milliseconds |
|
||||
| Agent `reconnect_max_ms` | `reconnect_max` | milliseconds |
|
||||
| Agent `observation_sync_interval_seconds` | `observation_sync_interval` | seconds |
|
||||
| Agent `observation_retention_days` | `observation_retention` | days |
|
||||
| Agent `terminal.session_ttl_seconds` | `terminal.session_ttl` | seconds |
|
||||
| CC `command_timeout_ms` | `command_timeout` | milliseconds |
|
||||
| CC `enroll_token_ttl_seconds` | `enroll_token_ttl` | seconds |
|
||||
| CC `observation_retention_seconds` | `history_retention` | seconds |
|
||||
|
||||
Old keys accept non-negative integers in their original units, or explicit
|
||||
duration strings, and produce a warning on stderr when loaded. New keys require
|
||||
strings; bare numbers are rejected. Supplying both keys for the same setting is
|
||||
an error, even when their values agree. Negative, malformed, and overflowing
|
||||
values are rejected; zero active timers are rejected rather than silently
|
||||
clamped. No secrets are included in deprecation warnings.
|
||||
|
||||
`init-config --from-config ...` reads legacy files and writes only canonical
|
||||
keys and formatted duration strings. Existing numeric CLI flags are unchanged
|
||||
in this slice, and their overrides also serialize in the new format.
|
||||
|
||||
For example, legacy `reconnect_base_ms = 1500` is read as 1500 milliseconds
|
||||
and written back as `reconnect_base = "1s 500ms"`. New-key
|
||||
`reconnect_base = 1500` is rejected: no implicit numeric unit is assigned.
|
||||
Serialization always writes duration strings, regardless of the accepted input
|
||||
format, using `humantime-serde`.
|
||||
|
||||
The parser is [humantime](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html).
|
||||
Examples include `"14d"`, `"168h"`, `"250ms"`, and `"1h 30m"`. These are elapsed
|
||||
durations, not calendar scheduling rules; use days/hours for retention rather
|
||||
than assuming calendar-month semantics.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Fleet View Cache
|
||||
|
||||
CC retains one complete, grouped fleet view in memory. List requests still apply
|
||||
their own search, visibility, presence, agent filters and result limit. Neither
|
||||
the database nor the response format changes.
|
||||
|
||||
## Inputs And Invalidation
|
||||
|
||||
The cache key combines the store's fleet revision and connected-agent IDs.
|
||||
Successful snapshot writes, saved-device create/rename/forget/merge, identifier
|
||||
edits, agent enrollment/revocation/nickname edits, and device-retention cleanup
|
||||
advance the revision. Token-only, audit and alert writes do not invalidate it.
|
||||
Connection changes are detected from the live session map on each read.
|
||||
|
||||
The next reader rebuilds a changed view. Concurrent readers wait for that build
|
||||
and reuse it. The helper checks the key again after building: if inputs changed
|
||||
while database reads/grouping were in flight, it discards the result and retries.
|
||||
Build errors are returned, not cached; cancelling a reader releases the lock.
|
||||
The previous view is not served as a fallback after a rebuild error.
|
||||
After three invalidated builds, the reader stops and releases the lock. Fleet
|
||||
list/wake requests return HTTP 503 with code `fleet_busy`; clients can retry
|
||||
later. No stale view or command is used as a fallback. This bounds the number
|
||||
of rebuilds per reader, not database execution time or total concurrent requests.
|
||||
|
||||
Wake searches the complete cached view by device key, without list visibility
|
||||
filters or the list API's 1,000-row limit. It borrows the view and clones only the
|
||||
selected route. Command dispatch still checks the current session;
|
||||
a cached route cannot grant access to a disconnected agent.
|
||||
|
||||
`GET /api/v1/control/fleet/devices/{device_key}` returns one exact row from the
|
||||
complete view, or `404 fleet_device_not_found`. Details do not inherit list
|
||||
filters or limits. After Remember/Attach/Merge, the UI follows the saved ID
|
||||
returned by the mutation, not matching MAC/IP heuristics. It also refreshes the
|
||||
full saved identity independently of list results; Forget still closes details.
|
||||
|
||||
## External Writes
|
||||
|
||||
The revision belongs to the running CC process. If another process directly
|
||||
modifies its SQLite database, run the existing CC reload command (or send
|
||||
SIGHUP) to invalidate the view, or restart CC. The cache does not watch files
|
||||
or poll SQLite for external changes. Normal HTTP/WS store writes invalidate
|
||||
automatically, and there is no time-based cache expiry.
|
||||
|
||||
## Shared Helper And Tracing
|
||||
|
||||
`runtime::view_cache::ViewCache` owns synchronization and stale-build rejection.
|
||||
Fleet code supplies the key and builder. It is intentionally CC-local; alerts
|
||||
keep their existing timer-driven evaluator rather than being forced into this
|
||||
lazy cache.
|
||||
|
||||
`fleet_load` spans remain on requests. A cache miss adds `fleet_rebuild`, with
|
||||
database loads and `fleet_group_devices` nested below it. On a cache hit, list
|
||||
requests clone/filter the view; wake requests look up their device in the borrowed
|
||||
view. Neither path repeats the database reads or grouping.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Multi-CC Identity And Configuration
|
||||
|
||||
Status: proposal, 2026-10-12. This is not an implemented config format.
|
||||
|
||||
## Goal
|
||||
|
||||
One agent process can serve production and test CCs simultaneously. Collect
|
||||
locally once, forward to independent connections, and show human names alongside
|
||||
IDs. A failed CC must not block another or kill local terminal processes.
|
||||
|
||||
## Identity And Names
|
||||
|
||||
- **CC ID:** stable identity of a logical control plane, independent of URL,
|
||||
hostname, display name and WebSocket connection. Renaming or moving it must
|
||||
not require agents to enroll again.
|
||||
- **CC name:** configured on CC and advertised to authenticated agents. Start
|
||||
with config-based rename plus reload/restart; no rename API required initially.
|
||||
- **Agent instance ID/name:** local daemon identity and reported human name,
|
||||
shared across its connections. Not the enrollment identity of every CC.
|
||||
- **Enrollment:** each CC issues its own `agent_id` and token. Never send one
|
||||
CC's credentials to another. Existing per-CC agent nicknames override the
|
||||
reported name in that CC's UI; names never become routing/authentication keys.
|
||||
- **Connection alias:** local config key such as `cloud` or `test`, useful for
|
||||
CLI selection and pre-auth logs. It is not the remote CC ID or authoritative
|
||||
remote name.
|
||||
|
||||
CC ID is generated once and retained in SQLite metadata when not configured;
|
||||
an explicit config ID must agree with an existing stored identity, not silently
|
||||
replace it. New init-config output can include a generated ID. Agent instance
|
||||
ID belongs in its local config. Copying either deployment for an independent
|
||||
test instance requires a distinct identity; do not mint a new ID each startup.
|
||||
|
||||
## Proposed Config Shape
|
||||
|
||||
CC keeps existing operational settings and adds:
|
||||
|
||||
```toml
|
||||
[identity]
|
||||
id = "cc-<uuid>"
|
||||
name = "Cloud"
|
||||
```
|
||||
|
||||
Agent separates local identity/settings from per-CC enrollment:
|
||||
|
||||
```toml
|
||||
[identity]
|
||||
id = "node-<uuid>"
|
||||
name = "Router"
|
||||
|
||||
[local]
|
||||
pid_file = "/var/run/wakey-agent.pid"
|
||||
dhcp_leases_path = "/tmp/dhcp.leases"
|
||||
mac_name_cache_path = "/tmp/wakey_mac_names.json"
|
||||
observation_store_path = "/tmp/wakey_observations.json"
|
||||
|
||||
[sync]
|
||||
interval = "60s"
|
||||
hook_retention = "7d"
|
||||
|
||||
[reconnect]
|
||||
base = "1s"
|
||||
max = "30s"
|
||||
|
||||
[control_planes.cloud]
|
||||
server_url = "https://wakey.example.com"
|
||||
agent_id = "agent-<cloud-issued-id>"
|
||||
agent_token = "<cloud-token>"
|
||||
# expected_cc_id = "cc-<uuid>"
|
||||
|
||||
[control_planes.test]
|
||||
server_url = "http://127.0.0.1:8080"
|
||||
agent_id = "agent-<test-issued-id>"
|
||||
agent_token = "<test-token>"
|
||||
```
|
||||
|
||||
Existing `[terminal]` and `[telemetry]` remain daemon-wide. Reconnect defaults
|
||||
apply independently to each connection; avoid per-CC copies of every setting.
|
||||
The field names above are proposed, not a reason to rename unrelated CC settings.
|
||||
|
||||
Keep accepting the legacy single-CC root fields as one `default` connection,
|
||||
with a deprecation warning. Reject mixed legacy/new connection declarations
|
||||
rather than guessing. Init-config emits the new shape; `--from-config` preserves
|
||||
all connections and their credentials. Enrollment targets a connection alias:
|
||||
one connection can be implicit, multiple connections require explicit selection.
|
||||
|
||||
## Connection And Forwarding Rules
|
||||
|
||||
- Add negotiated, post-auth CC identity/name metadata. Current protocol has
|
||||
Hello/Auth but no explicit successful-auth response; sending credentials is
|
||||
not proof of authentication. Old clients must not receive unsupported enum
|
||||
variants unconditionally. Keep legacy single-CC compatibility explicit.
|
||||
- Forward the agent's configured name during hello; CC sends its own name/ID
|
||||
after auth. Show `name (id)` or a short ID in operator/debug views. Retain the
|
||||
local alias and last reported name for disconnected status, marking it offline.
|
||||
- Each CC gets its own worker, retry state, bounded outbound queue and request
|
||||
replies. Reject duplicate authenticated connections to the same CC ID; changed
|
||||
expected IDs require an explicit operator decision, not silent rebinding.
|
||||
- CC ID is identification, not a substitute for TLS/server authentication.
|
||||
- Share local inventory/hook storage and periodic collection. Fan out complete
|
||||
snapshots under each CC's enrollment ID; a slow connection cannot hold the
|
||||
collector hostage. This does not turn snapshots into a durable event journal.
|
||||
- Requests/replies and their trace contexts stay scoped to their connection.
|
||||
Never broadcast command results or terminal bytes to every CC.
|
||||
- Saved devices, nicknames, permissions and history remain independent on each
|
||||
CC. No CC-to-CC synchronization or automatic identity promotion.
|
||||
|
||||
## Terminal And Telemetry Scope
|
||||
|
||||
The agent owns PTYs and one daemon-wide session registry. Session limits apply
|
||||
globally, not once per connection. Initially, sessions belong to their creating
|
||||
CC: another CC cannot attach, resume or close them. Attachment ownership is
|
||||
enforced on the agent, not just inferred from one CC's registry. Disconnecting
|
||||
one CC preserves its PTYs until existing TTL/explicit-close/agent-exit policy acts.
|
||||
Reconnecting the same CC ID can reconcile its own sessions; removing a configured
|
||||
CC must have an explicit terminal cleanup policy before implementation ships.
|
||||
|
||||
Use local agent instance ID and CC ID for `service.instance.id`, addressing the
|
||||
current unspecified CC instance. Add configured display names as resource
|
||||
metadata, not a replacement for stable IDs. Grafana presentation/label mapping
|
||||
needs validation; do not promise names will automatically become `instance`.
|
||||
CPU/RSS/host metrics describe one local process and must not be duplicated per CC.
|
||||
Document whether snapshot counters count collections or per-CC sends before fan-out.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Stable CC/local-agent identities, names, telemetry metadata and config adapters.
|
||||
2. Negotiated authenticated metadata exchange; keep the one-CC path working.
|
||||
3. Shared local managers plus isolated connection workers and snapshot fan-out.
|
||||
4. Global terminal ownership checks and multi-CC operator/debug status.
|
||||
|
||||
Acceptance tests must cover config/enrollment isolation, names changing without
|
||||
IDs changing, one CC failing while another proceeds, shared collection without
|
||||
cross-CC replies, duplicate CC identity rejection, and cross-CC terminal access
|
||||
rejection. Use two test CCs and one agent before deploying this on the router.
|
||||
@@ -0,0 +1,178 @@
|
||||
# OpenWrt UCI for Wakey
|
||||
|
||||
Wakey's OpenWrt init script reads `/etc/config/wakey` for the log filter and
|
||||
OTLP authorization header, then passes them to the agent through procd.
|
||||
The agent's application settings remain in `/etc/wakey-agent/config.toml`.
|
||||
The previous `/etc/wakey-agent/environment` file is no longer read.
|
||||
|
||||
## The three names
|
||||
|
||||
```sh
|
||||
uci set wakey.main.otlp_authorization='Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
| --- | --- |
|
||||
| `wakey` | Package: `/etc/config/wakey` |
|
||||
| `main` | Named section inside that package |
|
||||
| `otlp_authorization` | Option inside the section |
|
||||
|
||||
Quotes keep the complete `Basic ...` value together despite its space.
|
||||
The credential is the Base64 encoding of `username:password`.
|
||||
It is not the htpasswd hash, and Base64 is not encryption.
|
||||
|
||||
## Create and save
|
||||
|
||||
Run as root on the router. `touch` does not truncate an existing file:
|
||||
|
||||
```sh
|
||||
touch /etc/config/wakey
|
||||
chown root:root /etc/config/wakey
|
||||
chmod 600 /etc/config/wakey
|
||||
|
||||
uci set wakey.main=service
|
||||
uci set wakey.main.log_filter='info'
|
||||
uci set wakey.main.otlp_authorization='Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||
uci commit wakey
|
||||
chmod 600 /etc/config/wakey
|
||||
```
|
||||
|
||||
`wakey.main=service` creates a named section of type `service`. The type is our
|
||||
chosen label, not a built-in Wakey feature. Existing options are not cleared.
|
||||
`set` stages changes; `commit wakey` writes that package to disk. Committing does
|
||||
not restart a service.
|
||||
|
||||
The resulting file looks like:
|
||||
|
||||
```text
|
||||
config service 'main'
|
||||
option log_filter 'info'
|
||||
option otlp_authorization 'Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||
```
|
||||
|
||||
The example above uses a placeholder. Avoid typing real credentials into a
|
||||
command that your shell will save in history. In an interactive SSH terminal, you can
|
||||
read the Basic header without echoing it and send it to UCI over stdin:
|
||||
|
||||
```sh
|
||||
(
|
||||
set -e
|
||||
terminal_state=$(stty -g </dev/tty)
|
||||
# Restore the original terminal settings even on failure or interruption.
|
||||
trap 'stty "$terminal_state" </dev/tty' 0
|
||||
trap 'exit 1' HUP INT TERM
|
||||
printf 'Complete Basic authorization header: ' >/dev/tty
|
||||
stty -echo </dev/tty
|
||||
IFS= read -r authorization </dev/tty
|
||||
stty "$terminal_state" </dev/tty
|
||||
printf '\n' >/dev/tty
|
||||
printf "set wakey.main.otlp_authorization='%s'\ncommit wakey\n" "$authorization" | uci batch
|
||||
chmod 600 /etc/config/wakey
|
||||
)
|
||||
```
|
||||
|
||||
Use a normal `Basic <base64>` header here, without quotes/newlines inside the value.
|
||||
The shell's builtin `printf` avoids passing the secret in `uci`'s command arguments.
|
||||
UCI is not a secret vault: committed files, staged changes, router backups, and
|
||||
root access can expose these credentials. Do not paste their output into logs/chat.
|
||||
|
||||
## Inspect, change, delete
|
||||
|
||||
Inspect the non-secret option:
|
||||
|
||||
```sh
|
||||
uci get wakey.main.log_filter
|
||||
```
|
||||
|
||||
`uci show wakey`, `uci export wakey`, `uci changes wakey`, and
|
||||
`uci get wakey.main.otlp_authorization` **print the credential**. Use them only
|
||||
when you deliberately want to see it.
|
||||
|
||||
Change an option:
|
||||
|
||||
```sh
|
||||
uci set wakey.main.log_filter='wakey_agent=debug,wakey=debug,opentelemetry=warn'
|
||||
uci commit wakey
|
||||
```
|
||||
|
||||
Delete just the authorization option:
|
||||
|
||||
```sh
|
||||
uci -q delete wakey.main.otlp_authorization
|
||||
uci commit wakey
|
||||
```
|
||||
|
||||
If TOML still requires `authorization_env`, removing the variable makes startup
|
||||
fail. Remove that TOML field too only when the receiver no longer requires auth.
|
||||
|
||||
Discard all uncommitted changes to this package:
|
||||
|
||||
```sh
|
||||
uci revert wakey
|
||||
```
|
||||
|
||||
## How the service reads it
|
||||
|
||||
`config_load` and `config_get` are shell helpers from `/lib/functions.sh`, available
|
||||
in the normal `/etc/rc.common` init-script environment. They are not standalone
|
||||
commands to run directly in your SSH shell.
|
||||
|
||||
```sh
|
||||
config_load wakey
|
||||
config_get authorization main otlp_authorization
|
||||
```
|
||||
|
||||
`config_get` arguments are **destination variable, section, option, optional default**.
|
||||
The line above puts the option into the shell variable `authorization`. It does
|
||||
not export it to the agent automatically; procd must receive it explicitly.
|
||||
|
||||
The checked-in [init script](../scripts/init/openwrt/wakey) uses these helpers.
|
||||
Its configuration handling is:
|
||||
|
||||
```sh
|
||||
start_service() {
|
||||
local authorization='' log_filter='info'
|
||||
if [ -f /etc/config/wakey ]; then
|
||||
config_load wakey || return 1
|
||||
config_get authorization main otlp_authorization
|
||||
config_get log_filter main log_filter 'info'
|
||||
fi
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||
procd_set_param file "$CONFIG"
|
||||
[ ! -f /etc/config/wakey ] || procd_append_param file /etc/config/wakey
|
||||
procd_set_param env "RUST_LOG=$log_filter"
|
||||
[ -z "$authorization" ] || procd_append_param env "WAKEY_OTLP_AUTHORIZATION=$authorization"
|
||||
procd_set_param respawn 5 1 0
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
```
|
||||
|
||||
Keep these fields in the agent's TOML:
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
otlp_endpoint = "http://10.5.0.1:4317"
|
||||
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||
```
|
||||
|
||||
UCI stores the header; procd passes the environment variable; Rust reads it using
|
||||
the name in TOML. The application does not parse UCI itself.
|
||||
|
||||
After installing the updated init script and committing settings:
|
||||
|
||||
```sh
|
||||
/etc/init.d/wakey restart
|
||||
```
|
||||
|
||||
Use **restart**, not the existing HUP-based reload: a running process cannot receive
|
||||
changed environment variables that way. UCI is only for the router; the CC's
|
||||
systemd environment file remains unchanged. See [telemetry.md](telemetry.md).
|
||||
|
||||
## References
|
||||
|
||||
- [OpenWrt UCI CLI source and command usage](https://github.com/openwrt/uci/blob/master/cli.c)
|
||||
- [OpenWrt config_load/config_get helpers](https://github.com/openwrt/openwrt/blob/main/package/base-files/files/lib/functions.sh)
|
||||
@@ -0,0 +1,118 @@
|
||||
# Origin Authentication With Caddy
|
||||
|
||||
Cloudflare Access protects the operator surface at the edge. An origin reachable
|
||||
directly must also verify Access tokens: a nonempty assertion header is not
|
||||
authentication. A read-only probe through `mails.ldlda.com`, preserving Wakey's
|
||||
TLS hostname and HTTP host, returned 403 without a header and 200 with a forged
|
||||
header on the same origin IP. Do not retain the old header-presence matcher.
|
||||
|
||||
## Configure CC
|
||||
|
||||
Add this to the deployed CC configuration, using your protected application's
|
||||
settings, not the bypass application's audience:
|
||||
|
||||
```toml
|
||||
bind = "127.0.0.1:23501"
|
||||
|
||||
[access]
|
||||
issuer = "https://ldlda.cloudflareaccess.com"
|
||||
audience = "0c8ca0bce40a48ddc5bc065cea8390eaddd75a84458f147bbe249fcc57c3d781"
|
||||
# Optional; defaults to the issuer's /cdn-cgi/access/certs endpoint.
|
||||
# jwks_url = "https://ldlda.cloudflareaccess.com/cdn-cgi/access/certs"
|
||||
```
|
||||
|
||||
Issuer and key URLs must use HTTPS without user credentials, query or fragment.
|
||||
The issuer is an origin without a trailing slash; it must exactly match the JWT
|
||||
issuer. The audience is not a secret. URLs come only from trusted configuration,
|
||||
never from an incoming token. `init-config --from-config` preserves `[access]`.
|
||||
|
||||
CC requires a loopback bind when `[access]` is configured. With no `[access]`,
|
||||
the verifier returns 401; existing local development routes remain unchanged.
|
||||
Changes to Access configuration require a daemon restart, not a state reload.
|
||||
|
||||
## Configure Caddy
|
||||
|
||||
Use [the Caddy template](../deploy/control-plane.Caddyfile), replacing the domain
|
||||
and every upstream port with your deployment values. Its protected handler calls
|
||||
CC's `GET /internal/auth/verify` through `forward_auth`, then proxies the original
|
||||
request only on success. No additional service or custom Caddy module is needed.
|
||||
|
||||
Protect `/`, `/ui*` and `/api/v1/control/*`. This includes operator WebSocket
|
||||
handshakes; established WebSocket frames are not individually authenticated and
|
||||
token expiry does not automatically disconnect an existing WebSocket.
|
||||
|
||||
Keep `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws` and
|
||||
`/api/v1/agent/terminals/*` outside Access verification. They retain Wakey's own
|
||||
agent/scoped-relay authentication. Cloudflare's bypass application should cover
|
||||
these paths; terminal child paths must be included. If using HTTP ACME challenges,
|
||||
ensure `/.well-known/acme-challenge/*` is exempt at Cloudflare; Caddy handles its
|
||||
own active challenges, not CC.
|
||||
|
||||
Do not proxy `/internal/auth/verify` publicly and do not put `forward_auth` in
|
||||
front of the verifier itself. Do not replace verification with a static header
|
||||
that Caddy adds to every request. Caddy owns enforcement: direct access to the
|
||||
CC port would bypass it, so loopback binding and host access controls matter.
|
||||
|
||||
## Verification And Cache
|
||||
|
||||
The verifier accepts only RS256, verifies signatures, and requires matching
|
||||
issuer/audience plus expiry. A future `nbf` also rejects the token. No clock-skew
|
||||
leeway is applied; keep the host clock synchronized. Duplicate assertion headers
|
||||
and assertions over 16 KiB are rejected. No tokens, claims or upstream bodies
|
||||
are logged.
|
||||
|
||||
Responses have no identity payload:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| 204 | Valid protected-application token |
|
||||
| 401 | Missing/invalid token, unknown key after successful key retrieval, or unconfigured verifier |
|
||||
| 503 | Required signing keys cannot be obtained safely |
|
||||
|
||||
CC reuses one HTTP client and stores parsed keys with their HTTP cache policy.
|
||||
`http-cache-semantics` determines freshness from the issuer's `Cache-Control`,
|
||||
`Age`, `Date` and `Expires`, rather than a built-in key TTL. For example,
|
||||
`public, max-age=14400, must-revalidate, no-transform` permits four hours of
|
||||
freshness minus the response's existing age; `must-revalidate` forbids stale
|
||||
reuse, not every fresh reuse. No heuristic lifetime is invented when freshness
|
||||
is unspecified. A `no-cache` response requires revalidation before reuse;
|
||||
`no-store` retains neither keys nor policy after serving the fetching request.
|
||||
|
||||
Fresh matching keys verify locally. Stale policies produce conditional requests
|
||||
using ETag or Last-Modified when available. A matching 304 renews the policy and
|
||||
reuses parsed keys without downloading/parsing a body; mismatched or unsolicited
|
||||
304 responses fail closed. An unknown key ID forces conditional revalidation
|
||||
even when the stored response remains fresh, to discover rotation.
|
||||
|
||||
Fetches are single-flight and limited to once per minute, including failures.
|
||||
Rotation immediately after a fetch may therefore take up to a minute to become
|
||||
available. If the issuer requires revalidation on every use (or allows no
|
||||
storage), subsequent callers inside that cooldown receive 503 rather than reuse
|
||||
keys contrary to the HTTP policy. Successful 200 refresh replaces the entire
|
||||
set, including removing all keys for a valid empty set. Failed refresh does not
|
||||
extend freshness: matching keys may be used only while the stored policy still
|
||||
permits reuse. There is no indefinitely stale fallback, including on outages.
|
||||
|
||||
Fetches have a five-second timeout, no redirects, a 256 KiB body limit and at
|
||||
most 64 keys. These limits are internal safeguards, not new config knobs.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Install the new CC binary, add `[access]`, and restart CC. Check the loopback
|
||||
verifier returns 401 for an absent or forged assertion.
|
||||
2. Update Caddy from the template and validate its configuration before reload.
|
||||
The repo template uses port 6767; this deployment uses 23501.
|
||||
3. Confirm a real Access login can use the UI/control APIs and operator terminal
|
||||
WebSocket. Confirm agent connections and enrollment still work.
|
||||
4. Repeat a direct-origin request with Wakey's Host/SNI. Both absent and forged
|
||||
assertions must return 401, not the UI. Verify the internal endpoint itself
|
||||
returns 404 through the public virtual host.
|
||||
|
||||
Do not roll back to header-only acceptance on a verification failure. A UI
|
||||
build or a CC restart alone does not update the deployed Caddy policy.
|
||||
|
||||
References: [Cloudflare token validation](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)
|
||||
and [Caddy forward_auth](https://caddyserver.com/docs/caddyfile/directives/forward_auth).
|
||||
HTTP policy is provided by [http-cache-semantics](https://docs.rs/http-cache-semantics/latest/http_cache_semantics/struct.CachePolicy.html);
|
||||
only the single-resource storage, bounded fetch and anti-hammering policy are
|
||||
application-owned. No general-purpose disk or Moka response cache is needed.
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Wakey Roadmap
|
||||
|
||||
Last updated: 2026-10-12. This records agreed work and follow-ups, not a release
|
||||
promise. Checked items are implemented; live validation is called out separately.
|
||||
|
||||
## Completed Observability
|
||||
|
||||
- [x] Shared optional OTLP setup for traces, logs, and metrics in wakey-core.
|
||||
- [x] Basic/Bearer authorization supplied through a named environment variable.
|
||||
- [x] OpenWrt UCI configuration and systemd environment-file support, with setup guides.
|
||||
- [x] Cross-process command trace context; verified live on a CC-to-agent wake.
|
||||
- [x] Inventory source, merge, device-storage, and fleet-operation spans.
|
||||
- [x] Agent connection phase spans and retry events, without entered guards across awaits.
|
||||
- [x] Terminal snapshot generation, transport attachment, resume, and backpressure instrumentation.
|
||||
- [x] Separate hostname-resolution and neighbor-table spans; a live slow wake spent about four seconds in resolution.
|
||||
- [x] Metrics recipes for rates, duration averages/percentiles, terminal activity, and snapshots.
|
||||
- [x] Authenticated live logs, metrics, and traces confirmed through Grafana's read-only API.
|
||||
|
||||
See [telemetry setup](telemetry.md), [metrics recipes](telemetry-recipes.md), and
|
||||
[OpenWrt UCI](openwrt-uci.md). Terminal instrumentation has automated coverage;
|
||||
live browser recovery and shutdown behavior are not established by those checks.
|
||||
|
||||
## Device Management And Presentation
|
||||
|
||||
- [x] Endpoint-derived presence on snapshot writes and fleet reads; endpoint-less legacy rows retain their summary. CLI ordering matches the core signal order.
|
||||
|
||||
- [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown.
|
||||
- [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved.
|
||||
- [ ] Check and complete editing of remembered-device notes.
|
||||
- [x] Replace generic unnamed-row labels with useful fallbacks. Prefer saved name, hostname, then `Device: <mac>`, then `Device: <ip>`; retain a generic label only when neither address exists. Presence stays in the badge, not the name. Inactive IPs can label IP-only rows without becoming active IPs or wake routes. Unknown-only fleet groups remain unknown.
|
||||
- [ ] Review frontend visibility defaults. Keep an explicit all-rows option; do not hide evidence just to fix poor labels. The user deliberately enabled all-row querying.
|
||||
- [x] Keep device details current after identity mutations change the grouping key or list filters exclude the result. Exact fleet details and full saved-identity reads follow the mutation's explicit ID; no shared-IP matching or stale-control fallback. Remember/Attach/Rename verified with mocked browser flows on desktop, Remember/Rename on mobile; backend tests cover filtering, old-key rejection and lookup beyond the list cap. Forget deliberately closes; detaching stays on the saved identity instead of guessing among split observations.
|
||||
|
||||
Identifier detachment already exists in the current frontend source; it is **not**
|
||||
the requested whole-device removal. No automatic identity promotion or deletion
|
||||
of remembered identifiers based on observation expiry.
|
||||
|
||||
## Telemetry Follow-Ups
|
||||
|
||||
- [x] Human-readable agent/CC config durations with unit-aware legacy loading, stderr deprecation warnings, and canonical init-config output. See [configuration durations](config-durations.md).
|
||||
- [x] CC device retention by last snapshot inclusion (`device_retention = "60d"`, zero disables), independent of audit history and preserving saved identities.
|
||||
- [x] Lazy grouped fleet-view cache with store/connection invalidation and stale-build rejection. See [fleet cache](fleet-cache.md).
|
||||
|
||||
- [x] Separate console, OTLP log, and trace filters using per-layer filtering. Explicit signal config overrides `RUST_LOG`, then CLI verbosity supplies defaults. Unconfigured defaults remain info-level; exporter-failure logs stay local. Live validation pending.
|
||||
- [x] Upgrade the matched OpenTelemetry stack to 0.33 with exporter-managed backoff/jitter retries. Controlled collector tests cover all-signal recovery, bounded retries, and no retry on authentication rejection; live outage validation remains pending.
|
||||
- [ ] Record hostname-resolution success/failure without exporting hostnames or resolved addresses. Current spans show timing but do not explicitly classify the result.
|
||||
- [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix.
|
||||
- [x] Propagate optional W3C trace context for periodic/requested WS snapshots so agent collection and CC persistence can be inspected together. Wire compatibility and context linkage tested; live Grafana inspection confirmed CC storage is a child of agent collection in the same trace. Storage can start after collection ends: this is an asynchronous handoff, not a persistence acknowledgement.
|
||||
- [x] Add current-state gauges: retained device/endpoint rows per agent and unfiltered grouped fleet identities split saved/unsaved. Sample independently every 30 seconds with bounded revision checks; replace labels and suppress failed samples. Collector and state lifecycle tests included; dashboard recipes ready.
|
||||
- [ ] Decide whether snapshots need persistence acknowledgements. The existing sent-device counter does not prove CC storage; any confirmed-storage metric needs an authoritative measurement point.
|
||||
- [x] Break down snapshot persistence timing into pool acquisition, transaction acquisition, row replacement, and commit child spans. Shared write acquisition retains SQLx ownership and `BEGIN IMMEDIATE`; snapshot row work stays in the caller's transaction. Existing rollback and cache-refresh tests cover behavior. Live trace screenshot confirmed all phases: about 83 microseconds acquiring a connection, 154 microseconds beginning the transaction, 12.8 ms replacing rows, and 18.94 ms committing. This sample does not justify changing SQLite durability or prove a persistent bottleneck. See [telemetry span semantics](telemetry.md#signals).
|
||||
|
||||
The existing snapshot counter is cumulative collection volume, not unique devices
|
||||
or current fleet size. Metric labels should remain bounded; no per-device labels.
|
||||
|
||||
## Process And Host Visibility
|
||||
|
||||
- [x] Add Linux process age/RSS and host uptime gauges for agent and CC through opt-in OTLP, using the existing collection interval. Core owns OS-independent instruments; wakey-linux supplies the optional procfs reader. Missing data omits observations; no added labels, config fields, health routes or polling tasks. Collector coverage verifies units and values; router/live dashboard validation pending.
|
||||
- [x] Add cumulative process CPU time (counter) and current open-FD count (gauge) through the same Linux reader and OS-independent instruments. Exclude child processes, omit failed samples, and document one-core CPU-rate interpretation and sampling FD overhead. Extend collector/reader coverage rather than adding timing-based tests; live validation pending.
|
||||
- [ ] Add start-time/version metadata if needed. Keep version as resource metadata rather than repeating it on every sample.
|
||||
- [x] Add total/available host memory and 1/5/15-minute load gauges through the optional Linux reader, with byte conversion owned by procfs and fixed load-period labels. Omit unavailable MemAvailable instead of substituting free memory; document kernel views versus container limits. Collector/reader coverage included; live validation pending.
|
||||
- [ ] Complete a small host summary with hostname, OS/kernel and CPU count. Sources must tolerate missing Linux files; not every agent host is a router.
|
||||
- [ ] Display CC and agent process/host information in Fleet Health and agent details: version/name, process/host uptime, RSS, CPU time/usage, open FDs, available/total host memory and load where supported. OTLP export is not the UI's data source; the UI must work without a collector.
|
||||
- [ ] Add typed agent status snapshots over the existing authenticated WebSocket, on a modest timer and when requested. Reuse Linux collection rather than introducing separate parsers; CC retains each agent's latest sample with received time and connection status. Show stale/disconnected or unavailable values explicitly, never as zero/current. Keep routine samples in memory initially, not an unbounded DB history.
|
||||
- [ ] Expose the CC's own sampled status and retained agent summaries through protected control APIs. Keep `/healthz` unchanged and do not expose environment variables, credentials or command lines. CPU percentages require successive cumulative CPU/time samples; do not label raw CPU seconds as utilization. No expensive host reads on every UI request.
|
||||
- [ ] Keep `/healthz` as cheap liveness; consider a separate bounded `/readyz` database-access check for CC. Collector outages and disconnected fleet agents must not make a functioning CC unready.
|
||||
- [ ] Add application resource gauges where useful: DB pool usage, connected agents, pending commands, and active terminal sessions. Check existing instruments first to avoid duplicates; distinguish pool pressure from host/process pressure.
|
||||
|
||||
## Device Diagnostics
|
||||
|
||||
- [ ] Add agent-side ICMP ping through the existing command relay, with an explicit IP and selected agent/network route. Bound probe count, total duration, output and concurrent work; no shell interpolation. Account for missing tools and ICMP permissions, and support IPv4/IPv6 where available.
|
||||
- [ ] Return probe replies, loss and round-trip times; offer Ping alongside Wake in device details. MAC-only devices cannot be pinged without an IP. A reply means that IP responded from that agent's network, not proof of saved identity; no reply must not automatically mark a device offline or remove an identifier.
|
||||
|
||||
## Optional Agent Sources And Capabilities
|
||||
|
||||
These are exploration items, not a commitment to a router-only model or a new
|
||||
message bus. Sparse WSL inventory is legitimate; missing evidence is not failure.
|
||||
|
||||
- [ ] Discover and advertise usable observation sources separately from supported commands and configured permissions. Extend the existing hello/capability mechanism where appropriate; CC/UI must tolerate missing sources and older agents. Do not infer router status from Linux alone.
|
||||
- [ ] Investigate read-only Wi-Fi station data on the actual router: signal in **dBm**, association/interface, rates and traffic counters where available. Evaluate driver-supported `iw`/netlink or local `ubus`/hostapd interfaces before choosing a dependency. Keep measurements scoped to agent/interface/station with source and capture time; they are not permanent identity or globally valid presence. UI may show a signal badge only when supported and sufficiently fresh.
|
||||
- [ ] Investigate interface lifecycle hooks for WAN/link changes, keeping scripts minimal and logic in the binary. Interface up/down does not prove Internet reachability; any active connectivity probe needs an explicit target and bounded policy. Existing DHCP/neighbor hooks remain independent evidence, not a way to enrich or overwrite other sources.
|
||||
- [ ] Keep source support, operator authorization and local agent policy distinct. Capability advertisement is not permission: CC checks authorization and the agent enforces configured limits. New diagnostic APIs should be typed, protected operations, not arbitrary shell, UCI or bus calls exposed to the browser.
|
||||
|
||||
OpenWrt [hotplug](https://openwrt.org/docs/guide-user/base-system/hotplug) provides
|
||||
event hooks; [ubus](https://openwrt.org/docs/techref/ubus) is local IPC/RPC, and
|
||||
[UCI](https://openwrt.org/docs/guide-developer/ubus/uci) manages configuration.
|
||||
None is a Kafka-style retained observation history. Verify what the vendor
|
||||
firmware actually exposes before making any of them an agent requirement.
|
||||
|
||||
## Origin Authentication
|
||||
|
||||
- [x] Replace the nonempty assertion matcher in the Caddy template with `forward_auth`. The initial `lb.ldlda.com` probe reached Cloudflare; the later `mails.ldlda.com` probe reached an origin and confirmed 403 without an assertion versus 200 with a forged assertion on the same IP. This is a reproduced bypass, not just a theoretical concern; production rollout remains required.
|
||||
- [x] Add CC's `/internal/auth/verify` endpoint with configured issuer/audience/key URL, RS256 signature and required claim validation, bounded cached key retrieval, single-flight refresh and outage cooldown. Return 204/401/503 without exposing tokens or identity data. Configured verification requires loopback binding, and `init-config --from-config` preserves it. See [origin authentication](origin-auth.md).
|
||||
- [x] Cover forged/expired/wrong-audience/missing-claim tokens, key rotation/removal (including an empty set), duplicate assertions, unavailable/malformed/oversized key responses, cooldown and concurrent fetches. Router coverage verifies the oracle is separate from public/control handlers; actual Caddy enforcement is deployment-owned and still requires validation.
|
||||
- [x] Use `http-cache-semantics` for issuer-supplied HTTP freshness instead of a hardcoded signing-key TTL. Preserve bounded streaming reads, force revalidation on unknown key IDs, and handle ETag/Last-Modified 304 responses without reparsing a body. Tests cover fresh reuse, Age, no-cache/no-store, unspecified freshness, conditional rotation and invalid 304 rejection; no sleeps required.
|
||||
- [ ] Deploy the new CC verifier and Caddy policy for root/UI/control HTTP and operator WS handshakes. Keep public agent paths outside Access checks and the verifier unexposed publicly. Validate Caddy configuration, real Access login, agent connectivity, protected WebSocket rejection, and forged-header direct-origin rejection. Authentication failures must never fall back to header-only acceptance.
|
||||
|
||||
## Multi-CC Planning
|
||||
|
||||
- [ ] Consolidate stable CC/local-agent IDs, forwarded display names, per-CC enrollments, and agent config restructuring. See [multi-CC proposal](multi-cc-plan.md). Identity/name exchange and terminal ownership must be defined before adding connection workers; no CC-to-CC state synchronization is planned.
|
||||
|
||||
## Validation And Reliability
|
||||
|
||||
- [x] Bound fleet-cache rebuilds to three attempts under sustained mutations; return retryable HTTP 503 `fleet_busy` rather than silently serving stale wake routes. Deterministic limit/recovery tests included.
|
||||
- [x] Separate fleet wake selection from dispatch; wake lookup uses the complete cached view rather than the presentation list's 1,000-row cap. Automated coverage checks explicit-route rejection and lookup beyond the cap; live checks confirm missing-device and invalid-route errors without dispatching wakes.
|
||||
- [x] Share device read assembly within a single SQLite read transaction. Query-plan inspection on the test database with Python SQLite confirmed all six per-agent reads use primary-key searches; full-fleet reads scan intentionally, with covering indexes for MAC/IP projections and no temporary sort. No additional indexes justified by this check.
|
||||
- [x] Separate fleet refresh selection, relay, decoding/storage, and aggregation. Preserve concurrency of eight and per-agent failures; tests cover disconnected agents, sorted partial results, invalid payload preservation, and valid empty snapshot replacement.
|
||||
- [x] Add a refresh integration regression: a simulated agent answers through the production command relay; its inventory reaches SQLite, invalidates a previously populated fleet cache, and appears in the next fleet read. A disconnected agent does not cancel success; duplicate selection sends only one command. This covers the CC application path, not a real agent process or WebSocket transport.
|
||||
- [ ] Reproduce/check Ctrl-C cleanup on the test device: process groups, PTYs, relay tasks, and telemetry shutdown. Leftovers were suspected, not confirmed.
|
||||
- [ ] Exercise collector unavailability and recovery with running agent/CC instances. Verify normal operations continue and telemetry queues remain bounded; outage losses are expected under the current best-effort policy.
|
||||
- [ ] Validate terminal recovery instrumentation live: generation timing/size, transport attach/resume, and backpressure episodes ending in drain or disconnect.
|
||||
- [ ] Validate the revised UCI credential prompt and service environment on the live router.
|
||||
|
||||
## Watch, Not Yet Fix
|
||||
|
||||
- Fleet listing time is mostly device-store reads in observed traces. Measure
|
||||
sustained latency and row counts before changing queries or indexes.
|
||||
- Snapshot write durations vary. Existing store spans include pool/lock waits,
|
||||
transaction work, and result assembly where applicable; they are not pure SQL timings.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Pinned-state editing: currently not needed for the small remembered fleet.
|
||||
- Browser-render acknowledgement: attach acceptance and snapshot generation do
|
||||
not prove xterm finished restoring. This would require additional protocol work.
|
||||
- Retaining terminal images across recovery: text-screen snapshots do not provide image replay.
|
||||
- CPU/heap profiling: use targeted profiling when a measured issue calls for it;
|
||||
OTLP traces are not CPU profiles or flame graphs.
|
||||
- Grafana dashboard construction is the user's exercise; keep improving the recipe book rather than taking over dashboard editing.
|
||||
|
||||
## Release Notes
|
||||
|
||||
- No version bump is made by this roadmap. Additive optional config fields alone
|
||||
do not require 0.4.0; reconsider if compatibility or defaults change materially.
|
||||
- Recheck live telemetry after deployment. A Grafana volume reset can remove
|
||||
history and invalidate service-account tokens or data-source UIDs.
|
||||
- Never commit Grafana tokens, UCI credentials, or router backups.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Wakey Metrics Recipes
|
||||
|
||||
Use Grafana Explore with your Prometheus-compatible data source and the Code
|
||||
query editor. These recipes assume standard OTLP-to-Prometheus name conversion.
|
||||
Discover your actual series first:
|
||||
|
||||
```promql
|
||||
count by (__name__, job) ({__name__=~"wakey[._].*"})
|
||||
```
|
||||
|
||||
With Alloy's Prometheus exporter, service names normally become `job`, and agent
|
||||
instance IDs become `instance`. Inspect the returned labels before adding filters.
|
||||
For example, append `{job="wakey-agent"}` to a metric selector only if that is
|
||||
the job value you actually see. Metrics export every 30 seconds; use a time range
|
||||
that includes recent activity and allow at least two exports for rate queries.
|
||||
|
||||
## Metric Types
|
||||
|
||||
| Type | What it records | How to read it |
|
||||
| --- | --- | --- |
|
||||
| Counter | Cumulative events or bytes since process startup | `rate` for per-second throughput; `increase` for a window's estimated total |
|
||||
| UpDownCounter | Adds and subtracts from a current quantity | Read the current value directly; do not use `rate` for active sessions |
|
||||
| Gauge | Measures a current value, such as stored device rows | Read directly; do not use `rate` or `increase` |
|
||||
| Histogram | Distribution of individual measurements | `_sum / _count` for an average; `_bucket` for estimated percentiles |
|
||||
|
||||
Counter resets on restart are handled by `rate` and `increase`. Apply those
|
||||
functions **before** summing across instances so individual resets remain visible.
|
||||
|
||||
## Process And Host
|
||||
|
||||
Discover the new gauges first (these are Linux-only):
|
||||
|
||||
```promql
|
||||
count by (__name__, job, instance) ({__name__=~"wakey_(process|host)_.*"})
|
||||
```
|
||||
|
||||
Process age and host uptime, in seconds:
|
||||
|
||||
```promql
|
||||
wakey_process_uptime_seconds
|
||||
```
|
||||
|
||||
```promql
|
||||
wakey_host_uptime_seconds
|
||||
```
|
||||
|
||||
Set Grafana's unit to a duration. Compare the two: process age resetting while
|
||||
host uptime continues suggests a daemon restart; both resetting suggests a host
|
||||
restart or a changed namespace/view. Missing samples are not a zero uptime or
|
||||
proof of a restart. These are gauges: do not use `rate` or `increase` on them.
|
||||
|
||||
Resident process memory, in bytes:
|
||||
|
||||
```promql
|
||||
wakey_process_memory_resident_bytes
|
||||
```
|
||||
|
||||
Set the unit to **bytes (IEC)**. Keep `job` and `instance` in the legend to compare
|
||||
agent and CC separately. RSS includes shared/file-backed pages and is not heap
|
||||
allocation alone. A missing series can mean disabled telemetry, an unsupported
|
||||
platform, unavailable procfs, or export trouble. No new health endpoint or host
|
||||
inventory is provided here; do not sum host uptime from multiple daemons.
|
||||
|
||||
CPU consumption as a percentage of **one CPU core**:
|
||||
|
||||
```promql
|
||||
100 * rate(wakey_process_cpu_time_seconds_total[5m])
|
||||
```
|
||||
|
||||
Set the unit to **percent (0-100)**. A multithreaded process can exceed 100%;
|
||||
this is not normalized by the host's CPU count. The counter includes daemon
|
||||
user/system CPU, not its child shells or commands. Allow at least two exports;
|
||||
`rate` handles counter resets on process restart.
|
||||
|
||||
Current open descriptors:
|
||||
|
||||
```promql
|
||||
wakey_process_open_fds
|
||||
```
|
||||
|
||||
Read directly as a count, not a rate. The sampling read can contribute temporary
|
||||
descriptors. Persistent growth can warrant investigation, but sockets, terminal
|
||||
sessions and pool connections legitimately change the count too.
|
||||
|
||||
Host usable/available RAM, in bytes:
|
||||
|
||||
```promql
|
||||
wakey_host_memory_total_bytes
|
||||
```
|
||||
|
||||
```promql
|
||||
wakey_host_memory_available_bytes
|
||||
```
|
||||
|
||||
Set units to **bytes (IEC)**. Estimated percentage not available for new work:
|
||||
|
||||
```promql
|
||||
100 * (1 - wakey_host_memory_available_bytes / wakey_host_memory_total_bytes)
|
||||
```
|
||||
|
||||
Available includes reclaimable memory and is not merely free RAM. Missing
|
||||
`MemAvailable` leaves a gap rather than substituting `MemFree`. Do not sum host
|
||||
RAM from multiple daemon instances on the same host, or interpret these kernel
|
||||
values as a container's cgroup limit.
|
||||
|
||||
Load averages by period:
|
||||
|
||||
```promql
|
||||
wakey_host_load
|
||||
```
|
||||
|
||||
Use `{{job}} / {{instance}} / {{period}}` as the legend. Fixed periods are `1m`,
|
||||
`5m`, and `15m`. Read directly as a load count, not a percentage or a rate:
|
||||
Linux includes runnable and uninterruptible tasks; a high load can include I/O
|
||||
waiting rather than CPU work. These values are not normalized by CPU count.
|
||||
|
||||
## Operations
|
||||
|
||||
Completed attempts per second, including errors and timeouts:
|
||||
|
||||
```promql
|
||||
sum by (job, operation, outcome) (rate(wakey_operations_total[5m]))
|
||||
```
|
||||
|
||||
Estimated attempts over the last hour:
|
||||
|
||||
```promql
|
||||
sum by (job, operation, outcome) (increase(wakey_operations_total[1h]))
|
||||
```
|
||||
|
||||
Average duration over the last five minutes:
|
||||
|
||||
```promql
|
||||
sum by (job, operation) (rate(wakey_operation_duration_seconds_sum[5m]))
|
||||
/
|
||||
sum by (job, operation) (rate(wakey_operation_duration_seconds_count[5m]))
|
||||
```
|
||||
|
||||
Set Grafana's unit to **seconds**. This is a weighted average, not an average of
|
||||
instance averages. Both successful and unsuccessful attempts are included;
|
||||
add `outcome="ok"` to both selectors for successful attempts only.
|
||||
|
||||
Estimated p95 duration:
|
||||
|
||||
```promql
|
||||
histogram_quantile(
|
||||
0.95,
|
||||
sum by (le, job, operation) (
|
||||
rate(wakey_operation_duration_seconds_bucket[5m])
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
This needs classic histogram bucket series. Keep `le` in the grouping: it is
|
||||
the bucket boundary, in seconds. p95 is an estimate from those boundaries,
|
||||
not an exact sort of recorded durations. Few operations make percentiles noisy.
|
||||
|
||||
CC relay duration includes waiting for the agent; agent execution duration
|
||||
measures its dispatch separately. Do not sum both and call that user latency.
|
||||
|
||||
## Terminals
|
||||
|
||||
Active PTY workers by agent, including detached sessions:
|
||||
|
||||
```promql
|
||||
sum by (job, instance) (wakey_terminal_active)
|
||||
```
|
||||
|
||||
Read this directly as a count. It is not a count of browser tabs or attachments.
|
||||
|
||||
PTY output bytes per second:
|
||||
|
||||
```promql
|
||||
sum by (job, instance) (rate(wakey_terminal_output_bytes_total[5m]))
|
||||
```
|
||||
|
||||
Input bytes per second:
|
||||
|
||||
```promql
|
||||
sum by (job, instance) (rate(wakey_terminal_input_bytes_total[5m]))
|
||||
```
|
||||
|
||||
Set the unit to **bytes/sec**. Output is measured at the PTY read, even while
|
||||
detached; it is not WebSocket wire traffic or confirmed browser delivery.
|
||||
|
||||
Relay queue-full encounters over the last fifteen minutes:
|
||||
|
||||
```promql
|
||||
sum by (job, instance) (increase(wakey_terminal_backpressure_total[15m]))
|
||||
```
|
||||
|
||||
This counts encounters, not their duration and not dropped chunks. A burst of
|
||||
image output can raise it while output still arrives intact through backpressure.
|
||||
|
||||
Average duration of completed backpressure episodes, in seconds:
|
||||
|
||||
```promql
|
||||
sum by (job, instance, outcome) (
|
||||
rate(wakey_operation_duration_seconds_sum{operation="terminal_backpressure_wait"}[5m])
|
||||
)
|
||||
/
|
||||
sum by (job, instance, outcome) (
|
||||
rate(wakey_operation_duration_seconds_count{operation="terminal_backpressure_wait"}[5m])
|
||||
)
|
||||
```
|
||||
|
||||
An episode starts when the output channel is full and ends when the pending
|
||||
backlog drains or the relay disconnects. It is not per-frame network latency;
|
||||
unfinished episodes do not appear until they end. Keep the outcome grouping so
|
||||
disconnected episodes are not confused with successfully drained ones.
|
||||
|
||||
## Snapshots
|
||||
|
||||
Device entries in successfully sent snapshots over the last hour:
|
||||
|
||||
```promql
|
||||
sum by (job, instance) (increase(wakey_snapshot_devices_total[1h]))
|
||||
```
|
||||
|
||||
Repeated snapshots count the same devices again. This is collection volume,
|
||||
**not unique devices, current fleet size, or confirmed CC persistence**.
|
||||
|
||||
## Current Devices And Endpoints
|
||||
|
||||
These gauges are emitted by CC. Counts are sampled at startup and every 30 seconds,
|
||||
independently of UI requests, and exported on the normal metrics schedule. Allow
|
||||
up to two intervals for a change to appear. The snapshot counter above cannot
|
||||
substitute for these gauges.
|
||||
|
||||
A **device** is a grouped device identity. An **endpoint** is one observed address
|
||||
entry with an IP, optional MAC/interface, source, and presence. One PC can have
|
||||
multiple IPv4/IPv6 endpoints, and different sources or agents can observe the
|
||||
same address. An endpoint here is not an HTTP API route.
|
||||
|
||||
| Panel | Scope | Suggested visualization |
|
||||
| --- | --- | --- |
|
||||
| Stored devices by agent | Device rows currently retained in CC for each agent; not globally unique identities | Bar gauge, one value per agent |
|
||||
| Stored endpoints by agent | Endpoint rows currently retained in CC for each agent, including inactive entries; not unique IPs | Bar gauge, one value per agent |
|
||||
| Fleet devices | Grouped fleet identities, split into saved and unsaved | Stat, one value per category |
|
||||
|
||||
Stored device rows by agent:
|
||||
|
||||
```promql
|
||||
sum by (job, agent_id) (wakey_stored_devices{job="wakey-control-plane"})
|
||||
```
|
||||
|
||||
Stored endpoint rows by agent:
|
||||
|
||||
```promql
|
||||
sum by (job, agent_id) (wakey_stored_endpoints{job="wakey-control-plane"})
|
||||
```
|
||||
|
||||
Grouped fleet devices by manual identity status:
|
||||
|
||||
```promql
|
||||
sum by (job, identity) (wakey_fleet_devices{job="wakey-control-plane"})
|
||||
```
|
||||
|
||||
Adjust the `job` selector to your actual service label. These queries assume one
|
||||
CC owns the fleet; do not sum replicas of the same database as distinct devices.
|
||||
Enrolled agents with no stored rows report zero. After revocation, an agent can
|
||||
remain represented while its device rows are retained. When neither enrollment
|
||||
nor stored rows exist, the sampler stops reporting that agent label.
|
||||
|
||||
Use Grafana's **short** unit with zero decimal places. Read the gauges directly;
|
||||
do not apply `rate` or `increase`. Counts must reflect unfiltered CC state, not
|
||||
the last UI search or visibility filter. An agent going offline does not by
|
||||
itself delete its stored rows, so these counts are not online-device counts.
|
||||
|
||||
Do not sum per-agent device counts and label that value "unique fleet devices":
|
||||
the same device can be observed by more than one agent. Likewise, an endpoint
|
||||
count is not a count of distinct IP addresses. Saved/unsaved describes manual
|
||||
identity management, not online/offline/unknown presence.
|
||||
|
||||
Failed refreshes suppress observations until sampling recovers; they do not report
|
||||
zero or keep exporting an old sample. Prometheus may still show its last value
|
||||
until that series becomes stale. Export callbacks only read the in-memory sample;
|
||||
they never wait on database work. Sampling is disabled when OTLP is disabled.
|
||||
|
||||
## Empty or Strange Results
|
||||
|
||||
- No series: generate the relevant activity, wait for export, and check the metrics pipeline.
|
||||
- Different names: use the discovery query; collector translation can change suffixes and labels.
|
||||
- `NaN` average: no operations occurred in the window, so the denominator is zero. Show a gap, not a fake zero latency.
|
||||
- Empty `rate`: it needs multiple samples. Try a longer window and check the time range.
|
||||
- Fractional `increase`: Prometheus extrapolates to the window boundaries; event counts can therefore appear fractional.
|
||||
- Stale active count: the last exported value is not an immediate liveness check after a crash or network loss.
|
||||
- No `instance` label: CC does not currently set a service instance ID; agent telemetry does.
|
||||
|
||||
## References
|
||||
|
||||
- [Wakey telemetry setup and limitations](telemetry.md)
|
||||
- [Prometheus rate, increase, and histogram_quantile](https://prometheus.io/docs/prometheus/latest/querying/functions/)
|
||||
- [Alloy OTLP-to-Prometheus conversion](https://grafana.com/docs/alloy/latest/reference/components/otelcol/otelcol.exporter.prometheus/)
|
||||
@@ -0,0 +1,251 @@
|
||||
# Telemetry
|
||||
|
||||
CC and the agent daemon share the optional `wakey-core/telemetry` module.
|
||||
Local logs remain enabled. Configure an OTLP **gRPC** receiver to additionally
|
||||
export traces, structured logs, and metrics; HTTP/4318 is not supported in this slice.
|
||||
|
||||
CC configuration:
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
otlp_endpoint = "http://127.0.0.1:4317"
|
||||
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||
service_name = "wakey-control-plane"
|
||||
json_logs = true
|
||||
```
|
||||
|
||||
Agent configuration (replace the example with the collector's WireGuard address):
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
otlp_endpoint = "http://10.5.0.1:4317"
|
||||
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||
service_name = "wakey-agent"
|
||||
json_logs = false
|
||||
```
|
||||
|
||||
Omit `otlp_endpoint` to disable exporting. Omit `authorization_env` for a receiver
|
||||
that requires no authentication. Existing configs need no changes.
|
||||
An empty agent service name uses `wakey-agent`; its enrolled agent ID becomes
|
||||
the resource's `service.instance.id`, not a metric label. Telemetry config changes
|
||||
require a daemon restart, not SIGHUP. Short-lived enroll/reload/observe commands
|
||||
only log locally and do not initialize exporters.
|
||||
|
||||
## Receiver and filtering
|
||||
|
||||
The collector must enable OTLP receivers in **traces, logs, and metrics** pipelines.
|
||||
Loopback is sufficient for CC on the same host/network namespace. The router
|
||||
needs a collector listener reachable over WireGuard; do not expose an unauthenticated
|
||||
receiver on the public Internet. This initial setup assumes plaintext inside that
|
||||
tunnel. `json_logs` changes local output,
|
||||
not whether OTLP logs are exported.
|
||||
|
||||
## Credentials and services
|
||||
|
||||
The OpenWrt agent uses `/etc/config/wakey`; see the
|
||||
[UCI setup guide](openwrt-uci.md) for commands and permissions.
|
||||
The CC uses a systemd environment file as described below.
|
||||
|
||||
`authorization_env` names an environment variable containing the complete header:
|
||||
`Basic <base64(username:password)>` or `Bearer <token>`. With exporting enabled,
|
||||
missing/empty/invalid values fail startup; credentials are never written to TOML
|
||||
or included in error messages. Metadata is marked sensitive for Debug output.
|
||||
Base64 is not encryption: use the WireGuard/SSH tunnel or another protected transport.
|
||||
|
||||
The systemd CC reads the optional `/etc/wakey-control-plane/environment` file.
|
||||
|
||||
Use this portable literal format, **without quotes or `export`**:
|
||||
|
||||
```text
|
||||
# Replace this placeholder with the actual encoded credential locally.
|
||||
WAKEY_OTLP_AUTHORIZATION=Basic BASE64_OF_USERNAME_COLON_PASSWORD
|
||||
RUST_LOG=info
|
||||
```
|
||||
|
||||
Protect the file with owner `root:root` and mode `0600`. systemd's manager reads it
|
||||
before switching to the service user. Use the simple format above, with LF endings
|
||||
and no inline comments or multiline values.
|
||||
An absent file is allowed; configuring auth without providing its variable is not.
|
||||
|
||||
After changing the file, **restart** the service. On systemd, run `daemon-reload`
|
||||
once after installing the updated unit. OpenWrt's HUP reload does not reload the
|
||||
process environment. For a foreground WSL test, export the variable in that shell
|
||||
and use `http://127.0.0.1:4317` with the SSH tunnel; do not put credentials in CLI args.
|
||||
|
||||
Console logs, OTLP logs, and traces can have independent filters:
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
console_filter = "info"
|
||||
otlp_log_filter = "debug"
|
||||
trace_filter = "info"
|
||||
```
|
||||
|
||||
For each signal, an explicit filter wins over `RUST_LOG`, which wins over CLI
|
||||
verbosity (`-v`). With no filters, environment override, or verbosity flags, the
|
||||
application defaults remain info-level with SDK warnings. Metrics are unaffected.
|
||||
Explicit filters must be valid and nonempty; changing them requires a restart.
|
||||
Without explicit filters, `RUST_LOG=warn` suppresses informational spans and logs.
|
||||
Use `wakey_agent=info,wakey=info,wakey_core=info,opentelemetry=warn` for the agent and
|
||||
`wakey_control_plane=info,wakey_core=info,opentelemetry=warn` for CC. Exporter warnings
|
||||
remain local to prevent export-failure feedback loops. Both OTLP logs and traces
|
||||
exclude OpenTelemetry SDK targets and the `h2`, `hyper`, `hyper_util`, `tonic`
|
||||
and `tower` crate targets, even under broad debug filters. These transport
|
||||
crates also serve application traffic: their diagnostics stay console-only,
|
||||
while Wakey's command/storage/relay spans still export. Console filters remain
|
||||
independent. Increasing verbosity exports application debug events too; inspect
|
||||
them before enabling it broadly.
|
||||
|
||||
## Signals
|
||||
|
||||
- `relay_command` on CC propagates W3C context in an optional command field.
|
||||
`execute_command` on the agent adopts it; older messages/agents remain compatible.
|
||||
- `collect_device_snapshot` and `store_device_snapshot` measure collection and
|
||||
persistence as linked spans across the agent WebSocket. The snapshot carries
|
||||
optional W3C context from collection; CC adopts it after authentication checks.
|
||||
Periodic and requested syncs use the same path. Old messages and empty/invalid
|
||||
context remain valid but do not create a cross-process link. Both processes
|
||||
need tracing enabled and suitable filters/sampling to see both sides.
|
||||
- Terminal create/attach/close/resume spans describe lifecycle operations, not
|
||||
output chunks. Trace-linked OTLP logs use the SDK's tracing-context bridge.
|
||||
- Inventory source spans separate neighbor, DHCP lease, and hook-store reads
|
||||
from merging. `db_*` spans cover snapshot replacement, fleet reads, and manual
|
||||
identity changes, including transaction waits and result assembly where applicable.
|
||||
Snapshot replacement retains `db_replace_agent_snapshot` as its total duration,
|
||||
with four sequential child spans:
|
||||
- `db_pool_acquire`: obtaining a pooled connection, including any connection
|
||||
setup/checks; it is not exclusively queue wait.
|
||||
- `db_write_transaction_begin`: executing `BEGIN IMMEDIATE`, including SQLite
|
||||
writer-lock wait and worker scheduling, not a direct lock-wait measurement.
|
||||
- `db_snapshot_write_rows`: existing-key lookup, normalization, parent/child
|
||||
writes, and deletion of devices missing from the complete snapshot.
|
||||
- `db_snapshot_commit`: transaction commit, including its SQLite/worker work;
|
||||
this does not isolate filesystem flush time.
|
||||
Pool and transaction-begin spans come from the shared write helper, so they
|
||||
also appear under other write operations. No per-row spans or SQL values are
|
||||
recorded. A failed write rolls back through SQLx and has no commit span;
|
||||
fleet cache invalidation still happens only after a successful commit.
|
||||
- Fleet spans cover list/load/grouping, refresh fan-out with per-agent outcomes,
|
||||
and wake dispatch. Counts distinguish grouped, filtered, and returned rows;
|
||||
wake events identify the chosen agent and whether the route was explicit,
|
||||
without recording device identifiers or addresses.
|
||||
- Connectivity uses short `agent_dns`, `agent_connect`, `agent_handshake_send`,
|
||||
and CC `agent_auth_verify` spans, rather than connection-long spans. Connect
|
||||
attempts also contribute to operation metrics. Lifecycle events report retries
|
||||
with backoff and CC disconnect duration. Sending credentials is not an auth ACK;
|
||||
CC's authentication event is the authoritative result. DNS diagnostics are a
|
||||
separate lookup, not a measurement of the resolver inside WebSocket connect.
|
||||
- Terminal recovery uses `terminal_snapshot_generate` (encoded byte count),
|
||||
transport-attachment spans, and `terminal_snapshot_request`. These measure
|
||||
local work, not browser restoration or end-to-end image replay. Resume operation
|
||||
success means credentials were queued for the worker. `terminal_backpressure_wait`
|
||||
operation durations cover one full-queue backlog episode until it drains (`ok`)
|
||||
or disconnects (`disconnected`); cancellation can retain the default `error` outcome.
|
||||
|
||||
Instrument operation boundaries rather than individual rows or terminal chunks.
|
||||
Use `skip_all`, explicitly allow count fields, and instrument async futures rather
|
||||
than holding entered span guards across awaits. Do not record query arguments,
|
||||
SQL parameters, device payloads, or credentials. Span names are fixed across requests.
|
||||
|
||||
Metrics are exported every 30 seconds and at orderly shutdown:
|
||||
|
||||
The table uses OpenTelemetry instrument names. Prometheus conversion typically
|
||||
replaces dots with underscores and adds type/unit suffixes; histograms produce
|
||||
multiple series. See the [metrics recipe book](telemetry-recipes.md) for discovery
|
||||
queries, rates, averages, percentiles, and Grafana units.
|
||||
|
||||
| Metric | Meaning |
|
||||
| --- | --- |
|
||||
| `wakey.operations` | Completed command/snapshot attempts, labeled by operation and outcome |
|
||||
| `wakey.operation.duration` | Corresponding duration histogram in seconds |
|
||||
| `wakey.snapshot.devices` | Cumulative devices in snapshots successfully sent by the agent |
|
||||
| `wakey.stored.devices` | CC gauge of retained device rows, labeled by `agent_id` |
|
||||
| `wakey.stored.endpoints` | CC gauge of retained endpoint rows, including inactive entries, labeled by `agent_id` |
|
||||
| `wakey.fleet.devices` | CC gauge of unfiltered grouped identities, labeled by `identity` (`saved` or `unsaved`) |
|
||||
| `wakey.process.uptime` | Linux gauge: seconds since this process's kernel-reported start, including suspend |
|
||||
| `wakey.process.memory.resident` | Linux gauge: approximate resident memory in bytes, from VmRSS |
|
||||
| `wakey.process.cpu.time` | Linux counter: cumulative user + system CPU seconds for all process threads, excluding children |
|
||||
| `wakey.process.open_fds` | Linux gauge: current count of open process file descriptors |
|
||||
| `wakey.host.uptime` | Linux gauge: uptime in seconds reported by the process's procfs view, including suspend |
|
||||
| `wakey.host.memory.total` | Linux gauge: total usable RAM in bytes from the visible meminfo |
|
||||
| `wakey.host.memory.available` | Linux gauge: estimated available RAM in bytes; omitted if MemAvailable is unavailable |
|
||||
| `wakey.host.load` | Linux gauge: load average, with fixed `period` labels `1m`, `5m`, `15m` |
|
||||
| `wakey.terminal.active` | Agent PTY workers currently alive |
|
||||
| `wakey.terminal.input.bytes` | Input bytes received by the terminal relay |
|
||||
| `wakey.terminal.output.bytes` | Output bytes read from the PTY, including while detached |
|
||||
| `wakey.terminal.backpressure` | Relay queue-full encounters, not dropped output |
|
||||
|
||||
Metric labels do not include request/session IDs, MACs, or IPs. New spans use
|
||||
`skip_all` and explicitly select safe fields; terminal bytes and credentials are
|
||||
not attached. Profiles, host CPU-utilization metrics, connected-agent gauges, and export-loss
|
||||
dashboards are not part of this initial slice.
|
||||
|
||||
Process and host gauges are registered automatically for Linux agent/CC daemons
|
||||
when OTLP is enabled. `wakey-core` owns the OS-independent instruments and
|
||||
`ProcessMetricsSource` interface; `wakey-linux` supplies the procfs reader via
|
||||
its optional `telemetry` feature. Each daemon attaches that reader to its
|
||||
telemetry guard, so core has no Linux dependency or filesystem reads.
|
||||
Their small procfs reads run during the existing 30-second
|
||||
metric collection and final shutdown collection; no independent poller or task
|
||||
is started. Non-Linux builds omit these gauges. Missing/unreadable procfs data
|
||||
omits the affected observation instead of reporting zero or failing the daemon.
|
||||
No hostname, PID, command line or environment values are exported by this slice.
|
||||
Service/instance resource attributes identify each daemon; two daemons on the
|
||||
same host can report the same host uptime, so do not sum it across processes.
|
||||
CPU time is not elapsed time or CPU percentage: use its rate for consumption.
|
||||
Open-FD sampling can temporarily open descriptors counted by the measurement.
|
||||
Process metrics describe the daemon itself, not its child shells/PTY programs
|
||||
or the entire host. CPU/FD collection is also supplied by `wakey-linux`;
|
||||
unsupported platform readers may omit these optional measurements.
|
||||
Host memory/load come from the same optional Linux reader. `MemAvailable`
|
||||
estimates memory reusable without swapping and is not `MemFree`; no fallback
|
||||
is invented on older kernels. Load averages include runnable and uninterruptible
|
||||
tasks, so they are not CPU percentages. These are the visible kernel/procfs
|
||||
figures, not cgroup memory limits or container-specific load measurements;
|
||||
multiple daemons may export duplicate views of the same host.
|
||||
|
||||
RSS includes anonymous, file-backed and shared resident pages, not just heap,
|
||||
and is an approximate kernel measurement. Process age uses procfs start ticks
|
||||
and uptime, not wall-clock subtraction or telemetry initialization time.
|
||||
Container/time namespaces can change the visible host clock; this is the
|
||||
process's Linux view, not an assertion about a physical machine's boot time.
|
||||
The Linux parser is [procfs](https://docs.rs/procfs/0.18.0/procfs/), with optional
|
||||
default features disabled. Public `/healthz` remains unchanged.
|
||||
|
||||
Current-state fleet gauges sample independently every 30 seconds, with bounded
|
||||
revision retries to avoid mixed-state counts. Enrolled empty agents report zero;
|
||||
removed labels disappear when no enrollment or retained rows remain. Failed samples
|
||||
are omitted, not converted to zeros. No per-device labels are emitted. See the
|
||||
recipe book for scopes, queries, and the distinction between devices and endpoints.
|
||||
|
||||
## Failure policy
|
||||
|
||||
Trace and log queues are capped at 256 records each, with export batches of 64
|
||||
and a 3-second request timeout. Export happens off the application path. The
|
||||
OTLP exporters use their recommended retry policy: up to three retries after
|
||||
the first attempt, exponential backoff and jitter, within the exporter timeout
|
||||
budget. Temporary transport/receiver failures can recover; authentication
|
||||
rejection and partial-success responses are not retried. Requests already in
|
||||
flight can exceed the retry budget, so it is not a hard wall-clock deadline.
|
||||
The application does not wrap exports in another retry loop. On an
|
||||
outage, telemetry can be dropped rather than accumulating unbounded router RAM;
|
||||
terminal output follows its existing lossless backpressure policy independently.
|
||||
|
||||
Automated receiver tests reject the first export of each signal and confirm
|
||||
recovery, check the retry-attempt limit on a persistent outage, and confirm that
|
||||
bad credentials cause only one attempt. These tests do not establish durable
|
||||
delivery during a long outage; bounded queues can still drop telemetry.
|
||||
The bound is a record count, not an absolute byte limit on arbitrary log messages.
|
||||
Providers flush on orderly daemon exit (Ctrl-C/SIGTERM), on a blocking worker while
|
||||
Tokio remains alive. Only completed spans are exported; this is not graceful draining
|
||||
of every in-flight request. SIGKILL/power loss cannot flush. This is not a persistent
|
||||
telemetry spool.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo test -p wakey-core --features telemetry --test telemetry
|
||||
```
|
||||
|
||||
The test starts a temporary gRPC collector and verifies all three signals,
|
||||
command parent/child IDs, log correlation, and shutdown delivery without sleeps.
|
||||
@@ -53,6 +53,9 @@ Use netlink only when:
|
||||
|
||||
Today that mainly applies to `neighbor::nl`.
|
||||
|
||||
`wakey-linux` explicitly uses netlink for neighbor and address inventory on its
|
||||
Unix-gated path, without automatic JSON fallback.
|
||||
|
||||
## Relationship to wakey
|
||||
|
||||
`wakey` is the product.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
|
||||
//! rtnetlink-based neighbor query: dump links and neighbors, then filter in userspace.
|
||||
#![cfg(unix)]
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
*
|
||||
!.gitignore
|
||||
!copy.ps1
|
||||
!wslfix.sh
|
||||
Regular → Executable
+16
-1
@@ -14,7 +14,22 @@ pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
|
||||
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
|
||||
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/wakey-agent $PSScriptRoot
|
||||
|
||||
Remove-Item -Recurse (Join-Path $PSScriptRoot "root")
|
||||
$uciDir = Join-Path $PSScriptRoot 'uci-config'
|
||||
New-Item -ItemType Directory -Force -Path $uciDir | Out-Null
|
||||
# This ignored backup contains credentials; do not add it to version control.
|
||||
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/config/wakey (Join-Path $uciDir 'wakey')
|
||||
|
||||
$rootBackup = Join-Path $PSScriptRoot 'root'
|
||||
if (Test-Path -LiteralPath $rootBackup) {
|
||||
# Router backups include hidden/read-only files. Fail once if deletion is
|
||||
# genuinely blocked rather than merging a fresh copy into a partial backup.
|
||||
try {
|
||||
Remove-Item -LiteralPath $rootBackup -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
throw "Cannot remove previous router backup '$rootBackup': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/root $PSScriptRoot
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# sometimes it breaks, run to fix
|
||||
|
||||
sudo sh -c 'echo :WSLInterop:M::MZ::/init:PF > /usr/lib/binfmt.d/WSLInterop.conf'
|
||||
sudo systemctl restart systemd-binfmt
|
||||
@@ -1,74 +0,0 @@
|
||||
# Report: Device State Rewrite
|
||||
|
||||
## What changed
|
||||
|
||||
The control plane now stores **complete per-agent Device snapshots** instead of flat observation rows. The agent sends `Vec<Device>` (merged from DHCP, neighbors, hooks). The control plane upserts per agent, deletes stale, fleet view merges across agents at read time.
|
||||
|
||||
## Architecture (before → after)
|
||||
|
||||
**Before**: Agent → raw hook observations → control plane flattens into rows → re-merges at fleet read → presence recomputed from flat actions
|
||||
|
||||
**After**: Agent → inventory → `Vec<Device>` with correct presence → control plane stores as-is → fleet merge at read time from typed rows
|
||||
|
||||
## Schema
|
||||
|
||||
Old tables dropped: `agent_device_observations`, `agent_device_observation_events`, `agent_observation_snapshots`, `agent_observation_snapshot_keys`
|
||||
|
||||
New tables:
|
||||
- `agent_devices` — per-agent device row (keyed by `DeviceId` serialization)
|
||||
- `agent_device_macs` — MACs per device, indexed for cross-agent merge
|
||||
- `agent_device_ips` — IPs per device, indexed for cross-agent merge
|
||||
- `agent_device_hostnames` — hostnames per device
|
||||
- `agent_device_facts` — debug/source material (JSON, not identity)
|
||||
|
||||
## Concrete types throughout
|
||||
|
||||
- `FleetDevice.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`, `presence`: `Presence`
|
||||
- `FleetWakeRoute.mac`: `Option<MacAddr>`, `ip`: `Option<IpAddr>`
|
||||
- `AgentDeviceWithChildren.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`
|
||||
- Row types (`AgentDeviceMacRow`, `AgentDeviceIpRow`) stay `String` from SQLite, convert via `TryFrom` impls
|
||||
- `AgentDeviceRow` has `.presence()`, `.first_seen()`, `.last_seen()` methods — no string matching in business code
|
||||
- `Presence` from wakey-core: `Ord`, `PartialOrd`, `From<&str>`, `.as_str()`
|
||||
|
||||
## Rust features used
|
||||
|
||||
- `TryFrom<&AgentDeviceMacRow> for MacAddr`, `TryFrom<&AgentDeviceIpRow> for IpAddr`
|
||||
- `From<&str> for Presence`
|
||||
- `#[allow(dead_code)]` where appropriate (test-only methods, debug fields)
|
||||
- Workspace dependencies for shared crates (`macaddr`, `serde`, `tokio`, etc.)
|
||||
- Shared `TestStore` with `Drop` guard for clean test teardown
|
||||
|
||||
## What was removed
|
||||
|
||||
- `AgentObservation` struct (replaced by `Device`)
|
||||
- `AgentDeviceObservation`, `AgentDeviceObservationView`, `AgentDeviceObservationEvent`, `AgentDeviceObservationInput` types
|
||||
- `inventory_result_to_observations()` — the flatten-then-rebuild function
|
||||
- `upload_agent_observations`, `list_agent_observations`, `list_agent_observation_history` API endpoints
|
||||
- `gc_stale_observations()` — no longer needed
|
||||
- `SyncObservations` CLI command from agent
|
||||
- `prune_removed_observations_from_path` calls from agent session
|
||||
- Observations page from UI
|
||||
|
||||
## What stayed
|
||||
|
||||
- Agent observation store (`/tmp/wakey_observations.json`) — agent-local memory, not forwarded
|
||||
- Hotplug hooks — still update agent-local store, feed next inventory
|
||||
- Known devices + identifiers — manual lifecycle, never auto-deleted
|
||||
- Audit events, alerts — unchanged
|
||||
- `observation_retention` config field — `#[allow(dead_code)]`, kept for config file compat
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
cargo fmt — clean
|
||||
cargo clippy-all — clean (0 warnings)
|
||||
cargo test-all — 89 tests pass, 6 ignored (on-device only)
|
||||
pnpm typecheck — clean
|
||||
```
|
||||
|
||||
## Remaining
|
||||
|
||||
- UI cleanup: remove observations page nav entry, observation API types from `api.ts`
|
||||
- Known device identifier UI controls (API exists, UI needs affordance)
|
||||
- The `observation_retention` config field could be removed in a future cleanup
|
||||
- `list_agent_device_rows_for_agent` is `#[allow(dead_code)]` — used in tests, could be `#[cfg(test)]`
|
||||
@@ -7,12 +7,26 @@ USE_PROCD=1
|
||||
NAME=wakey
|
||||
BIN=/root/.bin/wakey-agent
|
||||
CONFIG=/etc/wakey-agent/config.toml
|
||||
UCI_CONFIG=/etc/config/wakey
|
||||
|
||||
start_service() {
|
||||
local authorization='' log_filter='info'
|
||||
if [ -f "$UCI_CONFIG" ]; then
|
||||
config_load wakey || return 1
|
||||
config_get authorization main otlp_authorization
|
||||
config_get log_filter main log_filter 'info'
|
||||
fi
|
||||
procd_open_instance
|
||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||
procd_set_param file "$CONFIG"
|
||||
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
||||
if [ -f "$UCI_CONFIG" ]; then
|
||||
procd_append_param file "$UCI_CONFIG"
|
||||
fi
|
||||
procd_set_param env "RUST_LOG=$log_filter"
|
||||
# Pass the complete header as one argument; never log its value.
|
||||
if [ -n "$authorization" ]; then
|
||||
procd_append_param env "WAKEY_OTLP_AUTHORIZATION=$authorization"
|
||||
fi
|
||||
procd_set_param respawn 5 1 0
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
|
||||
DB="${SQLX_PREPARE_DB:-$ROOT/wakey-prepare.sqlite3}"
|
||||
DATABASE_URL="sqlite://$DB"
|
||||
|
||||
if ! cargo sqlx --version >/dev/null 2>&1; then
|
||||
@@ -23,3 +23,5 @@ cp .sqlx/*.json wakey-control-plane/.sqlx/
|
||||
|
||||
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
|
||||
echo "Prepare DB (referenced by .env for live compile-time checking): $DB"
|
||||
echo "DATABASE_URL=$DATABASE_URL"
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "brotli==1.2.0",
|
||||
# "fonttools==4.63.0",
|
||||
# ]
|
||||
# ///
|
||||
"""Rebuild Wakey's self-hosted terminal webfont from Nerd Fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
NERD_FONTS_VERSION = "v3.4.0"
|
||||
ARCHIVE_SHA256 = "ef552a3e638f25125c6ad4c51176a6adcdce295ab1d2ffacf0db060caf8c1582"
|
||||
WOFF2_SHA256 = "50680a64466fbcc3eff68e00f84505f55681c86f8450c042416af5a8dbc1cce1"
|
||||
FONT_FILE = "JetBrainsMonoNerdFontMono-Regular"
|
||||
ARCHIVE_URL = (
|
||||
"https://github.com/ryanoasis/nerd-fonts/releases/download/"
|
||||
f"{NERD_FONTS_VERSION}/JetBrainsMono.tar.xz"
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUTPUT_DIR = ROOT / "ui/src/assets/fonts/jetbrains-mono-nerd"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_sha256(path: Path, expected: str) -> None:
|
||||
actual = sha256(path)
|
||||
if actual != expected:
|
||||
raise RuntimeError(
|
||||
f"checksum mismatch for {path}\nexpected: {expected}\nactual: {actual}"
|
||||
)
|
||||
|
||||
|
||||
def download(url: str, destination: Path) -> None:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "wakey-font-preparer"})
|
||||
with (
|
||||
urllib.request.urlopen(request, timeout=60) as response,
|
||||
destination.open("wb") as output,
|
||||
):
|
||||
shutil.copyfileobj(response, output)
|
||||
|
||||
|
||||
def extract_member(archive: tarfile.TarFile, name: str, destination: Path) -> None:
|
||||
source = archive.extractfile(name)
|
||||
if source is None:
|
||||
raise RuntimeError(f"missing {name} in Nerd Fonts archive")
|
||||
with source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def install_file(source: Path, destination: Path) -> None:
|
||||
"""Atomically replace an asset without exposing a partial file to Vite."""
|
||||
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||
try:
|
||||
shutil.copyfile(source, temporary)
|
||||
os.chmod(temporary, 0o644)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="wakey-terminal-font.") as temp:
|
||||
work_dir = Path(temp)
|
||||
archive_path = work_dir / "JetBrainsMono.tar.xz"
|
||||
ttf_path = work_dir / f"{FONT_FILE}.ttf"
|
||||
woff2_path = work_dir / f"{FONT_FILE}.woff2"
|
||||
license_path = work_dir / "OFL.txt"
|
||||
|
||||
print(f"Downloading Nerd Fonts {NERD_FONTS_VERSION} JetBrainsMono archive")
|
||||
download(ARCHIVE_URL, archive_path)
|
||||
verify_sha256(archive_path, ARCHIVE_SHA256)
|
||||
|
||||
with tarfile.open(archive_path, "r:xz") as archive:
|
||||
extract_member(archive, ttf_path.name, ttf_path)
|
||||
extract_member(archive, license_path.name, license_path)
|
||||
|
||||
# FontTools otherwise stamps the current time into the head table,
|
||||
# making equivalent rebuilds produce different WOFF2 files.
|
||||
font = TTFont(ttf_path, recalcTimestamp=False)
|
||||
font.flavor = "woff2"
|
||||
font.save(woff2_path)
|
||||
verify_sha256(woff2_path, WOFF2_SHA256)
|
||||
|
||||
install_file(woff2_path, OUTPUT_DIR / woff2_path.name)
|
||||
install_file(license_path, OUTPUT_DIR / license_path.name)
|
||||
|
||||
print(f"Prepared {OUTPUT_DIR / f'{FONT_FILE}.woff2'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+13
-9
@@ -54,17 +54,21 @@ if ($Publish) {
|
||||
Write-Host 'No -Registry provided; defaulting publish target to registry: gitea'
|
||||
}
|
||||
|
||||
$publishOrder = @(
|
||||
'lda-ipjs',
|
||||
'wakey-core',
|
||||
'wakey-linux',
|
||||
'wakey',
|
||||
'wakey-agent',
|
||||
'wakey-control-plane'
|
||||
# Patched external crates are excluded from the workspace, so publish
|
||||
# them by manifest path before workspace packages that depend on them.
|
||||
$publishTargets = @(
|
||||
@{ Name = 'wakey-vt100'; Args = @('publish', '--manifest-path', 'vendor/vt100/Cargo.toml') },
|
||||
@{ Name = 'lda-ipjs'; Args = @('publish', '-p', 'lda-ipjs') },
|
||||
@{ Name = 'wakey-core'; Args = @('publish', '-p', 'wakey-core') },
|
||||
@{ Name = 'wakey-linux'; Args = @('publish', '-p', 'wakey-linux') },
|
||||
@{ Name = 'wakey'; Args = @('publish', '-p', 'wakey') },
|
||||
@{ Name = 'wakey-agent'; Args = @('publish', '-p', 'wakey-agent') },
|
||||
@{ Name = 'wakey-control-plane'; Args = @('publish', '-p', 'wakey-control-plane') }
|
||||
)
|
||||
|
||||
foreach ($pkg in $publishOrder) {
|
||||
$pubArgs = @('publish', '-p', $pkg)
|
||||
foreach ($target in $publishTargets) {
|
||||
$pkg = $target.Name
|
||||
$pubArgs = @($target.Args)
|
||||
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
||||
|
||||
Write-Host ("publishing {0}..." -f $pkg)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
# Host-side contract test; OpenWrt's UCI and procd helpers are stubbed.
|
||||
set -eu
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
|
||||
|
||||
config_load() { [ "$1" = wakey ]; loaded=1; return "$load_status"; }
|
||||
config_get() {
|
||||
[ "$2" = main ]
|
||||
case "$3" in
|
||||
otlp_authorization) authorization=$test_authorization ;;
|
||||
log_filter) log_filter=${test_log_filter:-$4} ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
procd_open_instance() { opened=1; }
|
||||
procd_close_instance() { :; }
|
||||
procd_set_param() {
|
||||
if [ "$1" = env ]; then
|
||||
[ "$#" -eq 2 ]
|
||||
received_filter=$2
|
||||
fi
|
||||
}
|
||||
procd_append_param() {
|
||||
case "$1" in
|
||||
env) [ "$#" -eq 2 ]; received_auth=$2 ;;
|
||||
file) tracked_file=$2 ;;
|
||||
esac
|
||||
}
|
||||
. "$ROOT/scripts/init/openwrt/wakey"
|
||||
UCI_CONFIG="$TMP/wakey"
|
||||
touch "$UCI_CONFIG"
|
||||
load_status=0
|
||||
loaded=0
|
||||
opened=0
|
||||
test_authorization='Basic dGVzdDp0ZXN0=='
|
||||
test_log_filter='wakey_agent=debug,opentelemetry=warn'
|
||||
start_service
|
||||
[ "$loaded" -eq 1 ]
|
||||
[ "$opened" -eq 1 ]
|
||||
[ "$received_auth" = "WAKEY_OTLP_AUTHORIZATION=$test_authorization" ]
|
||||
[ "$received_filter" = "RUST_LOG=$test_log_filter" ]
|
||||
[ "$tracked_file" = "$UCI_CONFIG" ]
|
||||
|
||||
# Missing options retain the default filter and do not export empty credentials.
|
||||
test_authorization=''
|
||||
test_log_filter=''
|
||||
received_auth=''
|
||||
start_service
|
||||
[ "$received_auth" = '' ]
|
||||
[ "$received_filter" = 'RUST_LOG=info' ]
|
||||
|
||||
load_status=1
|
||||
opened=0
|
||||
if start_service; then
|
||||
echo 'failed UCI load was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$opened" -eq 0 ]
|
||||
|
||||
UCI_CONFIG="$TMP/absent"
|
||||
loaded=0
|
||||
tracked_file=''
|
||||
start_service
|
||||
[ "$loaded" -eq 0 ]
|
||||
[ "$tracked_file" = '' ]
|
||||
[ "$received_filter" = 'RUST_LOG=info' ]
|
||||
echo 'PASS: OpenWrt UCI environment forwarding and optional configuration'
|
||||
@@ -110,6 +110,16 @@ update_existing_repo() {
|
||||
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
||||
}
|
||||
|
||||
update_submodules() {
|
||||
repo_dir=$1
|
||||
|
||||
# Sync first so an updated .gitmodules URL is honored by an existing
|
||||
# checkout, then initialize and pin every nested submodule to the commit
|
||||
# recorded by the parent repository.
|
||||
git -C "$repo_dir" submodule sync --recursive || fail 'git submodule sync failed'
|
||||
git -C "$repo_dir" submodule update --init --recursive || fail 'git submodule update failed'
|
||||
}
|
||||
|
||||
install_executable_force() {
|
||||
src=$1
|
||||
dst=$2
|
||||
@@ -186,6 +196,7 @@ main() {
|
||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||
fail 'git clone failed'
|
||||
fi
|
||||
update_submodules "$WORKDIR"
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ pub use service::{
|
||||
};
|
||||
pub use wakey_linux::observations::{
|
||||
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
||||
prune_stale_observations_from_path,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+104
-10
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{Instrument, debug, warn};
|
||||
use wakey_core::{
|
||||
Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
|
||||
NeighborEntry, Presence, Query,
|
||||
@@ -19,10 +19,28 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
||||
/// This is the current center of gravity for the service layer. Higher-level
|
||||
/// status and wake flows should prefer deriving from inventory rather than
|
||||
/// directly from raw Linux source rows.
|
||||
#[tracing::instrument(name = "inventory", skip_all, fields(devices = tracing::field::Empty))]
|
||||
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
||||
let leases = get_leases().await?;
|
||||
let observations = match wakey_linux::observations::list_local_observations().await {
|
||||
// Instrument futures, not entered guards: each source remains correctly
|
||||
// parented when Tokio moves the task between workers during an await.
|
||||
let neighbors = async {
|
||||
let rows = wakey_linux::devices::query_neighbors(&query).await?;
|
||||
debug!(rows = rows.len(), "neighbor source collected");
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}
|
||||
.instrument(tracing::info_span!("inventory_neighbors"))
|
||||
.await?;
|
||||
let leases = async {
|
||||
let rows = get_leases().await?;
|
||||
debug!(rows = rows.len(), "DHCP source collected");
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}
|
||||
.instrument(tracing::info_span!("inventory_dhcp_leases"))
|
||||
.await?;
|
||||
let observations = match wakey_linux::observations::list_local_observations()
|
||||
.instrument(tracing::info_span!("inventory_hook_store"))
|
||||
.await
|
||||
{
|
||||
Ok(observations) => observations
|
||||
.into_iter()
|
||||
.filter_map(local_observation_to_fact)
|
||||
@@ -39,6 +57,7 @@ pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||
"building device inventory"
|
||||
);
|
||||
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
|
||||
tracing::Span::current().record("devices", devices.len());
|
||||
debug!(devices = devices.len(), "merged device inventory");
|
||||
Ok(DeviceInventory { devices })
|
||||
}
|
||||
@@ -57,6 +76,7 @@ pub fn merge_devices(
|
||||
|
||||
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
|
||||
/// aggregates.
|
||||
#[tracing::instrument(name = "inventory_merge", skip_all, fields(neighbors = neighbors.len(), leases = leases.len(), observations = observations.len()))]
|
||||
pub fn merge_devices_with_observations(
|
||||
neighbors: Vec<NeighborEntry>,
|
||||
leases: Vec<DhcpLeaseWithState>,
|
||||
@@ -77,7 +97,7 @@ pub fn merge_devices_with_observations(
|
||||
let key = row
|
||||
.mac
|
||||
.map(|m| m.to_string())
|
||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
||||
.unwrap_or_else(|| format!("ip:{}", row.ip)); // key by mac or by FAILED ip
|
||||
by_key.entry(key).or_default().0.push(row);
|
||||
}
|
||||
for lease in leases {
|
||||
@@ -99,7 +119,7 @@ pub fn merge_devices_with_observations(
|
||||
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !query.is_empty() {
|
||||
let mut texts = Vec::new();
|
||||
let mut devs = Vec::new();
|
||||
let mut ips = Vec::new();
|
||||
@@ -123,6 +143,7 @@ pub fn merge_devices_with_observations(
|
||||
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
||||
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
||||
});
|
||||
}
|
||||
devices.sort_by(|a, b| {
|
||||
presence_rank(b.presence)
|
||||
.cmp(&presence_rank(a.presence))
|
||||
@@ -168,11 +189,13 @@ pub fn local_observation_to_fact(
|
||||
}
|
||||
|
||||
const fn presence_rank(presence: Presence) -> u8 {
|
||||
// Match the core signal order: Unknown has no verdict, while Offline
|
||||
// carries concrete evidence and belongs above it in the inventory list.
|
||||
match presence {
|
||||
Presence::Online => 3,
|
||||
Presence::LikelyOnline => 2,
|
||||
Presence::Unknown => 1,
|
||||
Presence::Offline => 0,
|
||||
Presence::Offline => 1,
|
||||
Presence::Unknown => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +203,7 @@ const fn presence_rank(presence: Presence) -> u8 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
|
||||
use wakey_core::{DeviceId, DhcpLease, EndpointSource, InventoryQueryBuilder, NeighborState};
|
||||
|
||||
fn sample_neighbors() -> Vec<NeighborEntry> {
|
||||
vec![NeighborEntry {
|
||||
@@ -203,6 +226,23 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_sorts_offline_before_unknown() {
|
||||
let neighbors = [NeighborState::Incomplete, NeighborState::Failed]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, state)| NeighborEntry {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, index as u8 + 10)),
|
||||
dev: None,
|
||||
mac: None,
|
||||
state,
|
||||
})
|
||||
.collect();
|
||||
let devices = merge_devices(neighbors, Vec::new(), &InventoryQueryBuilder::new().build());
|
||||
assert_eq!(devices[0].presence, Presence::Offline);
|
||||
assert_eq!(devices[1].presence, Presence::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_applies_and_across_categories() {
|
||||
let query = InventoryQueryBuilder::new()
|
||||
@@ -278,11 +318,14 @@ mod tests {
|
||||
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
||||
);
|
||||
assert_eq!(out[0].observations.len(), 1);
|
||||
assert_eq!(out[0].endpoints.len(), 1);
|
||||
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
|
||||
assert!(out[0].ips.is_empty());
|
||||
assert_eq!(out[0].presence, Presence::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_marks_remove_only_observation_offline() {
|
||||
fn merge_devices_marks_neigh_remove_observation_offline() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
@@ -301,5 +344,56 @@ mod tests {
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].presence, Presence::Offline);
|
||||
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookNeighbor);
|
||||
assert!(out[0].ips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_keeps_dhcp_remove_observation_unknown() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![DeviceObservationFact {
|
||||
kind: "dhcp".into(),
|
||||
action: "remove".into(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||
hostname: None,
|
||||
first_seen_unix: Some(10),
|
||||
last_seen_unix: Some(20),
|
||||
}],
|
||||
&query,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].presence, Presence::Unknown);
|
||||
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
|
||||
assert!(out[0].ips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_uses_ip_id_for_ip_only_hook_endpoint_without_summary_ip() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 31));
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![DeviceObservationFact {
|
||||
kind: "neigh".into(),
|
||||
action: "remove".into(),
|
||||
mac: None,
|
||||
ip: Some(ip),
|
||||
hostname: None,
|
||||
first_seen_unix: Some(10),
|
||||
last_seen_unix: Some(20),
|
||||
}],
|
||||
&query,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].id, Some(DeviceId::Ip(ip)));
|
||||
assert!(out[0].ips.is_empty());
|
||||
assert_eq!(out[0].endpoints.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
+18
-1
@@ -2,8 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||
/>
|
||||
<title>Wakey Operator UI</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("wakey-ui-theme");
|
||||
if (
|
||||
t === "dark" ||
|
||||
((t === "system" || !t) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xterm/addon-clipboard": "0.3.0-beta.301",
|
||||
"@xterm/addon-fit": "0.12.0-beta.301",
|
||||
"@xterm/addon-image": "0.10.0-beta.301",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.301",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.301",
|
||||
"@xterm/addon-web-links": "0.13.0-beta.301",
|
||||
"@xterm/xterm": "6.1.0-beta.301",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
|
||||
Generated
+1435
-2750
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
msw: false
|
||||
+130
-17
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
setAgentNickname,
|
||||
} from "@/api";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AppLayout } from "@/layout/AppLayout";
|
||||
import { AgentsPage } from "@/pages/AgentsPage";
|
||||
import { AlertsPage } from "@/pages/AlertsPage";
|
||||
@@ -23,8 +24,13 @@ import { CommandsPage } from "@/pages/CommandsPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
|
||||
const TerminalPage = lazy(() =>
|
||||
import("@/pages/TerminalPage").then((module) => ({
|
||||
default: module.TerminalPage,
|
||||
})),
|
||||
);
|
||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
@@ -34,22 +40,48 @@ export function App() {
|
||||
const [history, setHistory] = useState<AlertTransition[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [alertsHealth, setAlertsHealth] = useState<{
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
} | null>(null);
|
||||
const [alertsStreamConnected, setAlertsStreamConnected] = useState(false);
|
||||
const streamHealthReceived = useRef(false);
|
||||
const alertsRevision = useRef(0);
|
||||
|
||||
const [state, setState] = useState<LoadState>("idle");
|
||||
|
||||
async function loadAlertsWithoutBlocking() {
|
||||
try {
|
||||
const nextAlerts = await fetchAlerts();
|
||||
if (!streamHealthReceived.current) {
|
||||
setAlertsHealth({ available: true });
|
||||
}
|
||||
return nextAlerts;
|
||||
} catch (err) {
|
||||
if (!streamHealthReceived.current) {
|
||||
setAlertsHealth({ available: false, reason: String(err) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const revision = alertsRevision.current;
|
||||
setState("loading");
|
||||
try {
|
||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||
await Promise.all([
|
||||
fetchAgents(),
|
||||
fetchAlerts(),
|
||||
loadAlertsWithoutBlocking(),
|
||||
fetchAlertHistory(20),
|
||||
fetchAudit(30),
|
||||
]);
|
||||
setAgents(nextAgents);
|
||||
setAlerts(nextAlerts);
|
||||
// Streamed state wins over HTTP requests started before its arrival.
|
||||
if (revision === alertsRevision.current) {
|
||||
if (nextAlerts) setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
setAudit(nextAudit);
|
||||
const firstConnectedAgentId =
|
||||
nextAgents.find((agent) => agent.connected)?.agent_id ?? "";
|
||||
@@ -94,43 +126,103 @@ export function App() {
|
||||
}
|
||||
|
||||
async function refreshAlertsAndHistory() {
|
||||
const revision = alertsRevision.current;
|
||||
const [nextAlerts, nextHistory] = await Promise.all([
|
||||
fetchAlerts(),
|
||||
loadAlertsWithoutBlocking(),
|
||||
fetchAlertHistory(20),
|
||||
]);
|
||||
setAlerts(nextAlerts);
|
||||
if (revision === alertsRevision.current) {
|
||||
if (nextAlerts) setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let socket: WebSocket | null = null;
|
||||
let disposed = false;
|
||||
let attempt = 0;
|
||||
let openedAt = 0;
|
||||
let reconnectTimer: number | undefined;
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
const resync = () => {
|
||||
const generation = alertsRevision.current;
|
||||
void Promise.all([fetchAlerts(), fetchAlertHistory(20)])
|
||||
.then(([nextAlerts, nextHistory]) => {
|
||||
// A WS snapshot delivered while this request was in flight is newer.
|
||||
if (disposed || generation !== alertsRevision.current) return;
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed) return;
|
||||
openedAt = Date.now();
|
||||
setAlertsStreamConnected(true);
|
||||
// Pull anything missed while the stream was down.
|
||||
resync();
|
||||
};
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
if (disposed) return;
|
||||
alertsRevision.current += 1;
|
||||
try {
|
||||
const payload = JSON.parse(String(evt.data)) as {
|
||||
type?: string;
|
||||
available?: boolean;
|
||||
reason?: string;
|
||||
alerts?: Alert[];
|
||||
recent_transitions?: AlertTransition[];
|
||||
};
|
||||
if (payload.type === "alerts_health") {
|
||||
streamHealthReceived.current = true;
|
||||
setAlertsHealth({
|
||||
available: payload.available === true,
|
||||
reason: payload.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload.alerts) setAlerts(payload.alerts);
|
||||
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
||||
if (payload.recent_transitions)
|
||||
setHistory(payload.recent_transitions);
|
||||
} catch {
|
||||
// Ignore malformed stream payloads and keep current UI state.
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
const id = window.setInterval(() => {
|
||||
void refreshAlertsAndHistory().catch(() => undefined);
|
||||
}, 8000);
|
||||
ws.onclose = () => window.clearInterval(id);
|
||||
socket.onclose = () => {
|
||||
socket = null;
|
||||
if (disposed) return;
|
||||
setAlertsStreamConnected(false);
|
||||
// Only a connection that stayed up proves the stream is healthy; an
|
||||
// accept-then-close server must not pin us to the first backoff step.
|
||||
if (openedAt && Date.now() - openedAt > 10_000) attempt = 0;
|
||||
openedAt = 0;
|
||||
// Browsers never reconnect a WebSocket on their own.
|
||||
const delay =
|
||||
Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000;
|
||||
attempt += 1;
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -145,6 +237,7 @@ export function App() {
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onAfterWake={loadAll}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -168,6 +261,8 @@ export function App() {
|
||||
transitions={history}
|
||||
audit={audit}
|
||||
loading={state === "loading"}
|
||||
alertsHealth={alertsHealth}
|
||||
alertsStreamConnected={alertsStreamConnected}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
@@ -210,12 +305,30 @@ export function App() {
|
||||
<AlertsPage
|
||||
alerts={alerts}
|
||||
transitions={history}
|
||||
alertsHealth={alertsHealth}
|
||||
alertsStreamConnected={alertsStreamConnected}
|
||||
onRefresh={refreshAlertsAndHistory}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
<Route path="observations" element={<ObservationsPage />} />
|
||||
<Route
|
||||
path="terminal"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton className="h-[calc(100dvh-2.5rem)] w-full" />
|
||||
}
|
||||
>
|
||||
<TerminalPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+139
-67
@@ -2,6 +2,21 @@ export type Agent = {
|
||||
agent_id: string;
|
||||
connected: boolean;
|
||||
nickname?: string | null;
|
||||
capabilities: "terminal"[];
|
||||
capability_options?: {
|
||||
terminal?: { max_sessions: number };
|
||||
};
|
||||
};
|
||||
|
||||
export type TerminalSession = {
|
||||
terminal_id: string;
|
||||
agent_id: string;
|
||||
created_at_unix: number;
|
||||
expires_at_unix?: number | null;
|
||||
agent_attached: boolean;
|
||||
operator_attached: boolean;
|
||||
websocket_url: string;
|
||||
attachment_token?: string;
|
||||
};
|
||||
|
||||
export type Alert = {
|
||||
@@ -96,32 +111,6 @@ export type KnownDeviceSummary = {
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
export type AgentDeviceObservation = {
|
||||
observation_key: string;
|
||||
agent_id: string;
|
||||
kind: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
first_seen_unix: number;
|
||||
last_seen_unix: number;
|
||||
last_action: string;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type AgentDeviceObservationEvent = {
|
||||
event_id: string;
|
||||
observation_key: string;
|
||||
agent_id: string;
|
||||
kind: string;
|
||||
action: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
ts_unix: number;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type FleetDeviceAgent = {
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
@@ -129,6 +118,20 @@ export type FleetDeviceAgent = {
|
||||
last_seen_unix: number;
|
||||
};
|
||||
|
||||
export type FleetDeviceEndpoint = {
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
connected: boolean;
|
||||
source: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
interface: string | null;
|
||||
presence: string;
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
};
|
||||
|
||||
export type FleetWakeRoute = {
|
||||
route_id: string;
|
||||
agent_id: string;
|
||||
@@ -137,7 +140,9 @@ export type FleetWakeRoute = {
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
interface: string | null;
|
||||
source: string;
|
||||
presence: string;
|
||||
last_seen_unix: number;
|
||||
wakeable: boolean;
|
||||
};
|
||||
@@ -152,6 +157,7 @@ export type FleetDevice = {
|
||||
hostnames: string[];
|
||||
agents: FleetDeviceAgent[];
|
||||
sources: string[];
|
||||
endpoints: FleetDeviceEndpoint[];
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
presence: string;
|
||||
@@ -179,6 +185,25 @@ export type WakeFleetDeviceResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export interface APIErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
};
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details: any,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "APIError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
@@ -190,11 +215,22 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw;
|
||||
let parsed: APIErrorResponse | null = null;
|
||||
try {
|
||||
detail = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {}
|
||||
throw new Error(`${res.status} ${res.statusText}\n${detail}`);
|
||||
parsed = JSON.parse(raw);
|
||||
// assume APIError
|
||||
if (parsed?.error?.details) {
|
||||
parsed.error.details = JSON.stringify(parsed.error.details);
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
|
||||
throw new APIError(
|
||||
`${res.status} ${res.statusText}\n${raw}`,
|
||||
parsed?.error?.code ?? "unknown",
|
||||
parsed?.error?.details ?? parsed?.error?.message ?? raw,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
@@ -204,6 +240,44 @@ export function fetchAgents(): Promise<Agent[]> {
|
||||
return request<Agent[]>("/api/v1/control/agents");
|
||||
}
|
||||
|
||||
export function createTerminal(
|
||||
agentId: string,
|
||||
rows: number,
|
||||
cols: number,
|
||||
): Promise<TerminalSession> {
|
||||
return request<TerminalSession>("/api/v1/control/terminals", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ agent_id: agentId, rows, cols }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listTerminals(): Promise<TerminalSession[]> {
|
||||
return request<TerminalSession[]>("/api/v1/control/terminals");
|
||||
}
|
||||
|
||||
export function attachTerminal(
|
||||
terminalId: string,
|
||||
operatorId: string,
|
||||
): Promise<TerminalSession> {
|
||||
return request<TerminalSession>(
|
||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ operator_id: operatorId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeTerminal(terminalId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to close terminal: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchAlerts(): Promise<Alert[]> {
|
||||
return request<Alert[]>("/api/v1/control/alerts");
|
||||
}
|
||||
@@ -265,6 +339,12 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
||||
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||
}
|
||||
|
||||
export function fetchKnownDevice(deviceId: string): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchFleetDevices(opts?: {
|
||||
query?: string;
|
||||
presence?: string;
|
||||
@@ -287,6 +367,12 @@ export function fetchFleetDevices(opts?: {
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchFleetDevice(deviceKey: string): Promise<FleetDevice> {
|
||||
return request<FleetDevice>(
|
||||
`/api/v1/control/fleet/devices/${encodeURIComponent(deviceKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function refreshFleetDevices(input?: {
|
||||
agentIds?: string[];
|
||||
timeoutMs?: number;
|
||||
@@ -318,38 +404,6 @@ export function wakeFleetDevice(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchObservations(opts?: {
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
}): Promise<AgentDeviceObservation[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<AgentDeviceObservation[]>(
|
||||
`/api/v1/control/observations?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchObservationHistory(opts?: {
|
||||
agentId?: string;
|
||||
kind?: string;
|
||||
mac?: string;
|
||||
ip?: string;
|
||||
observationKey?: string;
|
||||
limit?: number;
|
||||
}): Promise<AgentDeviceObservationEvent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
if (opts?.kind) params.set("kind", opts.kind);
|
||||
if (opts?.mac) params.set("mac", opts.mac);
|
||||
if (opts?.ip) params.set("ip", opts.ip);
|
||||
if (opts?.observationKey) params.set("observation_key", opts.observationKey);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<AgentDeviceObservationEvent[]>(
|
||||
`/api/v1/control/observations/history?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createKnownDevice(input: {
|
||||
display_name: string;
|
||||
pinned?: boolean;
|
||||
@@ -367,19 +421,27 @@ export function createKnownDevice(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function attachObservationIdentifier(
|
||||
export function renameKnownDevice(
|
||||
deviceId: string,
|
||||
observationKey: string,
|
||||
displayName: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/from-observation`,
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ observation_key: observationKey }),
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function forgetKnownDevice(
|
||||
deviceId: string,
|
||||
): Promise<{ device_id: string; forgotten: boolean }> {
|
||||
return request(`/api/v1/control/devices/${encodeURIComponent(deviceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function attachDeviceIdentifier(
|
||||
deviceId: string,
|
||||
identifier: { kind: string; value: string },
|
||||
@@ -393,6 +455,16 @@ export function attachDeviceIdentifier(
|
||||
);
|
||||
}
|
||||
|
||||
export function detachDeviceIdentifier(
|
||||
deviceId: string,
|
||||
identifierKey: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/${encodeURIComponent(identifierKey)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeKnownDevice(
|
||||
targetDeviceId: string,
|
||||
sourceDeviceId: string,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# JetBrainsMono Nerd Font Mono
|
||||
|
||||
`JetBrainsMonoNerdFontMono-Regular.woff2` is the regular monospaced variant
|
||||
from Nerd Fonts v3.4.0 (JetBrains Mono v2.304), converted from the release TTF
|
||||
to WOFF2 without subsetting.
|
||||
|
||||
Source archive:
|
||||
https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/JetBrainsMono.tar.xz
|
||||
|
||||
The font is distributed under the SIL Open Font License in `OFL.txt`.
|
||||
|
||||
Rebuild the checked-in webfont with:
|
||||
|
||||
```sh
|
||||
./scripts/prepare_terminal_font.py
|
||||
```
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
Bot,
|
||||
FileText,
|
||||
Key,
|
||||
Eye,
|
||||
Terminal,
|
||||
SquareTerminal,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
@@ -49,7 +49,6 @@ const navSections: NavSection[] = [
|
||||
items: [
|
||||
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
|
||||
{ to: "/alerts", label: "Alerts", icon: Bell },
|
||||
{ to: "/observations", label: "Observations", icon: Eye },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,6 +58,7 @@ const navSections: NavSection[] = [
|
||||
{ to: "/audit", label: "Audit", icon: FileText },
|
||||
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -95,7 +95,7 @@ export function AppLayout() {
|
||||
className={[
|
||||
"sidebar",
|
||||
collapsed ? "sidebar--collapsed" : "",
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out",
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-100 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",
|
||||
]
|
||||
@@ -221,8 +221,14 @@ export function AppLayout() {
|
||||
{/* Mobile backdrop */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||
onClick={() => setCollapsed(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
|
||||
}}
|
||||
aria-label="Close menu"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { App } from "@/App";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -16,10 +16,18 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
type Props = {
|
||||
alerts: Alert[];
|
||||
transitions: AlertTransition[];
|
||||
alertsHealth: { available: boolean; reason?: string } | null;
|
||||
alertsStreamConnected: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
export function AlertsPage({
|
||||
alerts,
|
||||
transitions,
|
||||
alertsHealth,
|
||||
alertsStreamConnected,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const [severity, setSeverity] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [kind, setKind] = useState("all");
|
||||
@@ -66,13 +74,34 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Active Alerts</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => void onRefresh()}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||
{alertsHealth?.available === false && (
|
||||
<div>
|
||||
Alert results may be stale or unavailable
|
||||
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void onRefresh()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-severity"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Severity
|
||||
<Select
|
||||
value={severity}
|
||||
@@ -80,7 +109,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setSeverity(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-severity" className="w-full">
|
||||
<SelectValue placeholder="Severity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -93,7 +122,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-status"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Status
|
||||
<Select
|
||||
value={status}
|
||||
@@ -101,7 +133,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setStatus(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-status" className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -114,7 +146,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-kind"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Kind
|
||||
<Select
|
||||
value={kind}
|
||||
@@ -122,7 +157,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setKind(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-kind" className="w-full">
|
||||
<SelectValue placeholder="Kind" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -170,9 +205,13 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-transition-search"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Search
|
||||
<Input
|
||||
id="alert-transition-search"
|
||||
value={transitionQ}
|
||||
onChange={(e) => setTransitionQ(e.target.value)}
|
||||
placeholder="kind, status, message, agent"
|
||||
|
||||
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="audit-event-type"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Event type
|
||||
<Select
|
||||
value={eventType}
|
||||
@@ -78,7 +81,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
if (value) setEventType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="audit-event-type" className="w-full">
|
||||
<SelectValue placeholder="Event type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="audit-outcome"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Outcome
|
||||
<Select
|
||||
value={outcome}
|
||||
@@ -99,7 +105,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
if (value) setOutcome(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="audit-outcome" className="w-full">
|
||||
<SelectValue placeholder="Outcome" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -112,9 +118,13 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="audit-search"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Search
|
||||
<Input
|
||||
id="audit-search"
|
||||
value={needle}
|
||||
onChange={(e) => setNeedle(e.target.value)}
|
||||
placeholder="message, agent, actor"
|
||||
|
||||
@@ -66,12 +66,15 @@ export function CommandsPage({
|
||||
Command Runner
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Debug tool — send raw commands to agents and inspect JSON responses
|
||||
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">
|
||||
<label
|
||||
htmlFor="cmd-agent"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Agent</span>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
@@ -80,13 +83,16 @@ export function CommandsPage({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="cmd-kind"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Command</span>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => setKind(value as CommandKind)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="cmd-kind" className="w-full">
|
||||
<SelectValue placeholder="Pick command" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -98,9 +104,13 @@ export function CommandsPage({
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="cmd-query"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Query</span>
|
||||
<Input
|
||||
id="cmd-query"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="optional"
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
transitions: AlertTransition[];
|
||||
audit: AuditEvent[];
|
||||
loading: boolean;
|
||||
alertsHealth: { available: boolean; reason?: string } | null;
|
||||
alertsStreamConnected: boolean;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
@@ -66,6 +68,8 @@ export function DashboardPage({
|
||||
transitions,
|
||||
audit,
|
||||
loading,
|
||||
alertsHealth,
|
||||
alertsStreamConnected,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const connected = agents.filter((a) => a.connected).length;
|
||||
@@ -115,7 +119,7 @@ export function DashboardPage({
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Fleet Status */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Bot className="size-3.5" />
|
||||
Fleet Status
|
||||
@@ -152,7 +156,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Active Alerts */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Bell className="size-3.5" />
|
||||
Active Alerts
|
||||
@@ -165,6 +169,20 @@ export function DashboardPage({
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!alertsStreamConnected || alertsHealth?.available === false) && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="mb-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||
{alertsHealth?.available === false && (
|
||||
<div>
|
||||
Alert results may be stale or unavailable
|
||||
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
@@ -188,7 +206,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Transitions */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
Transitions
|
||||
@@ -206,7 +224,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Token Health */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Key className="size-3.5" />
|
||||
Enroll Tokens
|
||||
@@ -228,7 +246,7 @@ export function DashboardPage({
|
||||
|
||||
{/* 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">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||
<div>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
|
||||
+115
-19
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
fetchFleetDevices,
|
||||
fetchFleetDevice,
|
||||
fetchKnownDevices,
|
||||
fetchKnownDevice,
|
||||
refreshFleetDevices,
|
||||
wakeFleetDevice,
|
||||
type Agent,
|
||||
@@ -51,9 +53,10 @@ type Props = {
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onAfterWake?: () => Promise<void>;
|
||||
onRefresh?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -68,8 +71,21 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||
const loadSeqRef = useRef(0);
|
||||
const detailsKeyRef = useRef<string | null>(null);
|
||||
const detailsSeqRef = useRef(0);
|
||||
|
||||
function selectDetails(device: FleetDevice | null) {
|
||||
// Closing or selecting another row invalidates in-flight detail refreshes.
|
||||
detailsSeqRef.current += 1;
|
||||
detailsKeyRef.current = device?.device_key ?? null;
|
||||
setDetails(device);
|
||||
}
|
||||
|
||||
async function loadFleet() {
|
||||
// Latest request wins: filter changes and debounced searches overlap, and
|
||||
// an older response must never overwrite fresher state.
|
||||
const seq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||
@@ -78,16 +94,64 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
presence,
|
||||
known,
|
||||
agentId: agentId === "all" ? "" : agentId,
|
||||
visibility: "all",
|
||||
limit: 500,
|
||||
}),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
if (seq !== loadSeqRef.current) return;
|
||||
setDevices(nextDevices);
|
||||
setKnownDevices(nextKnownDevices);
|
||||
} catch (err) {
|
||||
if (seq !== loadSeqRef.current) return;
|
||||
toast.error("Failed to load fleet", { description: String(err) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshView(
|
||||
originKey: string | null = detailsKeyRef.current,
|
||||
knownDeviceId?: string,
|
||||
) {
|
||||
if (!originKey || originKey !== detailsKeyRef.current) {
|
||||
await loadFleet();
|
||||
return;
|
||||
}
|
||||
// Mutations supply an explicit identity; never infer it from a shared IP.
|
||||
// Details use their own read, so list filters and limits cannot close them.
|
||||
const key = knownDeviceId ? `known:${knownDeviceId}` : originKey;
|
||||
detailsKeyRef.current = key;
|
||||
const seq = ++detailsSeqRef.current;
|
||||
try {
|
||||
const [device] = await Promise.all([fetchFleetDevice(key), loadFleet()]);
|
||||
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
|
||||
return;
|
||||
const saved = device.known_device
|
||||
? await fetchKnownDevice(device.known_device.device_id)
|
||||
: null;
|
||||
if (seq === detailsSeqRef.current && detailsKeyRef.current === key) {
|
||||
// The dialog's full identity must stay current even if the list refresh
|
||||
// failed. Dropdown options may come from the list, not mutation controls.
|
||||
if (saved) {
|
||||
setKnownDevices((current) =>
|
||||
current.some((item) => item.device_id === saved.device_id)
|
||||
? current.map((item) =>
|
||||
item.device_id === saved.device_id ? saved : item,
|
||||
)
|
||||
: [...current, saved],
|
||||
);
|
||||
}
|
||||
setDetails(device);
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
|
||||
return;
|
||||
// A missing/failed identity read must not leave stale mutation controls.
|
||||
selectDetails(null);
|
||||
toast.error("Failed to refresh device details", {
|
||||
description: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,10 +161,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
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`;
|
||||
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
|
||||
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
|
||||
toast.success(msg);
|
||||
await loadFleet();
|
||||
await refreshView();
|
||||
} catch (err) {
|
||||
toast.error("Fleet refresh failed", { description: String(err) });
|
||||
} finally {
|
||||
@@ -119,7 +183,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
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();
|
||||
await refreshView();
|
||||
} catch (err) {
|
||||
toast.error("Wake failed", { description: String(err) });
|
||||
} finally {
|
||||
@@ -136,8 +200,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
} catch {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.style.position = "fixed";
|
||||
area.style.opacity = "0";
|
||||
area.style.cssText = "position:fixed;opacity:0";
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand("copy");
|
||||
@@ -151,7 +214,13 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presence, known, agentId]);
|
||||
|
||||
// The filter effect above already loads on mount; only react to query edits.
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void loadFleet(), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -160,6 +229,8 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const sortedDevices = useMemo(() => {
|
||||
const rows = [...devices];
|
||||
rows.sort((a, b) => {
|
||||
// Pinned always float to top
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const dir = sort.dir === "asc" ? 1 : -1;
|
||||
if (sort.key === "presence") {
|
||||
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
|
||||
@@ -190,7 +261,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
<div>
|
||||
<CardTitle>Devices</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{devices.length} fleet rows from stored observations and known
|
||||
{devices.length} fleet rows from agent snapshots and known
|
||||
devices
|
||||
</p>
|
||||
</div>
|
||||
@@ -198,7 +269,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadFleet()}
|
||||
onClick={() => {
|
||||
void loadFleet();
|
||||
if (onRefresh) void onRefresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
@@ -219,11 +293,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</CardHeader>
|
||||
<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">
|
||||
<label
|
||||
htmlFor="fleet-search"
|
||||
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
|
||||
id="fleet-search"
|
||||
className="pl-8"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -241,9 +319,13 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
label="Identity"
|
||||
value={known}
|
||||
values={knownFilters}
|
||||
labels={{ known: "saved", unknown: "unsaved" }}
|
||||
onChange={(value) => setKnown(value as KnownFilter)}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="fleet-agent"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
value={agentId}
|
||||
@@ -313,7 +395,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
device={device}
|
||||
busy={wakeBusyKey === device.device_key}
|
||||
onWake={() => void wakeDevice(device)}
|
||||
onDetails={() => setDetails(device)}
|
||||
onDetails={() => selectDetails(device)}
|
||||
onCopy={(label, value) => void copyValue(label, value)}
|
||||
/>
|
||||
))}
|
||||
@@ -331,18 +413,21 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</Card>
|
||||
|
||||
<FleetDeviceDetailsDialog
|
||||
key={details?.device_key ?? "__none"}
|
||||
device={details}
|
||||
knownDevices={knownDevices}
|
||||
open={Boolean(details)}
|
||||
busy={wakeBusyKey === details?.device_key}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetails(null);
|
||||
if (!open) selectDetails(null);
|
||||
}}
|
||||
onWake={(routeId) => {
|
||||
if (details) void wakeDevice(details, routeId);
|
||||
}}
|
||||
onCopy={(label, value) => void copyValue(label, value)}
|
||||
onChanged={() => void loadFleet()}
|
||||
onChanged={(knownDeviceId) =>
|
||||
refreshView(details?.device_key ?? null, knownDeviceId)
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -361,15 +446,26 @@ function FleetDeviceRow({
|
||||
onDetails: () => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
const presenceBorder =
|
||||
device.presence === "online"
|
||||
? "border-l-emerald-500/70"
|
||||
: device.presence === "likely_online"
|
||||
? "border-l-sky-500/60"
|
||||
: device.presence === "offline"
|
||||
? "border-l-muted-foreground/20"
|
||||
: "";
|
||||
|
||||
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">
|
||||
<div
|
||||
className={`grid gap-3 rounded-md border border-l-[3px] ${presenceBorder} 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="secondary">saved</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">unknown</Badge>
|
||||
<Badge variant="outline">unsaved</Badge>
|
||||
)}
|
||||
{device.pinned && <Badge variant="outline">pinned</Badge>}
|
||||
{device.sources.map((source) => (
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Agent } from "@/api";
|
||||
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TerminalAccessoryBar } from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||
import { useTerminalSession } from "@/pages/terminal/useTerminalSession";
|
||||
import { useTerminalSessions } from "@/pages/terminal/useTerminalSessions";
|
||||
import { useXterm } from "@/pages/terminal/useXterm";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
type TerminalTouch = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||
|
||||
export function TerminalPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
}: Props) {
|
||||
const pageRef = useRef<HTMLElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||
);
|
||||
|
||||
const catalog = useTerminalSessions();
|
||||
const { sessions } = catalog;
|
||||
const terminal = useXterm({
|
||||
pageRef,
|
||||
hostRef,
|
||||
});
|
||||
const terminalSession = useTerminalSession({
|
||||
terminal: terminal.terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const clock = window.setInterval(
|
||||
() => setNowUnix(Math.floor(Date.now() / 1000)),
|
||||
30_000,
|
||||
);
|
||||
return () => window.clearInterval(clock);
|
||||
}, []);
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agent_id === selectedAgentId,
|
||||
);
|
||||
const selectedAgentSessionCount = sessions.filter(
|
||||
(item) => item.agent_id === selectedAgentId,
|
||||
).length;
|
||||
const selectedAgentSessionLimit = Math.max(
|
||||
1,
|
||||
selectedAgent?.capability_options?.terminal?.max_sessions ?? 2,
|
||||
);
|
||||
const agentAtSessionLimit =
|
||||
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
||||
const canRequestStart = Boolean(
|
||||
selectedAgent?.connected &&
|
||||
selectedAgent.capabilities.includes("terminal") &&
|
||||
terminalSession.connection !== "connecting" &&
|
||||
terminal.terminal.ready,
|
||||
);
|
||||
const terminalInputReady = terminalSession.connection === "ready";
|
||||
|
||||
function start() {
|
||||
if (agentAtSessionLimit) {
|
||||
toast.error("Terminal session limit reached", {
|
||||
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
void terminalSession.start();
|
||||
}
|
||||
|
||||
function toggleTerminalKeyboard() {
|
||||
const terminalInstance = terminal.terminal;
|
||||
const blurTerminalInput = () => {
|
||||
terminalInstance.blurInput();
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
hostRef.current?.contains(activeElement)
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
};
|
||||
const shouldHide =
|
||||
terminalInputFocusedBeforeAccessoryRef.current ||
|
||||
Boolean(hostRef.current?.contains(document.activeElement));
|
||||
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||
if (shouldHide) {
|
||||
blurTerminalInput();
|
||||
// Base UI may restore the previously focused element after pointer handling.
|
||||
window.requestAnimationFrame(blurTerminalInput);
|
||||
} else {
|
||||
terminalInstance.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="terminal-page"
|
||||
aria-label="Remote terminal"
|
||||
ref={pageRef}
|
||||
>
|
||||
<header className="terminal-toolbar">
|
||||
<div className="terminal-heading">
|
||||
<Terminal className="size-5 text-primary" aria-hidden />
|
||||
<h1>Remote Terminal</h1>
|
||||
</div>
|
||||
|
||||
<div className="terminal-controls">
|
||||
<AgentSelector
|
||||
agents={agents.filter(
|
||||
(agent) =>
|
||||
agent.connected && agent.capabilities.includes("terminal"),
|
||||
)}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
disabled={terminalSession.connection === "connecting"}
|
||||
className="w-full min-w-0 sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!selectedAgent && terminalSession.connection === "idle" ? (
|
||||
<p className="terminal-notice">
|
||||
No connected terminal-capable agent is selected.
|
||||
</p>
|
||||
) : selectedAgent && !selectedAgent.capabilities.includes("terminal") ? (
|
||||
<p className="terminal-notice">
|
||||
This agent has not enabled remote terminal capability.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="terminal-frame">
|
||||
<div className="terminal-framebar">
|
||||
<TerminalSessionTabs
|
||||
agents={agents}
|
||||
sessions={sessions}
|
||||
activeTerminalId={terminalSession.session?.terminal_id}
|
||||
connection={terminalSession.connection}
|
||||
sessionTitles={terminal.sessionTitles}
|
||||
nowUnix={nowUnix}
|
||||
canRequestStart={canRequestStart}
|
||||
agentAtSessionLimit={agentAtSessionLimit}
|
||||
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||
onActivate={(item) => void terminalSession.activate(item)}
|
||||
onClose={(item) => void terminalSession.close(item)}
|
||||
onStart={start}
|
||||
/>
|
||||
|
||||
<div className="terminal-frame-actions">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="terminal-status"
|
||||
data-state={terminalSession.connection}
|
||||
>
|
||||
<span aria-hidden />
|
||||
{terminalSession.connection}
|
||||
</Badge>
|
||||
{terminalSession.connection === "disconnected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void terminalSession.reconnect()}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-accessories-toggle"
|
||||
data-active={accessoriesOpen || undefined}
|
||||
aria-label={
|
||||
accessoriesOpen
|
||||
? "Hide terminal controls"
|
||||
: "Show terminal controls"
|
||||
}
|
||||
aria-expanded={accessoriesOpen}
|
||||
onClick={() => setAccessoriesOpen((current) => !current)}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Terminal controls</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={terminalSession.connection === "idle"}
|
||||
aria-label="Clear terminal"
|
||||
onClick={terminal.terminal.clear}
|
||||
>
|
||||
<Eraser className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear terminal</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{accessoriesOpen ? (
|
||||
<TerminalAccessoryBar
|
||||
inputReady={terminalInputReady}
|
||||
modifiers={terminalSession.modifiers}
|
||||
fontSize={terminal.fontSize}
|
||||
onRememberInputFocus={() => {
|
||||
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||
hostRef.current?.contains(document.activeElement),
|
||||
);
|
||||
}}
|
||||
onToggleModifier={terminalSession.toggleModifier}
|
||||
onWriteKey={terminalSession.writeAccessoryKey}
|
||||
onScrollPages={terminal.terminal.scrollPages}
|
||||
onCopy={() => void terminal.terminal.copy()}
|
||||
onPaste={() =>
|
||||
void terminal.terminal.paste(terminalSession.writeTerminalData)
|
||||
}
|
||||
onToggleKeyboard={toggleTerminalKeyboard}
|
||||
onChangeFontSize={terminal.terminal.changeFontSize}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="terminal-surface"
|
||||
ref={hostRef}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") {
|
||||
terminal.terminal.focus();
|
||||
return;
|
||||
}
|
||||
terminalTouchRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
if (!touch || touch.pointerId !== event.pointerId || touch.moved) {
|
||||
return;
|
||||
}
|
||||
touch.moved =
|
||||
Math.hypot(
|
||||
event.clientX - touch.startX,
|
||||
event.clientY - touch.startY,
|
||||
) > TERMINAL_TOUCH_SLOP_PX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
terminalTouchRef.current = null;
|
||||
if (
|
||||
touch?.pointerId === event.pointerId &&
|
||||
!touch.moved &&
|
||||
event.pointerType === "touch"
|
||||
) {
|
||||
terminal.terminal.focus();
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
terminalTouchRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function TokensPage() {
|
||||
return (
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="size-5 text-primary" />
|
||||
@@ -219,9 +219,13 @@ export function TokensPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{useCustom && (
|
||||
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="token-custom-ttl"
|
||||
className="mt-2 grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>TTL (seconds)</span>
|
||||
<Input
|
||||
id="token-custom-ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
value={customTtl}
|
||||
|
||||
@@ -101,7 +101,10 @@ export function WakeToolsPage({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="wake-agent"
|
||||
className="grid gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Agent</span>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
@@ -111,14 +114,17 @@ export function WakeToolsPage({
|
||||
</label>
|
||||
|
||||
<form onSubmit={handleWake} className="grid gap-3">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="wake-target"
|
||||
className="grid gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Target</span>
|
||||
<Input
|
||||
id="wake-target"
|
||||
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
|
||||
@@ -134,7 +140,7 @@ export function WakeToolsPage({
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 gap-y-0">
|
||||
<CardTitle>Recent Wakes</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -147,10 +153,10 @@ export function WakeToolsPage({
|
||||
<CardContent>
|
||||
{recentWakes.length > 0 ? (
|
||||
<div className="grid gap-0">
|
||||
{recentWakes.map((event, idx) => (
|
||||
{recentWakes.map((event, i) => (
|
||||
<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}`}
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${event.target}-${event.agentId}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -27,11 +27,13 @@ export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
values,
|
||||
labels,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
values: readonly string[];
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -39,12 +41,12 @@ export function FilterSelect({
|
||||
<span>{label}</span>
|
||||
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||
<SelectTrigger>
|
||||
<span>{value.replace("_", " ")}</span>
|
||||
<span>{labels?.[value] ?? value.replace("_", " ")}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{values.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item.replace("_", " ")}
|
||||
{labels?.[item] ?? item.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Copy, Minus, Plus, Zap, Pencil, Trash2, Check, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
attachDeviceIdentifier,
|
||||
createKnownDevice,
|
||||
detachDeviceIdentifier,
|
||||
mergeKnownDevice,
|
||||
renameKnownDevice,
|
||||
forgetKnownDevice,
|
||||
type FleetDevice,
|
||||
type FleetDeviceEndpoint,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -26,7 +32,13 @@ import {
|
||||
} 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";
|
||||
import {
|
||||
agentLabel,
|
||||
formatSeen,
|
||||
identifiersFor,
|
||||
presenceRank,
|
||||
routeLabel,
|
||||
} from "@/pages/fleet/utils";
|
||||
|
||||
type Props = {
|
||||
device: FleetDevice | null;
|
||||
@@ -36,7 +48,7 @@ type Props = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWake: (routeId?: string | null) => void;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
onChanged: () => void;
|
||||
onChanged: (knownDeviceId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function FleetDeviceDetailsDialog({
|
||||
@@ -49,75 +61,164 @@ export function FleetDeviceDetailsDialog({
|
||||
onCopy,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [routeId, setRouteId] = useState(
|
||||
device?.recommended_route?.route_id ?? "",
|
||||
);
|
||||
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||
const [addValue, setAddValue] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [forgetConfirm, setForgetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(device?.display_name ?? "");
|
||||
setTargetDeviceId("");
|
||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||
setError("");
|
||||
}, [device]);
|
||||
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||
|
||||
// Resolve the full KnownDevice object if this fleet row is known
|
||||
const fullKnown = knownDevices.find(
|
||||
(kd) => kd.device_id === device?.known_device?.device_id,
|
||||
);
|
||||
|
||||
// Identifiers from the row that are NOT already on the known device
|
||||
const unattachedIdentifiers = useMemo(() => {
|
||||
if (!fullKnown) return rowIdentifiers;
|
||||
const existing = new Set(
|
||||
fullKnown.identifiers.map((id) => `${id.kind}:${id.value}`),
|
||||
);
|
||||
return rowIdentifiers.filter(
|
||||
(id) => !existing.has(`${id.kind}:${id.value}`),
|
||||
);
|
||||
}, [fullKnown, rowIdentifiers]);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const currentDevice = device;
|
||||
const identifiers = identifiersFor(currentDevice);
|
||||
|
||||
const otherKnownDevices = knownDevices.filter(
|
||||
(known) => known.device_id !== currentDevice.known_device?.device_id,
|
||||
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
|
||||
);
|
||||
|
||||
async function createRemembered() {
|
||||
async function wrap(
|
||||
fn: () => Promise<void | { changed?: boolean; knownDeviceId?: string }>,
|
||||
) {
|
||||
setError("");
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await createKnownDevice({
|
||||
const res = await fn();
|
||||
if (!res || res.changed !== false) {
|
||||
await onChanged(res?.knownDeviceId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(String(err));
|
||||
if (err && (err.changed || err.partial)) {
|
||||
await onChanged(err.knownDeviceId);
|
||||
}
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRemembered() {
|
||||
await wrap(async () => {
|
||||
const result = await createKnownDevice({
|
||||
display_name: displayName.trim() || currentDevice.display_name,
|
||||
pinned: true,
|
||||
identifiers,
|
||||
identifiers: rowIdentifiers,
|
||||
});
|
||||
toast.success(`Remembered as "${result.display_name}"`);
|
||||
return { knownDeviceId: result.device_id };
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function attachToExisting() {
|
||||
async function handleAttachToExisting() {
|
||||
if (!targetDeviceId) return;
|
||||
setError("");
|
||||
try {
|
||||
for (const identifier of identifiers) {
|
||||
await attachDeviceIdentifier(targetDeviceId, identifier);
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||
await wrap(async () => {
|
||||
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
|
||||
if (ids.length === 0) return { changed: false };
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
|
||||
);
|
||||
|
||||
const changed = results.some((r) => r.status === "fulfilled");
|
||||
const errors = results.filter((r) => r.status === "rejected");
|
||||
|
||||
if (errors.length) {
|
||||
const err = errors[0].reason as Error;
|
||||
if (changed) {
|
||||
(err as any).changed = true;
|
||||
(err as any).knownDeviceId = targetDeviceId;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function mergeIntoExisting() {
|
||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||
setError("");
|
||||
try {
|
||||
await mergeKnownDevice(
|
||||
targetDeviceId,
|
||||
currentDevice.known_device.device_id,
|
||||
toast.success(
|
||||
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
|
||||
);
|
||||
onChanged();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
return { changed, knownDeviceId: targetDeviceId };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||
await wrap(async () => {
|
||||
const merged = await mergeKnownDevice(
|
||||
targetDeviceId,
|
||||
currentDevice.known_device!.device_id,
|
||||
);
|
||||
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
|
||||
return { knownDeviceId: merged.device_id };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddIdentifier() {
|
||||
if (!fullKnown || !addValue.trim()) return;
|
||||
await wrap(async () => {
|
||||
await attachDeviceIdentifier(fullKnown!.device_id, {
|
||||
kind: addKind,
|
||||
value: addValue.trim(),
|
||||
});
|
||||
setAddValue("");
|
||||
toast.success(`Added ${addKind} identifier`);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDetachIdentifier(identifierKey: string) {
|
||||
if (!fullKnown) return;
|
||||
await wrap(async () => {
|
||||
await detachDeviceIdentifier(fullKnown!.device_id, identifierKey);
|
||||
toast.success("Identifier removed");
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!fullKnown || !displayName.trim()) return;
|
||||
await wrap(async () => {
|
||||
await renameKnownDevice(fullKnown.device_id, displayName.trim());
|
||||
setEditingName(false);
|
||||
toast.success("Device renamed");
|
||||
});
|
||||
}
|
||||
|
||||
async function handleForget() {
|
||||
if (!fullKnown) return;
|
||||
await wrap(async () => {
|
||||
await forgetKnownDevice(fullKnown.device_id);
|
||||
toast.success("Remembered device forgotten");
|
||||
onOpenChange(false);
|
||||
});
|
||||
}
|
||||
|
||||
const selectedRoute =
|
||||
device.route_candidates.find((route) => route.route_id === routeId) ??
|
||||
device.route_candidates.find((r) => r.route_id === routeId) ??
|
||||
device.recommended_route;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
@@ -127,7 +228,7 @@ export function FleetDeviceDetailsDialog({
|
||||
<DialogDescription>
|
||||
{device.known_device
|
||||
? "Remembered fleet device"
|
||||
: "Observed fleet device"}
|
||||
: "Unidentified fleet device"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<PresenceBadge presence={device.presence} />
|
||||
@@ -150,8 +251,8 @@ export function FleetDeviceDetailsDialog({
|
||||
/>
|
||||
<DetailBlock
|
||||
label="Agents"
|
||||
values={device.agents.map((agent) =>
|
||||
agentLabel(agent.agent_id, agent.nickname),
|
||||
values={device.agents.map((a) =>
|
||||
agentLabel(a.agent_id, a.nickname),
|
||||
)}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
@@ -159,22 +260,56 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Endpoints</h3>
|
||||
<Badge variant="outline">{device.endpoints.length}</Badge>
|
||||
</div>
|
||||
{device.endpoints.length ? (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="hidden grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground sm:grid">
|
||||
<span>Source</span>
|
||||
<span>Target</span>
|
||||
<span className="text-right">Last seen</span>
|
||||
<span />
|
||||
</div>
|
||||
{[...device.endpoints]
|
||||
.sort((a, b) => {
|
||||
const p = presenceRank(b.presence) - presenceRank(a.presence);
|
||||
if (p !== 0) return p;
|
||||
return (b.last_seen_unix ?? 0) - (a.last_seen_unix ?? 0);
|
||||
})
|
||||
.map((endpoint, index) => (
|
||||
<EndpointRow
|
||||
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
|
||||
endpoint={endpoint}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
No endpoint evidence reported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Wake route ── */}
|
||||
<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}
|
||||
disabled={actionBusy || busy || !selectedRoute?.wakeable}
|
||||
>
|
||||
<Zap className="size-4" aria-hidden />
|
||||
{busy ? "Waking" : "Wake"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={routeId}
|
||||
onValueChange={(value) => setRouteId(value ?? "")}
|
||||
>
|
||||
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? "")}>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{selectedRoute
|
||||
@@ -194,68 +329,347 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Identity section ── */}
|
||||
<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]">
|
||||
<h3 className="text-sm font-medium">Identity</h3>
|
||||
|
||||
{/* ── Known device: show metadata + identifiers ── */}
|
||||
{fullKnown && (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant="secondary">saved</Badge>
|
||||
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
|
||||
<span className="font-medium">{fullKnown.display_name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Rename device"
|
||||
aria-label="Rename device"
|
||||
disabled={actionBusy}
|
||||
onClick={() => {
|
||||
setDisplayName(fullKnown.display_name);
|
||||
setEditingName(true);
|
||||
setForgetConfirm(false);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Forget remembered device"
|
||||
aria-label="Forget remembered device"
|
||||
disabled={actionBusy}
|
||||
onClick={() => {
|
||||
setForgetConfirm(true);
|
||||
setEditingName(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
{fullKnown.notes && (
|
||||
<span className="text-muted-foreground">
|
||||
: {fullKnown.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingName && (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleRename();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="Device name"
|
||||
autoFocus
|
||||
value={displayName}
|
||||
disabled={actionBusy}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void createRemembered()}
|
||||
disabled={!identifiers.length}
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
title="Save name"
|
||||
aria-label="Save name"
|
||||
disabled={actionBusy || !displayName.trim()}
|
||||
>
|
||||
Remember device
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Cancel rename"
|
||||
aria-label="Cancel rename"
|
||||
disabled={actionBusy}
|
||||
onClick={() => setEditingName(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{forgetConfirm && (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
role="group"
|
||||
aria-label="Confirm forget device"
|
||||
>
|
||||
<p className="text-sm">
|
||||
Forget “{fullKnown.display_name}” and its remembered
|
||||
identifiers? Agent observations remain; it may reappear as
|
||||
an unsaved device.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={() => void handleForget()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Forget device
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={() => setForgetConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
|
||||
{/* Current identifiers with remove buttons */}
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
Identifiers
|
||||
</div>
|
||||
{fullKnown.identifiers.length ? (
|
||||
<div className="grid gap-1">
|
||||
{fullKnown.identifiers.map((id) => (
|
||||
<div
|
||||
key={id.identifier_key}
|
||||
className="flex items-center justify-between gap-2 rounded px-1 py-0.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mr-1.5 text-[0.65rem]"
|
||||
>
|
||||
{id.kind}
|
||||
</Badge>
|
||||
{id.value}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
void handleDetachIdentifier(id.identifier_key)
|
||||
}
|
||||
disabled={actionBusy}
|
||||
aria-label={`Remove ${id.kind} ${id.value}`}
|
||||
>
|
||||
<Minus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No identifiers attached
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick-add from row values */}
|
||||
{unattachedIdentifiers.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add from this device row:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{unattachedIdentifiers.map((id) => (
|
||||
<Button
|
||||
key={`${id.kind}:${id.value}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={() =>
|
||||
void wrap(async () => {
|
||||
await attachDeviceIdentifier(
|
||||
fullKnown!.device_id,
|
||||
id,
|
||||
);
|
||||
toast.success(`Added ${id.kind}: ${id.value}`);
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{id.kind}: {id.value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual add identifier */}
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add identifier manually:
|
||||
</p>
|
||||
<div className="grid grid-cols-[6rem_minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={addKind}
|
||||
onValueChange={(v) =>
|
||||
setAddKind((v as "mac" | "ip") ?? "mac")
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{addKind}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectItem value="mac">mac</SelectItem>
|
||||
<SelectItem value="ip">ip</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={addValue}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
placeholder={
|
||||
addKind === "mac" ? "aa:bb:cc:dd:ee:ff" : "192.168.1.100"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleAddIdentifier()}
|
||||
disabled={actionBusy || !addValue.trim()}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merge into another known device */}
|
||||
<Separator />
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Merge this saved device into another:
|
||||
</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(value) => setTargetDeviceId(value ?? "")}
|
||||
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(known) => known.device_id === targetDeviceId,
|
||||
(kd) => kd.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select known device"}
|
||||
: "Select target 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}
|
||||
{otherKnownDevices.map((kd) => (
|
||||
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||
{kd.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}
|
||||
onClick={() => void handleMerge()}
|
||||
disabled={actionBusy || !targetDeviceId}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Unknown device: remember or attach ── */}
|
||||
{!device.known_device && (
|
||||
<div className="grid gap-3">
|
||||
{/* Remember as new known device */}
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Proposed identifiers:{" "}
|
||||
{identifiers.map((id) => `${id.kind}:${id.value}`).join(", ") ||
|
||||
"-"}
|
||||
Remember as a new saved device:
|
||||
</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleCreateRemembered()}
|
||||
disabled={actionBusy || !rowIdentifiers.length}
|
||||
>
|
||||
Remember device
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Identifiers:{" "}
|
||||
{rowIdentifiers
|
||||
.map((id) => `${id.kind}:${id.value}`)
|
||||
.join(", ") || "none"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attach to existing known device */}
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or attach to an existing saved device:
|
||||
</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={targetDeviceId}
|
||||
onValueChange={(v) => setTargetDeviceId(v ?? "")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{targetDeviceId
|
||||
? knownDevices.find(
|
||||
(kd) => kd.device_id === targetDeviceId,
|
||||
)?.display_name
|
||||
: "Select saved device"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,30rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{knownDevices.map((kd) => (
|
||||
<SelectItem key={kd.device_id} value={kd.device_id}>
|
||||
{kd.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleAttachToExisting()}
|
||||
disabled={
|
||||
actionBusy || !targetDeviceId || !rowIdentifiers.length
|
||||
}
|
||||
>
|
||||
Attach IDs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -267,3 +681,52 @@ export function FleetDeviceDetailsDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointRow({
|
||||
endpoint,
|
||||
onCopy,
|
||||
}: {
|
||||
endpoint: FleetDeviceEndpoint;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
const target = [endpoint.mac, endpoint.ip].filter(Boolean).join(" / ");
|
||||
const location = [endpoint.interface, endpoint.hostname]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-card px-3 py-2.5 text-sm sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] sm:items-center sm:gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<PresenceBadge presence={endpoint.presence} />
|
||||
<Badge variant="secondary">{endpoint.source.replace("_", " ")}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{agentLabel(endpoint.agent_id, endpoint.nickname)}
|
||||
{endpoint.connected ? " (connected)" : " (offline)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-xs">{target || "-"}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{location || "No interface or hostname"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-right text-xs text-muted-foreground">
|
||||
{formatSeen(endpoint.last_seen_unix)}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
{target && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onCopy("Endpoint", target)}
|
||||
aria-label={`Copy endpoint ${target}`}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ export const presenceFilters = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
"offline",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
|
||||
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;
|
||||
if (presence === "offline") return 2;
|
||||
if (presence === "unknown") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
|
||||
}
|
||||
|
||||
export function routeLabel(route: FleetWakeRoute): string {
|
||||
const status = route.connected ? "connected" : "offline";
|
||||
const status = route.connected ? "connected" : "agent offline";
|
||||
const target = route.mac
|
||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||
: (route.ip ?? "-");
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationHistory,
|
||||
type AgentDeviceObservation,
|
||||
type AgentDeviceObservationEvent,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
formatSeen,
|
||||
observationLabel,
|
||||
observationStateLabel,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
type Props = {
|
||||
observation: AgentDeviceObservation | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function ObservationHistoryDialog({
|
||||
observation,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const [events, setEvents] = useState<AgentDeviceObservationEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !observation) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetchObservationHistory({
|
||||
observationKey: observation.observation_key,
|
||||
limit: 100,
|
||||
})
|
||||
.then(setEvents)
|
||||
.catch((err) => {
|
||||
setEvents([]);
|
||||
setError(String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, observation]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Observation History</DialogTitle>
|
||||
<DialogDescription>
|
||||
{observation
|
||||
? observationLabel(observation)
|
||||
: "No observation selected"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||
<div className="grid min-w-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>
|
||||
<span>Network</span>
|
||||
<span>Known Device</span>
|
||||
</div>
|
||||
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
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)}
|
||||
</span>
|
||||
<span>
|
||||
<Badge
|
||||
variant={event.action === "remove" ? "outline" : "secondary"}
|
||||
>
|
||||
{observationStateLabel(event.action)}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="flex min-w-0 gap-1">
|
||||
<Badge variant="outline">{event.kind}</Badge>
|
||||
<Badge variant="outline">{event.action}</Badge>
|
||||
</span>
|
||||
<span className="min-w-0 text-muted-foreground">
|
||||
<span className="block truncate">{event.ip ?? "-"}</span>
|
||||
<span className="block truncate">{event.mac ?? "-"}</span>
|
||||
{event.hostname && (
|
||||
<span className="block truncate">{event.hostname}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{event.known_device?.display_name ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!events.length && (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{loading ? "Loading history..." : "No history found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
identifierSummary,
|
||||
observationLabel,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
type Props = {
|
||||
observation: AgentDeviceObservation;
|
||||
devices: KnownDevice[];
|
||||
busy: boolean;
|
||||
selectedDeviceId: string;
|
||||
newName: string;
|
||||
onSelectDevice: (deviceId: string) => void;
|
||||
onNewNameChange: (name: string) => void;
|
||||
onAttachExisting: () => void;
|
||||
onCreateAndAttach: () => void;
|
||||
};
|
||||
|
||||
export function ObservationIdentityActions({
|
||||
observation,
|
||||
devices,
|
||||
busy,
|
||||
selectedDeviceId,
|
||||
newName,
|
||||
onSelectDevice,
|
||||
onNewNameChange,
|
||||
onAttachExisting,
|
||||
onCreateAndAttach,
|
||||
}: Props) {
|
||||
const attachable = Boolean(observation.mac || observation.ip);
|
||||
const selectedDevice = devices.find(
|
||||
(device) => device.device_id === selectedDeviceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={selectedDeviceId}
|
||||
onValueChange={(value: string | null) => onSelectDevice(value ?? "")}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 flex-1">
|
||||
<span className="truncate">
|
||||
{selectedDevice?.display_name ?? "Attach to existing"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,28rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.device_id} value={device.device_id}>
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate">{device.display_name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{identifierSummary(device)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !attachable || !selectedDeviceId}
|
||||
onClick={onAttachExisting}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(event) => onNewNameChange(event.target.value)}
|
||||
placeholder={`Create as ${observationLabel(observation)}`}
|
||||
disabled={busy || !attachable}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || !attachable}
|
||||
onClick={onCreateAndAttach}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ObservationIdentityActions } from "@/pages/observations/ObservationIdentityActions";
|
||||
import {
|
||||
formatSeen,
|
||||
observationLabel,
|
||||
observationStateLabel,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
type Props = {
|
||||
observations: AgentDeviceObservation[];
|
||||
devices: KnownDevice[];
|
||||
selectedDevices: Record<string, string>;
|
||||
newNames: Record<string, string>;
|
||||
busyKey: string;
|
||||
onSelectDevice: (observationKey: string, deviceId: string) => void;
|
||||
onNewNameChange: (observationKey: string, name: string) => void;
|
||||
onAttachExisting: (observation: AgentDeviceObservation) => void;
|
||||
onCreateAndAttach: (observation: AgentDeviceObservation) => void;
|
||||
onOpenHistory: (observation: AgentDeviceObservation) => void;
|
||||
};
|
||||
|
||||
export function ObservationTable({
|
||||
observations,
|
||||
devices,
|
||||
selectedDevices,
|
||||
newNames,
|
||||
busyKey,
|
||||
onSelectDevice,
|
||||
onNewNameChange,
|
||||
onAttachExisting,
|
||||
onCreateAndAttach,
|
||||
onOpenHistory,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="grid gap-2 overflow-x-auto">
|
||||
<div className="grid min-w-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>
|
||||
<span>Seen</span>
|
||||
<span>Known Device</span>
|
||||
<span>History</span>
|
||||
</div>
|
||||
|
||||
{observations.map((observation) => {
|
||||
const busy = busyKey === observation.observation_key;
|
||||
return (
|
||||
<div
|
||||
key={observation.observation_key}
|
||||
className="grid min-w-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">
|
||||
{observationLabel(observation)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{observation.kind}</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
observation.last_action === "remove"
|
||||
? "outline"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{observationStateLabel(observation.last_action)}
|
||||
</Badge>
|
||||
<Badge variant="outline">{observation.last_action}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 text-muted-foreground">
|
||||
<div className="truncate" title={observation.ip ?? "-"}>
|
||||
{observation.ip ?? "-"}
|
||||
</div>
|
||||
<div className="truncate" title={observation.mac ?? "-"}>
|
||||
{observation.mac ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-muted-foreground">
|
||||
{observation.agent_id}
|
||||
</div>
|
||||
<div className="min-w-0 text-muted-foreground">
|
||||
<div className="truncate">
|
||||
{formatSeen(observation.last_seen_unix)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{observation.known_device ? (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary">known</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{observation.known_device.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{observation.known_device.pinned ? "pinned" : "not pinned"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ObservationIdentityActions
|
||||
observation={observation}
|
||||
devices={devices}
|
||||
busy={busy}
|
||||
selectedDeviceId={
|
||||
selectedDevices[observation.observation_key] ?? ""
|
||||
}
|
||||
newName={newNames[observation.observation_key] ?? ""}
|
||||
onSelectDevice={(deviceId) =>
|
||||
onSelectDevice(observation.observation_key, deviceId)
|
||||
}
|
||||
onNewNameChange={(name) =>
|
||||
onNewNameChange(observation.observation_key, name)
|
||||
}
|
||||
onAttachExisting={() => onAttachExisting(observation)}
|
||||
onCreateAndAttach={() => onCreateAndAttach(observation)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenHistory(observation)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!observations.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No observations found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
attachObservationIdentifier,
|
||||
createKnownDevice,
|
||||
fetchKnownDevices,
|
||||
fetchObservations,
|
||||
type AgentDeviceObservation,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { ObservationHistoryDialog } from "@/pages/observations/ObservationHistoryDialog";
|
||||
import { ObservationTable } from "@/pages/observations/ObservationTable";
|
||||
import {
|
||||
observationLabel,
|
||||
type ObservationFilter,
|
||||
} from "@/pages/observations/utils";
|
||||
|
||||
export function ObservationsPage() {
|
||||
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
||||
[],
|
||||
);
|
||||
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
||||
const [filter, setFilter] = useState<ObservationFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedDevices, setSelectedDevices] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
||||
const [busyKey, setBusyKey] = useState("");
|
||||
const [historyObservation, setHistoryObservation] =
|
||||
useState<AgentDeviceObservation | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextObservations, nextDevices] = await Promise.all([
|
||||
fetchObservations({ limit: 500 }),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
setObservations(nextObservations);
|
||||
setDevices(nextDevices);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return observations.filter((observation) => {
|
||||
if (filter === "known" && !observation.known_device) return false;
|
||||
if (filter === "unknown" && observation.known_device) return false;
|
||||
if (!q) return true;
|
||||
|
||||
const haystack = [
|
||||
observation.hostname,
|
||||
observation.mac,
|
||||
observation.ip,
|
||||
observation.agent_id,
|
||||
observation.kind,
|
||||
observation.last_action,
|
||||
observation.known_device?.display_name,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [filter, observations, query]);
|
||||
|
||||
async function attachExisting(observation: AgentDeviceObservation) {
|
||||
const deviceId = selectedDevices[observation.observation_key];
|
||||
if (!deviceId) return;
|
||||
setBusyKey(observation.observation_key);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const device = await attachObservationIdentifier(
|
||||
deviceId,
|
||||
observation.observation_key,
|
||||
);
|
||||
setStatus(
|
||||
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAttach(observation: AgentDeviceObservation) {
|
||||
const displayName =
|
||||
newNames[observation.observation_key]?.trim() ||
|
||||
observationLabel(observation);
|
||||
if (!displayName.trim()) return;
|
||||
setBusyKey(observation.observation_key);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const device = await createKnownDevice({
|
||||
display_name: displayName,
|
||||
pinned: true,
|
||||
identifiers: [],
|
||||
});
|
||||
const updated = await attachObservationIdentifier(
|
||||
device.device_id,
|
||||
observation.observation_key,
|
||||
);
|
||||
setStatus(
|
||||
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Observed Devices</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(value: ObservationFilter | null) => {
|
||||
if (value) setFilter(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<span>{filter}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectItem value="unknown">unknown</SelectItem>
|
||||
<SelectItem value="known">known</SelectItem>
|
||||
<SelectItem value="all">all</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Search</span>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="hostname, mac, ip, agent, known device"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filtered.length} of {observations.length}
|
||||
</p>
|
||||
{status && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{status}
|
||||
</pre>
|
||||
)}
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<ObservationTable
|
||||
observations={filtered}
|
||||
devices={devices}
|
||||
selectedDevices={selectedDevices}
|
||||
newNames={newNames}
|
||||
busyKey={busyKey}
|
||||
onSelectDevice={(observationKey, deviceId) =>
|
||||
setSelectedDevices((prev) => ({
|
||||
...prev,
|
||||
[observationKey]: deviceId,
|
||||
}))
|
||||
}
|
||||
onNewNameChange={(observationKey, name) =>
|
||||
setNewNames((prev) => ({ ...prev, [observationKey]: name }))
|
||||
}
|
||||
onAttachExisting={(observation) => void attachExisting(observation)}
|
||||
onCreateAndAttach={(observation) => void createAndAttach(observation)}
|
||||
onOpenHistory={setHistoryObservation}
|
||||
/>
|
||||
|
||||
<ObservationHistoryDialog
|
||||
observation={historyObservation}
|
||||
open={Boolean(historyObservation)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setHistoryObservation(null);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||
|
||||
export type ObservationFilter = "all" | "unknown" | "known";
|
||||
|
||||
export function observationLabel(observation: AgentDeviceObservation): string {
|
||||
return (
|
||||
observation.hostname?.trim() ||
|
||||
observation.mac?.trim() ||
|
||||
observation.ip?.trim() ||
|
||||
observation.observation_key
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSeen(tsUnix: number): string {
|
||||
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
||||
return new Date(tsUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function identifierSummary(device: KnownDevice): string {
|
||||
if (!device.identifiers.length) return "no identifiers";
|
||||
return device.identifiers
|
||||
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function observationStateLabel(action: string): string {
|
||||
if (action === "remove") return "stale";
|
||||
if (action === "add" || action === "update" || action === "old") {
|
||||
return "present";
|
||||
}
|
||||
return action;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Keyboard,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { MouseEvent, PointerEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TerminalModifiers } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
export const MIN_TERMINAL_FONT_SIZE = 10;
|
||||
export const MAX_TERMINAL_FONT_SIZE = 22;
|
||||
|
||||
type Props = {
|
||||
inputReady: boolean;
|
||||
modifiers: TerminalModifiers;
|
||||
fontSize: number;
|
||||
onRememberInputFocus: () => void;
|
||||
onToggleModifier: (modifier: keyof TerminalModifiers) => void;
|
||||
onWriteKey: (data: string) => void;
|
||||
onScrollPages: (pages: number) => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onToggleKeyboard: () => void;
|
||||
onChangeFontSize: (delta: number) => void;
|
||||
};
|
||||
|
||||
const CONTROL_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, title: string]
|
||||
> = [
|
||||
["^C", "\x03", "Interrupt"],
|
||||
["^U", "\x15", "Clear input line"],
|
||||
["^A", "\x01", "Start of line"],
|
||||
["^E", "\x05", "End of line"],
|
||||
];
|
||||
|
||||
const ARROW_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, icon: LucideIcon]
|
||||
> = [
|
||||
["Left arrow", "\x1b[D", ArrowLeft],
|
||||
["Down arrow", "\x1b[B", ArrowDown],
|
||||
["Up arrow", "\x1b[A", ArrowUp],
|
||||
["Right arrow", "\x1b[C", ArrowRight],
|
||||
];
|
||||
|
||||
function preserveInputFocus(
|
||||
event: PointerEvent<HTMLDivElement> | MouseEvent<HTMLDivElement>,
|
||||
onRememberInputFocus: () => void,
|
||||
) {
|
||||
if (event.target instanceof Element && event.target.closest("button")) {
|
||||
onRememberInputFocus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalAccessoryBar({
|
||||
inputReady,
|
||||
modifiers,
|
||||
fontSize,
|
||||
onRememberInputFocus,
|
||||
onToggleModifier,
|
||||
onWriteKey,
|
||||
onScrollPages,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onToggleKeyboard,
|
||||
onChangeFontSize,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className="terminal-accessories"
|
||||
role="toolbar"
|
||||
aria-label="Terminal controls"
|
||||
onPointerDownCapture={(event) => {
|
||||
// Preserve whether xterm was focused before the button library moves
|
||||
// focus. The keyboard button consumes this to show or hide mobile IME.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
onMouseDownCapture={(event) => {
|
||||
// Firefox can take the mouse-event path without emitting pointerdown.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
>
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.ctrl || undefined}
|
||||
aria-pressed={modifiers.ctrl}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("ctrl")}
|
||||
>
|
||||
Ctrl
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.meta || undefined}
|
||||
aria-pressed={modifiers.meta}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("meta")}
|
||||
>
|
||||
Meta
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\x1b")}
|
||||
>
|
||||
Esc
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\t")}
|
||||
>
|
||||
Tab
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{CONTROL_KEYS.map(([label, sequence, title]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--control"
|
||||
aria-label={title}
|
||||
title={title}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{ARROW_KEYS.map(([label, sequence, Icon]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label={label}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(-1)}
|
||||
>
|
||||
PgUp
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(1)}
|
||||
>
|
||||
PgDn
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Copy selection or visible screen"
|
||||
title="Copy selection or visible screen"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<ClipboardCopy className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Paste"
|
||||
title="Paste"
|
||||
disabled={!inputReady}
|
||||
onClick={onPaste}
|
||||
>
|
||||
<ClipboardPaste className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Toggle terminal keyboard"
|
||||
title="Toggle terminal keyboard"
|
||||
disabled={!inputReady}
|
||||
onClick={onToggleKeyboard}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group terminal-font-controls">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
|
||||
aria-label="Decrease terminal text size"
|
||||
onClick={() => onChangeFontSize(-1)}
|
||||
>
|
||||
<ZoomOut className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<output aria-label="Terminal text size">{fontSize}</output>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
|
||||
aria-label="Increase terminal text size"
|
||||
onClick={() => onChangeFontSize(1)}
|
||||
>
|
||||
<ZoomIn className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Plus, Terminal, X } from "lucide-react";
|
||||
|
||||
import type { Agent, TerminalSession } from "@/api";
|
||||
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TerminalConnectionState } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
sessions: TerminalSession[];
|
||||
activeTerminalId?: string;
|
||||
connection: TerminalConnectionState;
|
||||
sessionTitles: Record<string, string>;
|
||||
nowUnix: number;
|
||||
canRequestStart: boolean;
|
||||
agentAtSessionLimit: boolean;
|
||||
selectedAgentSessionLimit: number;
|
||||
onActivate: (session: TerminalSession) => void;
|
||||
onClose: (session: TerminalSession) => void;
|
||||
onStart: () => void;
|
||||
};
|
||||
|
||||
function terminalCreatedTime(createdAtUnix: number): string {
|
||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function terminalTimestamp(timestampUnix: number): string {
|
||||
return new Date(timestampUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function terminalExpiryDisplay(
|
||||
expiresAtUnix: number | null | undefined,
|
||||
nowUnix: number,
|
||||
): { label: string; warning: boolean } | null {
|
||||
if (expiresAtUnix == null) return null;
|
||||
const remainingSeconds = expiresAtUnix - nowUnix;
|
||||
if (remainingSeconds <= 0) return { label: "expired", warning: true };
|
||||
if (remainingSeconds < 60) return { label: "<1m left", warning: true };
|
||||
if (remainingSeconds < 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 60)}m left`,
|
||||
warning: remainingSeconds < 10 * 60,
|
||||
};
|
||||
}
|
||||
if (remainingSeconds < 24 * 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 3600)}h left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / (24 * 3600))}d left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function TerminalSessionTabs({
|
||||
agents,
|
||||
sessions,
|
||||
activeTerminalId,
|
||||
connection,
|
||||
sessionTitles,
|
||||
nowUnix,
|
||||
canRequestStart,
|
||||
agentAtSessionLimit,
|
||||
selectedAgentSessionLimit,
|
||||
onActivate,
|
||||
onClose,
|
||||
onStart,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="terminal-tabs">
|
||||
<div
|
||||
className="terminal-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>No active session</span>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((item) => {
|
||||
const agent = agents.find(
|
||||
(candidate) => candidate.agent_id === item.agent_id,
|
||||
);
|
||||
const active = item.terminal_id === activeTerminalId;
|
||||
const locked = item.operator_attached && !active;
|
||||
const agentLabel = agent ? displayAgentLabel(agent) : item.agent_id;
|
||||
const sessionTitle = sessionTitles[item.terminal_id];
|
||||
const tabLabel = sessionTitle
|
||||
? `${agentLabel} · ${sessionTitle}`
|
||||
: agentLabel;
|
||||
const expiry = terminalExpiryDisplay(item.expires_at_unix, nowUnix);
|
||||
const timingTitle =
|
||||
item.expires_at_unix != null
|
||||
? `Expires ${terminalTimestamp(item.expires_at_unix)}`
|
||||
: "No expiry";
|
||||
return (
|
||||
<div
|
||||
key={item.terminal_id}
|
||||
role="presentation"
|
||||
className="terminal-tab"
|
||||
data-active={active || undefined}
|
||||
data-locked={locked || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className="terminal-tab-select"
|
||||
disabled={locked || connection === "connecting"}
|
||||
title={`${locked ? "Attached in another browser\n" : ""}${tabLabel}\nCreated ${terminalTimestamp(item.created_at_unix)}\n${timingTitle}`}
|
||||
onClick={() => onActivate(item)}
|
||||
>
|
||||
<span
|
||||
className="terminal-tab-dot"
|
||||
data-state={
|
||||
active
|
||||
? connection
|
||||
: locked
|
||||
? "attached"
|
||||
: item.agent_attached
|
||||
? "detached"
|
||||
: "disconnected"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="terminal-tab-label">{tabLabel}</span>
|
||||
<time
|
||||
className={
|
||||
expiry?.warning ? "text-amber-400" : "text-[#7f8d9a]"
|
||||
}
|
||||
dateTime={new Date(
|
||||
(item.expires_at_unix ?? item.created_at_unix) * 1000,
|
||||
).toISOString()}
|
||||
>
|
||||
{expiry?.label ?? terminalCreatedTime(item.created_at_unix)}
|
||||
</time>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-tab-close"
|
||||
disabled={locked || connection === "connecting"}
|
||||
aria-label={`Close ${agentLabel} terminal session`}
|
||||
title={
|
||||
locked
|
||||
? "Attached in another browser"
|
||||
: connection === "connecting"
|
||||
? "Connecting — wait before closing"
|
||||
: "Close session"
|
||||
}
|
||||
onClick={() => onClose(item)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-new-tab"
|
||||
disabled={!canRequestStart}
|
||||
aria-label="New terminal session"
|
||||
onClick={onStart}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{agentAtSessionLimit
|
||||
? `${selectedAgentSessionLimit}-session limit reached`
|
||||
: "New session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import type { TerminalSession } from "@/api";
|
||||
|
||||
export type TerminalConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
|
||||
export type TerminalModifiers = {
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
export const NO_TERMINAL_MODIFIERS: TerminalModifiers = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
};
|
||||
|
||||
export function mergeTerminalSession(
|
||||
sessions: TerminalSession[],
|
||||
next: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
const existingIndex = sessions.findIndex(
|
||||
(item) => item.terminal_id === next.terminal_id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Attaching updates session state, not identity or tab position. Removing
|
||||
// and re-inserting here made same-second sessions swap places on click.
|
||||
return sessions.map((item, index) =>
|
||||
index === existingIndex ? next : item,
|
||||
);
|
||||
}
|
||||
return orderTerminalSessions([...sessions, next]);
|
||||
}
|
||||
|
||||
function compareTerminalSessions(
|
||||
left: TerminalSession,
|
||||
right: TerminalSession,
|
||||
): number {
|
||||
return (
|
||||
left.created_at_unix - right.created_at_unix ||
|
||||
left.terminal_id.localeCompare(right.terminal_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function orderTerminalSessions(
|
||||
sessions: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
return [...sessions].sort(compareTerminalSessions);
|
||||
}
|
||||
|
||||
export function reconcileTerminalSessions(
|
||||
current: TerminalSession[],
|
||||
listed: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||
const retained = current.flatMap((item) => {
|
||||
const updated = listedById.get(item.terminal_id);
|
||||
if (!updated) return [];
|
||||
listedById.delete(item.terminal_id);
|
||||
return [updated];
|
||||
});
|
||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||
}
|
||||
|
||||
export function restoreCandidates(
|
||||
sessions: TerminalSession[],
|
||||
rememberedId: string | null,
|
||||
selectedAgentId: string,
|
||||
): TerminalSession[] {
|
||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||
const available = sessions.filter(
|
||||
(item) => !item.operator_attached && item !== remembered,
|
||||
);
|
||||
available.sort((left, right) => {
|
||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||
return (
|
||||
rightPreferred - leftPreferred ||
|
||||
right.created_at_unix - left.created_at_unix
|
||||
);
|
||||
});
|
||||
return remembered ? [remembered, ...available] : available;
|
||||
}
|
||||
|
||||
export function applyTerminalModifiers(
|
||||
data: string,
|
||||
modifiers: TerminalModifiers,
|
||||
): string {
|
||||
let modified = data;
|
||||
if (modifiers.ctrl && modified.length === 1) {
|
||||
const code = modified.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
modified = String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
}
|
||||
return modifiers.meta ? `\x1b${modified}` : modified;
|
||||
}
|
||||
|
||||
export function visibleTerminalText(terminal: XTerm): string {
|
||||
const buffer = terminal.buffer.active;
|
||||
const firstLine = buffer.viewportY;
|
||||
const lastLine = Math.min(buffer.length, firstLine + terminal.rows);
|
||||
const lines: string[] = [];
|
||||
for (let index = firstLine; index < lastLine; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
APIError,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
type TerminalSession,
|
||||
} from "@/api";
|
||||
import {
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
applyTerminalModifiers,
|
||||
orderTerminalSessions,
|
||||
restoreCandidates,
|
||||
type TerminalConnectionState,
|
||||
type TerminalModifiers,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
import type { TerminalCatalog } from "@/pages/terminal/useTerminalSessions";
|
||||
import type { TerminalPort } from "@/pages/terminal/useXterm";
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
|
||||
// Fallback for browsers that do not implement crypto.randomUUID().
|
||||
const randomId = () =>
|
||||
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||
const num = Number(c);
|
||||
return (
|
||||
num ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
|
||||
function terminalOperatorId(): string {
|
||||
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||
if (remembered) return remembered;
|
||||
const created = window.crypto.randomUUID?.() ?? randomId();
|
||||
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
type Options = {
|
||||
terminal: TerminalPort;
|
||||
catalog: TerminalCatalog;
|
||||
selectedAgentId: string;
|
||||
};
|
||||
|
||||
export function useTerminalSession({
|
||||
terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
}: Options) {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const catalogRef = useRef(catalog);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
catalogRef.current = catalog;
|
||||
activeSessionRef.current = session;
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
socketRef.current = null;
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const writeTerminalData = useCallback(
|
||||
(data: string, consumeModifiers = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
const output = consumeModifiers
|
||||
? applyTerminalModifiers(data, modifiersRef.current)
|
||||
: data;
|
||||
socket.send(new TextEncoder().encode(output));
|
||||
if (
|
||||
consumeModifiers &&
|
||||
(modifiersRef.current.ctrl || modifiersRef.current.meta)
|
||||
) {
|
||||
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const modifiersRef = useRef(modifiers);
|
||||
modifiersRef.current = modifiers;
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
detachTransport();
|
||||
setSession(nextSession);
|
||||
activeSessionRef.current = nextSession;
|
||||
terminal.setActiveTerminalId(nextSession.terminal_id);
|
||||
catalogRef.current.merge({ ...nextSession, operator_attached: true });
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
operator_id: operatorId,
|
||||
}),
|
||||
);
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) return;
|
||||
if (typeof event.data !== "string") {
|
||||
terminal.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
window.requestAnimationFrame(() => {
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message), true);
|
||||
terminal.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminal.writeLine("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
terminal.writeLine("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socketRef.current = null;
|
||||
catalogRef.current.markDetached(nextSession.terminal_id);
|
||||
setConnection((current) =>
|
||||
current === "exited" ? current : "disconnected",
|
||||
);
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
};
|
||||
},
|
||||
[detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
async (nextSession: TerminalSession) => {
|
||||
if (nextSession.terminal_id === activeSessionRef.current?.terminal_id)
|
||||
return;
|
||||
if (nextSession.operator_attached) return;
|
||||
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
nextSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not attach terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[connect, detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const dimensions = terminal.dimensions();
|
||||
if (!selectedAgentIdRef.current || !dimensions) return;
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
try {
|
||||
terminal.fit();
|
||||
const fittedDimensions = terminal.dimensions() ?? dimensions;
|
||||
const created = await createTerminal(
|
||||
selectedAgentIdRef.current,
|
||||
fittedDimensions.rows,
|
||||
fittedDimensions.cols,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
}, [connect, detachTransport, terminal]);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
const currentSession = activeSessionRef.current;
|
||||
if (!currentSession) return;
|
||||
detachTransport();
|
||||
try {
|
||||
terminal.reset();
|
||||
setConnection("connecting");
|
||||
const attached = await attachTerminal(
|
||||
currentSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("disconnected");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, [connect, detachTransport, operatorId, terminal]);
|
||||
|
||||
const close = useCallback(
|
||||
async (closingSession: TerminalSession) => {
|
||||
const closingId = closingSession.terminal_id;
|
||||
const closesActiveSession =
|
||||
closingId === activeSessionRef.current?.terminal_id;
|
||||
const remaining = catalogRef.current
|
||||
.getSessions()
|
||||
.filter((item) => item.terminal_id !== closingId);
|
||||
const fallback = remaining.find((item) => !item.operator_attached);
|
||||
|
||||
if (closesActiveSession) {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
detachTransport();
|
||||
}
|
||||
try {
|
||||
await closeTerminal(closingId);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
catalogRef.current.remove(closingId);
|
||||
terminal.forgetTitle(closingId);
|
||||
if (
|
||||
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
||||
) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||
}
|
||||
if (closesActiveSession) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
// reset discards the complete screen and terminal modes; clear() does not.
|
||||
terminal.reset();
|
||||
if (fallback) void activate(fallback);
|
||||
}
|
||||
},
|
||||
[activate, detachTransport, terminal],
|
||||
);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof TerminalModifiers) => {
|
||||
setModifiers((current) => {
|
||||
const next = { ...current, [modifier]: !current[modifier] };
|
||||
modifiersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
|
||||
const writeAccessoryKey = useCallback(
|
||||
(data: string) => {
|
||||
writeTerminalData(data);
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal, writeTerminalData],
|
||||
);
|
||||
|
||||
// Transport input/resize subscriptions and restoration begin only once xterm is usable.
|
||||
useEffect(() => {
|
||||
if (!terminal.ready) return;
|
||||
mountedRef.current = true;
|
||||
const input = terminal.onInput((data) => writeTerminalData(data, true));
|
||||
const resize = terminal.onResize(() => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
}
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
async function restoreTerminalSession() {
|
||||
try {
|
||||
const listed = await catalogRef.current.refresh();
|
||||
if (cancelled) return;
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
orderTerminalSessions(listed),
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (cancelled) return;
|
||||
setConnection("connecting");
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
candidate.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
terminal.reset();
|
||||
connect(attached);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof APIError) ||
|
||||
error.code !== "terminal_operator_already_attached"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setConnection("idle");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not restore terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void restoreTerminalSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
input.dispose();
|
||||
resize.dispose();
|
||||
detachTransport();
|
||||
};
|
||||
}, [
|
||||
terminal.ready,
|
||||
terminal.onInput,
|
||||
terminal.onResize,
|
||||
terminal.reset,
|
||||
terminal.sendResize,
|
||||
terminal,
|
||||
connect,
|
||||
detachTransport,
|
||||
operatorId,
|
||||
writeTerminalData,
|
||||
]);
|
||||
|
||||
return {
|
||||
session,
|
||||
connection,
|
||||
modifiers,
|
||||
start,
|
||||
activate,
|
||||
reconnect,
|
||||
close,
|
||||
writeTerminalData,
|
||||
writeAccessoryKey,
|
||||
toggleModifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { listTerminals, type TerminalSession } from "@/api";
|
||||
import {
|
||||
mergeTerminalSession,
|
||||
reconcileTerminalSessions,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
export function useTerminalSessions() {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const sessionsRef = useRef(sessions);
|
||||
const refreshRef = useRef<() => Promise<TerminalSession[]>>(async () => []);
|
||||
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const refresh = useCallback(() => refreshRef.current(), []);
|
||||
const merge = useCallback((session: TerminalSession) => {
|
||||
setSessions((current) => mergeTerminalSession(current, session));
|
||||
}, []);
|
||||
const markDetached = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === terminalId
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const remove = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.filter((item) => item.terminal_id !== terminalId),
|
||||
);
|
||||
}, []);
|
||||
const reconcile = useCallback((listed: TerminalSession[]) => {
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed));
|
||||
}, []);
|
||||
const getSessions = useCallback(() => sessionsRef.current, []);
|
||||
|
||||
// The catalog stream and fallback timer live for the page mount, independent of active sessions.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshInFlight: Promise<TerminalSession[]> | null = null;
|
||||
let refreshRequested = false;
|
||||
let eventsSocket: WebSocket | null = null;
|
||||
let reconnectTimer = 0;
|
||||
let healthyTimer = 0;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const refreshSessions = (): Promise<TerminalSession[]> => {
|
||||
if (refreshInFlight) {
|
||||
// Coalesce bursts and fetch again if another invalidation arrives in flight.
|
||||
refreshRequested = true;
|
||||
return refreshInFlight;
|
||||
}
|
||||
refreshInFlight = (async () => {
|
||||
let listed: TerminalSession[] = [];
|
||||
do {
|
||||
refreshRequested = false;
|
||||
listed = await listTerminals();
|
||||
if (!cancelled) {
|
||||
setSessions((current) =>
|
||||
reconcileTerminalSessions(current, listed),
|
||||
);
|
||||
}
|
||||
} while (refreshRequested && !cancelled);
|
||||
return listed;
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
if (refreshRequested && !cancelled) {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
refreshRef.current = refreshSessions;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
const socket = new WebSocket(
|
||||
websocketUrl("/api/v1/control/terminals/events/ws"),
|
||||
);
|
||||
eventsSocket = socket;
|
||||
socket.onmessage = (event) => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data) as { type?: string };
|
||||
if (message.type === "sessions_changed") {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Invalidations are hints; the periodic recovery fetch remains active.
|
||||
}
|
||||
};
|
||||
socket.onopen = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
window.clearTimeout(healthyTimer);
|
||||
healthyTimer = window.setTimeout(() => {
|
||||
if (eventsSocket === socket && socket.readyState === WebSocket.OPEN) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
}, 10_000);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
window.clearTimeout(healthyTimer);
|
||||
const ceiling = Math.min(500 * 2 ** reconnectAttempt, 12_000);
|
||||
const delay = Math.min(
|
||||
Math.round(ceiling * (0.8 + Math.random() * 0.4)),
|
||||
15_000,
|
||||
);
|
||||
reconnectAttempt = Math.min(reconnectAttempt + 1, 5);
|
||||
reconnectTimer = window.setTimeout(connectEvents, delay);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
void refreshSessions().catch(() => undefined);
|
||||
const refreshTimer = window.setInterval(() => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}, 15_000);
|
||||
const refreshOnFocus = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(refreshTimer);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearTimeout(healthyTimer);
|
||||
eventsSocket?.close();
|
||||
eventsSocket = null;
|
||||
refreshRef.current = async () => [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
refresh,
|
||||
merge,
|
||||
markDetached,
|
||||
remove,
|
||||
reconcile,
|
||||
getSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export type TerminalCatalog = ReturnType<typeof useTerminalSessions>;
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
MIN_TERMINAL_FONT_SIZE,
|
||||
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { visibleTerminalText } from "@/pages/terminal/sessionUtils";
|
||||
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
|
||||
// Stable behavioral surface shared with session transport and page controls.
|
||||
export type TerminalPort = {
|
||||
clear: () => void;
|
||||
copy: () => Promise<void>;
|
||||
dimensions: () => { rows: number; cols: number } | null;
|
||||
forgetTitle: (terminalId: string) => void;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
blurInput: () => void;
|
||||
onInput: (handler: (data: string) => void) => { dispose: () => void };
|
||||
onResize: (handler: () => void) => { dispose: () => void };
|
||||
paste: (write: (data: string) => void) => Promise<void>;
|
||||
reset: () => void;
|
||||
scrollPages: (pages: number) => void;
|
||||
sendResize: (send: (message: string) => void, force?: boolean) => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
setActiveTerminalId: (terminalId: string | undefined) => void;
|
||||
write: (data: Uint8Array) => void;
|
||||
writeLine: (line: string) => void;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
pageRef: RefObject<HTMLElement | null>;
|
||||
hostRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useXterm({ pageRef, hostRef }: Options) {
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const activeTerminalIdRef = useRef<string | undefined>(undefined);
|
||||
const lastSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const resizeListenersRef = useRef(new Set<() => void>());
|
||||
const [fontFamily, setFontFamily] = useState<string>();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const setActiveTerminalId = useCallback((terminalId: string | undefined) => {
|
||||
activeTerminalIdRef.current = terminalId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadTerminalFontFamily().then((loadedFontFamily) => {
|
||||
if (!cancelled) setFontFamily(loadedFontFamily);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewportHeight = () => {
|
||||
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||
pageRef.current?.style.setProperty(
|
||||
"--terminal-visual-viewport-height",
|
||||
`${height}px`,
|
||||
);
|
||||
};
|
||||
updateViewportHeight();
|
||||
viewport?.addEventListener("resize", updateViewportHeight);
|
||||
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, [pageRef]);
|
||||
|
||||
const sendResize = useCallback(
|
||||
(send: (message: string) => void, force = false) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
if (
|
||||
!force &&
|
||||
lastSizeRef.current.rows === terminal.rows &&
|
||||
lastSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSizeRef.current = { rows: terminal.rows, cols: terminal.cols };
|
||||
send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fit = useCallback(() => fitRef.current?.fit(), []);
|
||||
const focus = useCallback(() => terminalRef.current?.focus(), []);
|
||||
const blurInput = useCallback(
|
||||
() => terminalRef.current?.textarea?.blur(),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => terminalRef.current?.reset(), []);
|
||||
const clear = useCallback(() => terminalRef.current?.clear(), []);
|
||||
const write = useCallback(
|
||||
(data: Uint8Array) => terminalRef.current?.write(data),
|
||||
[],
|
||||
);
|
||||
const writeLine = useCallback(
|
||||
(line: string) => terminalRef.current?.writeln(line),
|
||||
[],
|
||||
);
|
||||
const dimensions = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
|
||||
}, []);
|
||||
const forgetTitle = useCallback((terminalId: string) => {
|
||||
setSessionTitles((current) => {
|
||||
if (!(terminalId in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const onInput = useCallback(
|
||||
(handler: (data: string) => void) =>
|
||||
terminalRef.current?.onData(handler) ?? { dispose: () => {} },
|
||||
[],
|
||||
);
|
||||
const onResize = useCallback((handler: () => void) => {
|
||||
resizeListenersRef.current.add(handler);
|
||||
return { dispose: () => resizeListenersRef.current.delete(handler) };
|
||||
}, []);
|
||||
const scrollPages = useCallback(
|
||||
(pages: number) => terminalRef.current?.scrollPages(pages),
|
||||
[],
|
||||
);
|
||||
const copy = useCallback(async () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const text = terminal.hasSelection()
|
||||
? terminal.getSelection()
|
||||
: visibleTerminalText(terminal);
|
||||
if (!text) {
|
||||
toast.info("Nothing to copy");
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const paste = useCallback(async (writeData: (data: string) => void) => {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) writeData(text);
|
||||
terminalRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const changeFontSize = useCallback((delta: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||
const next = Math.min(
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||
);
|
||||
if (next === current) return;
|
||||
terminal.options.fontSize = next;
|
||||
setFontSize(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wait for the font before creating xterm; this effect owns the instance and its observers.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || !fontFamily) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily,
|
||||
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||
lineHeight: 1.1,
|
||||
scrollbar: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
scrollbarSliderBackground: "#52617099",
|
||||
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||
scrollbarSliderActiveBackground: "#8294a6",
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = "11";
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.loadAddon(new ImageAddon({}));
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(hostRef.current);
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const copiesTerminalSelection =
|
||||
event.type === "keydown" &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "KeyC";
|
||||
if (!copiesTerminalSelection) return true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
} else {
|
||||
void navigator.clipboard
|
||||
.writeText(terminal.getSelection())
|
||||
.catch((error) =>
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const titleChange = terminal.onTitleChange((title) => {
|
||||
const terminalId = activeTerminalIdRef.current;
|
||||
if (!terminalId) return;
|
||||
const normalized = title.trim();
|
||||
setSessionTitles((current) => {
|
||||
if (current[terminalId] === normalized) return current;
|
||||
if (!normalized) {
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [terminalId]: normalized };
|
||||
});
|
||||
});
|
||||
fitAddon.fit();
|
||||
// Consumers subscribe only after this transition; the port methods stay stable.
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
setReady(true);
|
||||
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
|
||||
return () => {
|
||||
titleChange.dispose();
|
||||
resizeObserver.disconnect();
|
||||
resizeListenersRef.current.clear();
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
setReady(false);
|
||||
};
|
||||
}, [fontFamily, hostRef]);
|
||||
|
||||
const terminal = useMemo<TerminalPort>(
|
||||
() => ({
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
}),
|
||||
[
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
],
|
||||
);
|
||||
|
||||
return { terminal, fontSize, sessionTitles };
|
||||
}
|
||||
+409
-1
@@ -135,7 +135,7 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground transition-colors duration-100 ease-in-out;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -189,6 +189,13 @@
|
||||
background-size: 100% 100%;
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
transition: background 100ms ease-in-out;
|
||||
}
|
||||
|
||||
/* xterm focuses an input near the active row. Keep that focus operation from
|
||||
scrolling the page shell and moving the terminal toolbar off-screen. */
|
||||
.app-main:has(.terminal-page) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||
@@ -315,6 +322,407 @@
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Remote terminal ─────────────────────────────────────────────────── */
|
||||
|
||||
.terminal-page {
|
||||
display: flex;
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.terminal-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
gap: 0.4rem;
|
||||
height: 1.4rem;
|
||||
padding-inline: 0.45rem;
|
||||
border-color: rgb(255 255 255 / 14%);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.terminal-status > span {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--presence-unknown);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="ready"] > span {
|
||||
background: var(--presence-online);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="connecting"] > span {
|
||||
background: var(--presence-likely);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="disconnected"] > span,
|
||||
.terminal-status[data-state="exited"] > span {
|
||||
background: var(--presence-offline);
|
||||
}
|
||||
|
||||
.terminal-notice {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #0b1117;
|
||||
}
|
||||
|
||||
.terminal-framebar {
|
||||
display: flex;
|
||||
min-height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem 0.45rem 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, white 9%, transparent);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.terminal-session-label,
|
||||
.terminal-frame-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-tab-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.terminal-tab-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-tab {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: min(20rem, 48vw);
|
||||
height: 1.85rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #aab7c4;
|
||||
}
|
||||
|
||||
.terminal-tab-select {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.35rem 0 0.6rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.terminal-tab-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-tab time {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.terminal-tab[data-active] {
|
||||
max-width: min(30rem, 60vw);
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(255 255 255 / 7%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-tab[data-locked] {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.terminal-tab-close {
|
||||
display: grid;
|
||||
width: 1.6rem;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: transparent;
|
||||
color: #7f8d9a;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.terminal-tab-close:focus-visible,
|
||||
.terminal-tab-close:hover,
|
||||
.terminal-tab[data-active] .terminal-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-tab-close:disabled {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.terminal-new-tab {
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-tab-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--presence-unknown);
|
||||
}
|
||||
|
||||
.terminal-tab-dot[data-state="ready"],
|
||||
.terminal-tab-dot[data-state="attached"] {
|
||||
background: var(--presence-online);
|
||||
}
|
||||
|
||||
.terminal-tab-dot[data-state="connecting"],
|
||||
.terminal-tab-dot[data-state="detached"] {
|
||||
background: var(--presence-likely);
|
||||
}
|
||||
|
||||
.terminal-tab-dot[data-state="disconnected"],
|
||||
.terminal-tab-dot[data-state="exited"] {
|
||||
background: var(--presence-offline);
|
||||
}
|
||||
|
||||
.terminal-frame-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-session-label > span:not(.terminal-status) {
|
||||
overflow: hidden;
|
||||
color: #dbe4ee;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 550;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-framebar button {
|
||||
color: #aab7c4;
|
||||
}
|
||||
|
||||
.terminal-framebar button:hover {
|
||||
color: #f1f5f9;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
order: 2;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #0b1117;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories-toggle[data-active] {
|
||||
color: #6ee7a8;
|
||||
background: rgb(110 231 168 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories {
|
||||
display: flex;
|
||||
order: 3;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.45rem max(0.35rem, env(safe-area-inset-bottom));
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
border-top: 1px solid rgb(255 255 255 / 9%);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.terminal-accessory-group {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessory-group + .terminal-accessory-group {
|
||||
padding-left: 0.35rem;
|
||||
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
width: auto;
|
||||
min-width: 2.4rem;
|
||||
height: 2rem;
|
||||
padding-inline: 0.55rem;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: rgb(255 255 255 / 4%);
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key:hover {
|
||||
background: rgb(255 255 255 / 9%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key[data-active] {
|
||||
border-color: rgb(110 231 168 / 48%);
|
||||
background: rgb(110 231 168 / 14%);
|
||||
color: #9af0bf;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--control {
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--wide {
|
||||
min-width: 3.15rem;
|
||||
}
|
||||
|
||||
.terminal-font-controls output {
|
||||
min-width: 1.5rem;
|
||||
color: #91a0ae;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #0b1117;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm-viewport {
|
||||
/* xterm defaults this element to pure black. It remains visible around the
|
||||
fitted canvas, so keep it on the same surface as the terminal theme. */
|
||||
background-color: #0b1117;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm-scrollable-element > .scrollbar,
|
||||
.terminal-surface .xterm-scrollable-element > .scrollbar > .slider {
|
||||
/* Without this, mobile browsers claim a thumb drag as page panning before
|
||||
xterm's pointer-capture scrollbar can process it. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-page {
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.terminal-accessories {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
min-width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||
|
||||
.presence-dot {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "Wakey JetBrainsMono Nerd Font Mono";
|
||||
src: url("../assets/fonts/jetbrains-mono-nerd/JetBrainsMonoNerdFontMono-Regular.woff2")
|
||||
format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import "./terminalFonts.css";
|
||||
|
||||
const TERMINAL_WEB_FONT = "Wakey JetBrainsMono Nerd Font Mono";
|
||||
const TERMINAL_FALLBACK_FONTS =
|
||||
'"SFMono-Regular", Consolas, "Liberation Mono", monospace';
|
||||
|
||||
let terminalFontsPromise: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* Resolves the terminal font stack only after browser glyph metrics are ready.
|
||||
* xterm caches those metrics when it opens, so a late webfont load permanently
|
||||
* misaligns cells for that terminal instance.
|
||||
*/
|
||||
export function loadTerminalFontFamily(): Promise<string> {
|
||||
terminalFontsPromise ??= document.fonts
|
||||
.load(`400 14px "${TERMINAL_WEB_FONT}"`, "\uE0B0")
|
||||
.then((faces) => {
|
||||
if (faces.length === 0) {
|
||||
throw new Error("terminal webfont was not registered");
|
||||
}
|
||||
return `"${TERMINAL_WEB_FONT}", ${TERMINAL_FALLBACK_FONTS}`;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
"Could not load terminal webfont; using system monospace",
|
||||
error,
|
||||
);
|
||||
return TERMINAL_FALLBACK_FONTS;
|
||||
});
|
||||
return terminalFontsPromise;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"ignoreDeprecations": "5.0",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
+1
Submodule vendor/vt100 added at 60d0544b14.
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.0"
|
||||
version = "0.3.7"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -9,6 +9,7 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
futures-util = "0.3"
|
||||
http = "1"
|
||||
humantime-serde = "1"
|
||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||
nix = { version = "0.31", default-features = false, features = [
|
||||
"signal",
|
||||
@@ -23,6 +24,7 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = [
|
||||
"fs",
|
||||
"macros",
|
||||
"io-util",
|
||||
"rt-multi-thread",
|
||||
"time",
|
||||
"signal",
|
||||
@@ -31,12 +33,10 @@ tokio = { version = "1", features = [
|
||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||
toml = "1.1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = [
|
||||
"env-filter",
|
||||
"fmt",
|
||||
"time",
|
||||
] }
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
url = "2"
|
||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
wakey-linux = { path = "../wakey-linux", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||
@@ -98,6 +98,10 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub agent_token: Option<String>,
|
||||
|
||||
/// Days to keep local hook observation rows since last seen. Zero disables pruning.
|
||||
#[arg(long)]
|
||||
pub observation_retention_days: Option<u64>,
|
||||
|
||||
/// Replace an existing config file.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user