fix(ui): keep device details open across identity changes

This commit is contained in:
lda committed 2026-10-12 23:59:23 +07:00 Verified
1 parent 4eef8c758f
commit 4e290e6de8
12 files changed
+324 -32

No files matched your search

+6
View File
@@ -27,6 +27,12 @@ filters or the list API's 1,000-row limit. It borrows the view and clones only t
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
+134
View File
@@ -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.
+16 -3
View File
@@ -29,7 +29,7 @@ live browser recovery and shutdown behavior are not established by those checks.
- [ ] 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.
- [ ] Keep device details current after identity mutations change the grouping key or active list filters exclude the result. Follow the explicit identity returned by create/attach/merge and load details independently of list filters; do not guess identity from a shared recyclable IP or retain stale mutation controls. Review detachment/split behavior separately, since a former group can become multiple rows.
- [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
@@ -59,8 +59,10 @@ or current fleet size. Metric labels should remain bounded; no per-device labels
- [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, plus protected UI presentation of collected measurements. Sources must tolerate missing Linux files; not every agent host is a router.
- [ ] Expose changing measurements through OTLP on a modest sampling interval; show selected process/host details on Fleet Health and agent details. Keep host information behind the protected operator interface, not public health endpoints. Avoid expensive reads on every request and unbounded labels.
- [ ] 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.
@@ -85,6 +87,17 @@ event hooks; [ubus](https://openwrt.org/docs/techref/ubus) is local IPC/RPC, and
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
- [ ] Replace Caddy's nonempty `Cf-Access-Jwt-Assertion` matcher: header presence does not authenticate a request that reaches the origin directly. The `lb.ldlda.com` probe reached Cloudflare and redirected to Access login, so a direct-origin bypass was not reproduced; the matcher remains insufficient.
- [ ] Add a CC-owned, loopback-accessible `/internal/auth/verify` endpoint for Caddy `forward_auth`. Validate the Access JWT signature, permitted algorithm, exact team issuer, protected Wakey application AUD and expiry using a standard JWT library. Cache signing keys with bounded fetches/timeouts and rate-limited refreshes; arbitrary token key IDs must not trigger unlimited upstream requests. Return 204 for valid credentials, 401 for missing/invalid credentials, and 503 when verification cannot be completed safely. Never log tokens.
- [ ] Apply `forward_auth` to `/`, `/ui*` and `/api/v1/control/*`, including operator WebSocket handshakes. Keep health, agent enrollment/WS/terminal relay and ACME challenge paths outside Access verification; agent endpoints retain their own authentication. Do not accept the bypass application's AUD as an operator credential or expose the internal verifier through public Caddy routes. Keep CC bound to loopback: Caddy owns enforcement, so direct CC access would bypass it.
- [ ] Test forged/expired/wrong-audience tokens, signing-key rotation and unavailable key retrieval, protected HTTP/WS rejection, and unaffected public agent routes before deployment. Validate the Caddy configuration and test a real direct-origin connection as well as the Cloudflare-facing hostname. 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.
+12
View File
@@ -339,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;
@@ -361,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;
+63 -13
View File
@@ -4,7 +4,9 @@ import { toast } from "sonner";
import {
fetchFleetDevices,
fetchFleetDevice,
fetchKnownDevices,
fetchKnownDevice,
refreshFleetDevices,
wakeFleetDevice,
type Agent,
@@ -70,6 +72,15 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
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
@@ -91,14 +102,6 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
if (seq !== loadSeqRef.current) return;
setDevices(nextDevices);
setKnownDevices(nextKnownDevices);
// Keep the open dialog current after rename/identifier mutations.
setDetails((current) =>
current
? (nextDevices.find(
(device) => device.device_key === current.device_key,
) ?? null)
: null,
);
} catch (err) {
if (seq !== loadSeqRef.current) return;
toast.error("Failed to load fleet", { description: String(err) });
@@ -107,6 +110,51 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
}
}
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),
});
}
}
async function refreshAllConnected() {
setRefreshing(true);
try {
@@ -116,7 +164,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
? `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 {
@@ -135,7 +183,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: 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 {
@@ -347,7 +395,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: 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)}
/>
))}
@@ -371,13 +419,15 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
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>
);
+16 -10
View File
@@ -48,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({
@@ -99,18 +99,20 @@ export function FleetDeviceDetailsDialog({
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
);
async function wrap(fn: () => Promise<void | { changed?: boolean }>) {
async function wrap(
fn: () => Promise<void | { changed?: boolean; knownDeviceId?: string }>,
) {
setError("");
setActionBusy(true);
try {
const res = await fn();
if (!res || res.changed !== false) {
onChanged();
await onChanged(res?.knownDeviceId);
}
} catch (err: any) {
setError(String(err));
if (err && (err.changed || err.partial)) {
onChanged();
await onChanged(err.knownDeviceId);
}
} finally {
setActionBusy(false);
@@ -125,6 +127,7 @@ export function FleetDeviceDetailsDialog({
identifiers: rowIdentifiers,
});
toast.success(`Remembered as "${result.display_name}"`);
return { knownDeviceId: result.device_id };
});
}
@@ -133,7 +136,7 @@ export function FleetDeviceDetailsDialog({
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
await wrap(async () => {
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
if (ids.length === 0) return;
if (ids.length === 0) return { changed: false };
const results = await Promise.allSettled(
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
@@ -144,14 +147,17 @@ export function FleetDeviceDetailsDialog({
if (errors.length) {
const err = errors[0].reason as Error;
if (changed) (err as any).changed = true;
if (changed) {
(err as any).changed = true;
(err as any).knownDeviceId = targetDeviceId;
}
throw err;
}
toast.success(
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
);
return { changed };
return { changed, knownDeviceId: targetDeviceId };
});
}
@@ -159,12 +165,12 @@ export function FleetDeviceDetailsDialog({
if (!targetDeviceId || !currentDevice.known_device) return;
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
await wrap(async () => {
await mergeKnownDevice(
const merged = await mergeKnownDevice(
targetDeviceId,
currentDevice.known_device!.device_id,
);
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
onOpenChange(false);
return { knownDeviceId: merged.device_id };
});
}
@@ -297,7 +303,7 @@ export function FleetDeviceDetailsDialog({
<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"}
+1 -1
View File
@@ -14,5 +14,5 @@ pub use enroll::{
};
pub(crate) use fleet::FleetCache;
pub(crate) use fleet::sample_fleet_counts;
pub use fleet::{list_fleet_devices, refresh_fleet_devices, wake_fleet_device};
pub use fleet::{get_fleet_device, list_fleet_devices, refresh_fleet_devices, wake_fleet_device};
pub use stats::{StateStatsResponse, state_stats};
@@ -1,7 +1,7 @@
use std::collections::{BTreeSet, HashMap};
use axum::Json;
use axum::extract::{Query, State};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use tracing::warn;
@@ -105,6 +105,30 @@ pub async fn list_fleet_devices(
}
}
/// Details are an exact identity lookup, not a filtered/paginated list lookup.
/// In particular, remembering an unsaved row must remain readable under its new key.
#[tracing::instrument(name = "fleet_get", skip_all)]
pub async fn get_fleet_device(
State(state): State<AppState>,
Path(device_key): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let devices = fleet_view(&state)
.await
.map_err(|err| fleet_error(err, "get_fleet_device_failed"))?;
let device = devices
.iter()
.find(|device| device.device_key == device_key)
.cloned()
.ok_or_else(|| {
ApiError::new(
StatusCode::NOT_FOUND,
"fleet_device_not_found",
"fleet device not found",
)
})?;
Ok(Json(device))
}
#[tracing::instrument(name = "fleet_load", skip_all, fields(grouped = tracing::field::Empty, filtered = tracing::field::Empty, returned = tracing::field::Empty))]
async fn load_fleet_devices(
state: &AppState,
@@ -159,6 +159,35 @@ async fn fleet_cache_tracks_store_edits_and_connections_without_caching_filters(
.unwrap()
.is_empty()
);
// The filtered list excludes this row, but exact details still follow the
// authoritative saved key after remembering and renaming the observation.
{
use axum::{
extract::{Path, State},
response::IntoResponse,
};
let response = super::get_fleet_device(
State(state.clone()),
Path(format!("known:{}", saved.device_id)),
)
.await
.unwrap()
.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let details: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(details["display_name"], "renamed pc");
assert_eq!(details["known_device"]["device_id"], saved.device_id);
let missing =
super::get_fleet_device(State(state.clone()), Path(initial[0].device_key.clone()))
.await
.err()
.expect("old unsaved grouping key no longer exists");
assert_eq!(missing.status, axum::http::StatusCode::NOT_FOUND);
assert_eq!(missing.code, "fleet_device_not_found");
}
let counts = super::sample_fleet_counts(&state).await.unwrap();
assert_eq!((counts.saved, counts.unsaved), (1, 0));
let all = ListFleetDevicesQuery {
@@ -216,6 +216,20 @@ mod tests {
.unwrap();
assert_eq!(listed.len(), 1000);
assert!(listed.iter().all(|row| row.device_key != "pc-1000"));
{
use axum::extract::Path;
let details =
super::super::get_fleet_device(State(state.clone()), Path("pc-1000".into()))
.await
.unwrap()
.into_response();
assert_eq!(details.status(), StatusCode::OK);
let body = axum::body::to_bytes(details.into_body(), usize::MAX)
.await
.unwrap();
let row: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(row["device_key"], "pc-1000");
}
for route_id in [None, Some("valid")] {
let selected = load_selected_route(&state, "pc-1000", route_id)
.await
+4 -4
View File
@@ -15,10 +15,10 @@ pub use commands::{list_agents, run_command};
pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
enroll, forget_known_device, get_known_device, healthz, issue_enroll_token, list_enroll_tokens,
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
rename_known_device, revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats,
wake_fleet_device,
enroll, forget_known_device, get_fleet_device, get_known_device, healthz, issue_enroll_token,
list_enroll_tokens, list_fleet_devices, list_known_devices, merge_known_device,
refresh_fleet_devices, rename_known_device, revoke_agent, revoke_enroll_token,
set_agent_nickname, state_stats, wake_fleet_device,
};
pub use terminals::{
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
+4
View File
@@ -109,6 +109,10 @@ fn control_api_routes() -> Router<AppState> {
"/api/v1/control/fleet/devices/refresh",
post(api::refresh_fleet_devices),
)
.route(
"/api/v1/control/fleet/devices/{device_key}",
get(api::get_fleet_device),
)
.route("/api/v1/control/fleet/wake", post(api::wake_fleet_device))
.route(
"/api/v1/control/devices",