wah wah wah

This commit is contained in:
lda
2026-04-12 18:15:23 +07:00 Unverified
parent 5efcdf75ea
commit c8a986148d
5 changed files with 506 additions and 44 deletions
+143 -41
View File
@@ -1,47 +1,149 @@
## Plan: Operator UI v1 (Flexible Delivery) ## Plan: wakey UI v1 (Device-First)
Ship a practical, operator-first UI quickly using existing control-plane APIs, while keeping implementation choices flexible where they do not affect UX outcomes. Prioritize clear workflows, reliable live state, and low-friction deployment at /ui. The Operator UI is not the end goal by itself.
The goal is to make wakey excellent at its original purpose: quickly finding devices and waking them reliably.
**Steps** Agent, audit, and token features remain important, but they should support the device workflow rather than dominate the navigation and development effort.
1. Phase 1: Experience goals and page IA. Define primary workflows and success criteria before coding: monitor fleet health, run commands safely, inspect audit history, manage alerts, and handle enrollment tokens.
2. Phase 1: Information architecture. Create a minimal nav with Dashboard, Agents, Commands, Audit, Alerts, and Tokens; keep room to merge/split pages later based on usage.
3. Phase 1: Data contracts and client boundaries. Build a typed API client around existing endpoints with origin-relative URLs only; centralize request/retry/error normalization and leave room to swap transport helpers.
4. Phase 2: Core shell and states. Implement app shell, loading/empty/error states, top-level notifications, and shared primitives (table/list/cards/filter panel) without prematurely freezing visual details.
5. Phase 2: Dashboard and Agents first. Surface connected/offline agent state and active alert counts, with quick navigation into command and investigation workflows.
6. Phase 2: Command Runner. Implement safe command form (status/devs/leases/inventory/wake), result rendering, request_id visibility, and copy/share affordances for incident collaboration.
7. Phase 3: Audit timeline. Implement filterable audit feed (agent_id, event_type, outcome, time window) with metadata drill-down and links back to related command outcomes.
8. Phase 3: Alerts center. Implement active alerts plus transition history, with live subscription via alerts websocket and automatic polling fallback if stream drops.
9. Phase 3: Token operations. Implement issue/list/revoke flows with expiry visibility and clear destructive-action confirmation UX.
10. Phase 4: UX polish and operator ergonomics. Add keyboard-friendly flow, persisted local filters, robust reconnect indicators, and compact/high-density views for on-call usage.
11. Phase 4: Deployment integration. Build static UI artifact into release pipeline and serve at /ui behind Cloudflare Access with the existing edge policy.
12. Phase 5: Validation and soak. Run realistic operator drills and prolonged soak to tune alert noise, UI refresh cadence, and failure handling.
**Relevant files** ## Product North Star
- [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs) — route integration point for serving /ui and preserving public/control boundary.
- [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs) — command runner response contract.
- [wakey-control-plane/src/api/audit.rs](wakey-control-plane/src/api/audit.rs) — audit query/filter contract.
- [wakey-control-plane/src/api/alerts.rs](wakey-control-plane/src/api/alerts.rs) — active alerts, transition history, and websocket stream contracts.
- [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs) — token-management contracts.
- [deploy/Caddyfile.control-plane.example](deploy/Caddyfile.control-plane.example) — edge gating and /ui exposure model.
- [scripts/package_rootfs.ps1](scripts/package_rootfs.ps1) — package integration for UI artifact.
- [.gitea/workflows/release.yml](.gitea/workflows/release.yml) — CI build and release integration.
- [README.md](README.md) — operator-facing UI and API usage docs.
**Verification** An operator should be able to do this in under 10 seconds:
1. UI build verification in CI (typecheck, lint, production build) and artifact presence checks. 1. Open the UI.
2. Page-level smoke tests: Dashboard, Agents, Commands, Audit, Alerts, Tokens all load and complete primary actions. 2. Search for a device by name, IP, or MAC.
3. Contract checks between typed client models and control-plane payloads for commands/audit/alerts. 3. See whether it looks online/reachable.
4. Live-state checks: websocket stream updates alerts; fallback polling activates on disconnect and recovers gracefully. 4. Trigger wake.
5. Security checks: /ui and /api/v1/control/* remain blocked without Access policy; public agent endpoints remain reachable. 5. See immediate command result and short follow-up status.
6. Soak checks: multi-hour sessions preserve responsiveness and do not lose transitions during reconnect churn.
**Decisions** ## Scope Priorities
- Keep major architecture fixed: same-domain /ui, origin-relative API client, edge-enforced admin access.
- Keep minor implementation details flexible: exact component library, state library, and styling primitives can change if DX improves.
- Optimize for operator speed over visual novelty: dense information, low click depth, and fast command feedback.
- Treat websocket as enhancement, not dependency: polling fallback is mandatory.
**Further Considerations** 1. P0: Device discovery and wake UX.
1. Short-term backend enhancements likely to improve UX quickly: agent metadata labels, audit cursor pagination, persisted alert-rule defaults. 2. P1: Fast troubleshooting context around wake results.
2. If team preference changes, frontend framework can be swapped as long as route/contracts/deploy shape stays the same. 3. P2: Fleet/agent/admin operations.
3. Add a lightweight investigation-mode preset in UI that pivots from alert to related audits to related command request_id in one flow.
This explicitly means pages and components for Agent, Audit, Alerts, and Tokens should be present but secondary in visual hierarchy and effort until P0 is complete.
## IA Direction (v1)
Primary top-level focus:
1. Devices
2. Wake Queue (or Recent Actions)
Secondary top-level focus:
1. Fleet Health
2. Audit
3. Alerts
4. Access/Tokens
If needed, keep current routes during transition, but adjust default landing and navigation emphasis so Devices is the home workflow.
## Phase Plan
### Phase 1: Device-Centric Foundation
1. Add a dedicated Devices page that merges existing status/leases/inventory signal into one operator list.
2. Include searchable columns for name, IP, MAC, interface/dev, and recency indicators.
3. Provide row-level wake action and bulk-safe interaction model (single-click row action, confirm for bulk).
4. Define a compact "device confidence" heuristic from available data (for example: recent lease + reachable neighbor).
5. Set the default route to Devices, with prominent search and wake controls above the fold.
Acceptance criteria:
1. Search by hostname, IP, and MAC all work from one input.
2. Wake action reachable in one click from list row.
3. Response feedback shown immediately with clear success/error text.
### Phase 2: Wake Execution UX
1. Build a focused wake panel with explicit target preview before send.
2. Provide quick presets: "wake by selected device", "wake by MAC", "wake by query".
3. Persist recent wake targets locally for operator speed.
4. Add post-wake verification loop (short timed refresh of status indicators).
5. Surface request correlation id and a copy action for incident sharing.
Acceptance criteria:
1. Operator can retry wake with one click.
2. Operator can see last 20 wake attempts with outcome and timestamp.
3. Error states differentiate validation, timeout, and execution failure.
### Phase 3: Context Without Workflow Drift
1. Keep Alerts and Audit accessible from device rows and wake outcomes.
2. Add contextual deep links: device -> related alerts, device -> recent audit events.
3. Improve filtering for alerts/audit with saved local filter presets.
4. Add gentle live updates, keeping websocket optional with polling fallback.
Acceptance criteria:
1. From any failed wake, operator can jump to relevant audit entries in one step.
2. Alerts page can be filtered by kind/severity/agent and linked back to impacted devices.
### Phase 4: Fleet/Admin Hardening
1. Keep Agents page for connectivity and control routing visibility.
2. Keep Tokens page for enrollment lifecycle operations.
3. Keep Dashboard but reframe metrics around "device availability" and "wake success" first.
4. Add audit-friendly confirmation flows for destructive actions.
Acceptance criteria:
1. Admin flows do not block or slow P0 device workflows.
2. All admin actions produce clear audit-visible outcomes.
### Phase 5: Validation and Production Readiness
1. Add smoke tests for P0 flow: find device -> wake -> observe result.
2. Add contract checks for command payload/response shapes used by device and wake screens.
3. Add scenario drills (offline agent, delayed command, websocket drop, expired token).
4. Verify edge policy still protects control routes while preserving required public endpoints.
Acceptance criteria:
1. P0 flow remains usable during partial degradation.
2. Build/typecheck/test gates stay green in CI.
## Concrete UI Backlog (Ordered)
1. Create DevicesPage with unified searchable table.
2. Wire wake action directly from device row.
3. Add Recent Wake Actions panel with outcomes.
4. Add post-wake short verification refresh.
5. Rework Dashboard cards to device-first metrics.
6. Add deep links from wake result to audit and alerts context.
7. Add empty/loading/error skeleton patterns tuned for device list scale.
## Metrics of Success
1. Time-to-wake median under 10 seconds for known target.
2. Wake success rate visible per time window.
3. Fewer operator clicks for common tasks (device lookup + wake).
4. Reduced navigation to agent-centric pages for everyday usage.
## Design and Interaction Principles
1. Device-first information density over generic admin dashboards.
2. Search and action bar always visible on desktop and mobile.
3. Action feedback must be immediate and explicit.
4. Keep advanced infrastructure details available but visually de-emphasized.
## Architecture Decisions
1. Keep same-domain /ui serving and origin-relative API requests.
2. Keep route and API contracts stable while iterating UX aggressively.
3. Treat websocket as progressive enhancement; polling fallback required.
4. Preserve Cloudflare Access boundary for all control endpoints.
## Relevant Files
1. [ui/src/App.tsx](ui/src/App.tsx)
2. [ui/src/pages/CommandsPage.tsx](ui/src/pages/CommandsPage.tsx)
3. [ui/src/pages/DashboardPage.tsx](ui/src/pages/DashboardPage.tsx)
4. [ui/src/api.ts](ui/src/api.ts)
5. [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs)
6. [wakey-control-plane/src/api/audit.rs](wakey-control-plane/src/api/audit.rs)
7. [wakey-control-plane/src/api/alerts.rs](wakey-control-plane/src/api/alerts.rs)
8. [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs)
9. [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs)
10. [README.md](README.md)
## Immediate Next Build Slice
1. Implement DevicesPage and make it default route.
2. Add row-level wake action with in-place result feedback.
3. Add Recent Wake Actions section backed by local state first, then audit correlation.
4. Rebalance navigation labels/order to make device workflows primary.
+12
View File
@@ -17,6 +17,7 @@ import { AlertsPage } from "@/pages/AlertsPage";
import { AuditPage } from "@/pages/AuditPage"; import { AuditPage } from "@/pages/AuditPage";
import { CommandsPage } from "@/pages/CommandsPage"; import { CommandsPage } from "@/pages/CommandsPage";
import { DashboardPage } from "@/pages/DashboardPage"; import { DashboardPage } from "@/pages/DashboardPage";
import { DevicesPage } from "@/pages/DevicesPage";
import { TokensPage } from "@/pages/TokensPage"; import { TokensPage } from "@/pages/TokensPage";
type LoadState = "idle" | "loading" | "ready" | "error"; type LoadState = "idle" | "loading" | "ready" | "error";
@@ -99,6 +100,17 @@ export function App() {
<Route path="/" element={<AppLayout />}> <Route path="/" element={<AppLayout />}>
<Route <Route
index index
element={
<DevicesPage
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onAfterWake={loadAll}
/>
}
/>
<Route
path="dashboard"
element={ element={
<DashboardPage <DashboardPage
agents={agents} agents={agents}
+4 -3
View File
@@ -1,9 +1,10 @@
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
const navItems = [ const navItems = [
["/", "Dashboard"], ["/", "Devices"],
["/commands", "Wake Tools"],
["/dashboard", "Fleet Health"],
["/agents", "Agents"], ["/agents", "Agents"],
["/commands", "Commands"],
["/audit", "Audit"], ["/audit", "Audit"],
["/alerts", "Alerts"], ["/alerts", "Alerts"],
["/tokens", "Tokens"], ["/tokens", "Tokens"],
@@ -15,7 +16,7 @@ export function AppLayout() {
<header className="topbar"> <header className="topbar">
<div> <div>
<h1>Wakey Operator UI</h1> <h1>Wakey Operator UI</h1>
<p>Control-plane operations, audits, and live alerts</p> <p>Find devices fast and wake them reliably</p>
</div> </div>
</header> </header>
+313
View File
@@ -0,0 +1,313 @@
import { useEffect, useMemo, useState } from "react";
import { runCommand, type Agent } from "@/api";
type Props = {
agents: Agent[];
selectedAgentId: string;
onSelectAgent: (agentId: string) => void;
onAfterWake?: () => Promise<void>;
};
type DeviceRow = {
id: string;
name: string;
ips: string[];
macs: string[];
interfaces: string[];
presence: string;
};
type WakeEvent = {
ts: number;
target: string;
agentId: string;
outcome: string;
requestId: string;
detail: string;
};
const WAKE_HISTORY_KEY = "wakey_recent_wakes_v1";
function parseInventoryRows(payload: unknown): DeviceRow[] {
if (!payload || typeof payload !== "object") return [];
const obj = payload as Record<string, unknown>;
if (obj.kind !== "inventory") return [];
if (!obj.devices || !Array.isArray(obj.devices)) return [];
return obj.devices
.map((raw, idx) => {
if (!raw || typeof raw !== "object") return null;
const device = raw as Record<string, unknown>;
const names = Array.isArray(device.names) ? device.names.filter((v) => typeof v === "string") as string[] : [];
const ips = Array.isArray(device.ips) ? device.ips.filter((v) => typeof v === "string") as string[] : [];
const macs = Array.isArray(device.macs) ? device.macs.filter((v) => typeof v === "string") as string[] : [];
const interfaces = Array.isArray(device.interfaces)
? device.interfaces.filter((v) => typeof v === "string") as string[]
: [];
const presence = typeof device.presence === "string" ? device.presence : "unknown";
const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return {
id,
name: names[0] || "(unnamed)",
ips,
macs,
interfaces,
presence,
};
})
.filter((v): v is DeviceRow => Boolean(v));
}
function parseWakeSummary(response: unknown): { outcome: string; requestId: string; detail: string } {
if (!response || typeof response !== "object") {
return { outcome: "error", requestId: "", detail: "invalid wake response" };
}
const envelope = response as Record<string, unknown>;
const requestId = typeof envelope.request_id === "string" ? envelope.request_id : "";
const status = typeof envelope.status === "string" ? envelope.status : "error";
if (status !== "ok") {
const error = envelope.error as Record<string, unknown> | undefined;
const detail = typeof error?.message === "string" ? error.message : "wake failed";
return { outcome: "error", requestId, detail };
}
const result = envelope.result as Record<string, unknown> | undefined;
if (result?.kind !== "wake") {
return { outcome: "ok", requestId, detail: "wake dispatched" };
}
const entries = Array.isArray(result.result) ? result.result : [];
const statuses = entries
.map((row) => {
if (!row || typeof row !== "object") return "";
const statusRow = (row as Record<string, unknown>).status as Record<string, unknown> | undefined;
return typeof statusRow?.kind === "string" ? statusRow.kind : "";
})
.filter(Boolean);
const detail = statuses.length ? statuses.join(", ") : "wake dispatched";
const failed = statuses.some((s) => s === "error" || s === "incomplete");
return { outcome: failed ? "error" : "ok", requestId, detail };
}
function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)"
? device.name
: (device.macs[0] || device.ips[0] || "");
}
function loadHistory(): WakeEvent[] {
try {
const raw = window.localStorage.getItem(WAKE_HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((row): row is WakeEvent => {
if (!row || typeof row !== "object") return false;
const event = row as Record<string, unknown>;
return (
typeof event.ts === "number"
&& typeof event.target === "string"
&& typeof event.agentId === "string"
&& typeof event.outcome === "string"
&& typeof event.requestId === "string"
&& typeof event.detail === "string"
);
});
} catch {
return [];
}
}
function saveHistory(history: WakeEvent[]) {
window.localStorage.setItem(WAKE_HISTORY_KEY, JSON.stringify(history.slice(0, 20)));
}
export function DevicesPage({ agents, selectedAgentId, onSelectAgent, onAfterWake }: Props) {
const [rows, setRows] = useState<DeviceRow[]>([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [wakeBusyId, setWakeBusyId] = useState("");
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
useEffect(() => {
setRecentWakes(loadHistory());
}, []);
useEffect(() => {
if (!selectedAgentId && agents[0]) {
onSelectAgent(agents[0].agent_id);
}
}, [selectedAgentId, agents, onSelectAgent]);
async function loadInventory() {
if (!selectedAgentId) return;
setLoading(true);
setError("");
try {
const response = await runCommand(selectedAgentId, "inventory", "");
if (!response || typeof response !== "object") {
setRows([]);
} else {
const envelope = response as Record<string, unknown>;
setRows(parseInventoryRows(envelope.result));
}
} catch (err) {
setError(String(err));
setRows([]);
} finally {
setLoading(false);
}
}
async function wakeDevice(device: DeviceRow) {
const target = chooseWakeTarget(device);
if (!target || !selectedAgentId) return;
setWakeBusyId(device.id);
try {
const response = await runCommand(selectedAgentId, "wake", target);
const summary = parseWakeSummary(response);
const next: WakeEvent[] = [
{
ts: Date.now(),
target,
agentId: selectedAgentId,
outcome: summary.outcome,
requestId: summary.requestId,
detail: summary.detail,
},
...recentWakes,
].slice(0, 20);
setRecentWakes(next);
saveHistory(next);
await loadInventory();
if (onAfterWake) await onAfterWake();
} catch (err) {
const next: WakeEvent[] = [
{
ts: Date.now(),
target,
agentId: selectedAgentId,
outcome: "error",
requestId: "",
detail: String(err),
},
...recentWakes,
].slice(0, 20);
setRecentWakes(next);
saveHistory(next);
} finally {
setWakeBusyId("");
}
}
useEffect(() => {
void loadInventory();
}, [selectedAgentId]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) =>
row.name.toLowerCase().includes(q)
|| row.presence.toLowerCase().includes(q)
|| row.ips.some((v) => v.toLowerCase().includes(q))
|| row.macs.some((v) => v.toLowerCase().includes(q))
|| row.interfaces.some((v) => v.toLowerCase().includes(q)),
);
}, [rows, query]);
return (
<section className="two-col">
<div className="card">
<div className="row-head">
<h2>Devices</h2>
<button onClick={() => void loadInventory()} disabled={loading || !selectedAgentId}>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
<div className="grid-3 compact">
<label>
Agent
<select
value={selectedAgentId}
onChange={(e) => onSelectAgent(e.target.value)}
required
>
<option value="" disabled>Select agent</option>
{agents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{agent.agent_id} ({agent.connected ? "connected" : "offline"})
</option>
))}
</select>
</label>
<label className="span-2">
Search
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="name, ip, mac, interface"
/>
</label>
</div>
<p className="muted">Showing {filtered.length} of {rows.length}</p>
{error && <pre className="error">{error}</pre>}
<div className="list device-list">
{filtered.map((row) => (
<div className="row plain device-row" key={row.id}>
<div className="device-main">
<strong>{row.name}</strong>
<div className="muted">{row.ips.join(", ") || "-"}</div>
<div className="muted">{row.macs.join(", ") || "-"}</div>
</div>
<div className="device-meta">
<span>{row.interfaces.join(", ") || "-"}</span>
<span className="pill">{row.presence}</span>
<button onClick={() => void wakeDevice(row)} disabled={wakeBusyId === row.id || !selectedAgentId}>
{wakeBusyId === row.id ? "Waking..." : "Wake"}
</button>
</div>
</div>
))}
{!filtered.length && <div className="empty">No devices found</div>}
</div>
</div>
<div className="card">
<div className="row-head">
<h2>Recent Wake Actions</h2>
<button
onClick={() => {
setRecentWakes([]);
saveHistory([]);
}}
>
Clear
</button>
</div>
<div className="list">
{recentWakes.map((event, idx) => (
<div className="row plain" key={`${event.ts}-${event.target}-${idx}`}>
<div>
<strong>{event.target}</strong>
<div className="muted">{new Date(event.ts).toLocaleString()} on {event.agentId}</div>
<div className="muted">{event.detail}</div>
</div>
<span className={`pill ${event.outcome === "ok" ? "ready" : "error"}`}>
{event.outcome}
</span>
</div>
))}
{!recentWakes.length && <div className="empty">No wake actions yet</div>}
</div>
</div>
</section>
);
}
+34
View File
@@ -142,6 +142,40 @@ input, select { width: 100%; padding: 0.45rem 0.55rem; }
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
} }
.device-list {
margin-top: 0.8rem;
}
.device-row {
align-items: stretch;
}
.device-main {
min-width: 0;
}
.device-main strong {
display: inline-block;
margin-bottom: 0.25rem;
}
.device-meta {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 720px) {
.device-row {
flex-direction: column;
}
.device-meta {
justify-content: flex-start;
}
}
.empty { color: var(--muted); padding: 0.35rem 0.2rem; } .empty { color: var(--muted); padding: 0.35rem 0.2rem; }
.output { .output {