demo a thing WEB UI IS BACK

This commit is contained in:
lda
2026-04-12 02:00:01 +07:00 Unverified
parent e23617bd90
commit ea611d5135
7 changed files with 336 additions and 0 deletions
Generated
+33
View File
@@ -812,6 +812,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -1234,6 +1240,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2512,14 +2528,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -2684,6 +2710,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -2830,6 +2862,7 @@ dependencies = [
"sled", "sled",
"tokio", "tokio",
"toml", "toml",
"tower-http",
"tracing", "tracing",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
+8
View File
@@ -224,6 +224,14 @@ Control-plane routing is organized with the same boundary in code:
This keeps edge policy and app routing aligned as features grow. This keeps edge policy and app routing aligned as features grow.
## UI (Initial Shell)
Control-plane now serves a minimal operator shell at `/ui/` for immediate
workflow testing (agents, command runner, alerts, and recent audits).
This UI is intentionally lightweight and is the starting point for the richer
Operator UI v1 plan.
## CLI ## CLI
`wakey` is usable as a local/operator CLI. `wakey` is usable as a local/operator CLI.
+126
View File
@@ -0,0 +1,126 @@
const $ = (id) => document.getElementById(id);
function setStatus(kind, text) {
const pill = $("status-pill");
pill.className = `pill ${kind}`;
pill.textContent = text;
}
async function api(path, init) {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...init,
});
if (!res.ok) {
let body = "";
try { body = JSON.stringify(await res.json(), null, 2); } catch (_) {}
throw new Error(`${res.status} ${res.statusText}\n${body}`);
}
return res.json();
}
function renderAgents(agents) {
const root = $("agents");
root.innerHTML = "";
if (!agents.length) {
root.innerHTML = '<div class="item">No agents enrolled yet</div>';
return;
}
for (const a of agents) {
const row = document.createElement("div");
row.className = "item";
row.innerHTML = `<span>${a.agent_id}</span><span>${a.connected ? "connected" : "offline"}</span>`;
row.onclick = () => { $("agent-id").value = a.agent_id; };
root.appendChild(row);
}
}
function renderAlerts(alerts) {
const root = $("alerts");
root.innerHTML = "";
if (!alerts.length) {
root.innerHTML = '<div class="item">No active alerts</div>';
return;
}
for (const a of alerts) {
const row = document.createElement("div");
row.className = "item";
row.innerHTML = `<span>${a.kind}${a.agent_id ? ` (${a.agent_id})` : ""}</span><span>${a.severity}</span>`;
root.appendChild(row);
}
}
async function loadAgents() {
const agents = await api("/api/v1/control/agents");
renderAgents(agents);
return agents;
}
async function loadAlerts() {
const alerts = await api("/api/v1/control/alerts");
renderAlerts(alerts);
return alerts;
}
async function loadAudit() {
const events = await api("/api/v1/control/audit/events?limit=25");
$("audit").textContent = JSON.stringify(events, null, 2);
}
function buildCommandPayload(kind, query) {
if (kind === "devs") {
return { command: { kind: "devs", dev: null, up_only: false } };
}
if (kind === "status") {
return { command: { kind: "status", query: query || null, name: null, ips: [], devs: [], nuds: [], macs: [] } };
}
if (kind === "leases") {
return { command: { kind: "leases", include_state: true } };
}
if (kind === "inventory") {
return { command: { kind: "inventory", query: query || null, name: null, ips: [], devs: [], nuds: [], macs: [] } };
}
return { command: { kind: "wake", query: query || null, mac: null, ip: null } };
}
async function runCommand(evt) {
evt.preventDefault();
const agentId = $("agent-id").value.trim();
const kind = $("command-kind").value;
const query = $("command-query").value.trim();
if (!agentId) return;
const payload = buildCommandPayload(kind, query);
const out = $("command-result");
out.textContent = "Running...";
try {
const result = await api(`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`, {
method: "POST",
body: JSON.stringify(payload),
});
out.textContent = JSON.stringify(result, null, 2);
setStatus("ok", "Command OK");
await loadAudit();
} catch (err) {
out.textContent = String(err);
setStatus("bad", "Command Failed");
}
}
async function bootstrap() {
try {
setStatus("warn", "Loading");
await Promise.all([loadAgents(), loadAlerts(), loadAudit()]);
setStatus("ok", "Ready");
} catch (err) {
setStatus("bad", "API Error");
$("audit").textContent = String(err);
}
}
$("refresh-agents").onclick = () => loadAgents().catch((e) => { setStatus("bad", "Agent Error"); console.error(e); });
$("refresh-alerts").onclick = () => loadAlerts().catch((e) => { setStatus("bad", "Alerts Error"); console.error(e); });
$("refresh-audit").onclick = () => loadAudit().catch((e) => { setStatus("bad", "Audit Error"); console.error(e); });
$("command-form").onsubmit = runCommand;
bootstrap();
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Wakey Control UI</title>
<link rel="stylesheet" href="/ui/styles.css" />
</head>
<body>
<header class="topbar">
<h1>Wakey Control UI</h1>
<div id="status-pill" class="pill">Loading</div>
</header>
<main class="layout">
<section class="panel">
<h2>Agents</h2>
<button id="refresh-agents" class="btn">Refresh</button>
<div id="agents" class="list"></div>
</section>
<section class="panel">
<h2>Command Runner</h2>
<form id="command-form" class="stack">
<label>
Agent ID
<input id="agent-id" name="agent_id" type="text" required />
</label>
<label>
Command
<select id="command-kind" name="kind">
<option value="devs">devs</option>
<option value="status">status</option>
<option value="leases">leases</option>
<option value="inventory">inventory</option>
<option value="wake">wake</option>
</select>
</label>
<label>
Query (status/inventory/wake)
<input id="command-query" name="query" type="text" />
</label>
<button class="btn" type="submit">Run</button>
</form>
<pre id="command-result" class="code"></pre>
</section>
<section class="panel">
<h2>Active Alerts</h2>
<button id="refresh-alerts" class="btn">Refresh</button>
<div id="alerts" class="list"></div>
</section>
<section class="panel wide">
<h2>Recent Audit Events</h2>
<button id="refresh-audit" class="btn">Refresh</button>
<pre id="audit" class="code"></pre>
</section>
</main>
<script src="/ui/app.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
:root {
--bg: #0f172a;
--card: #111827;
--line: #334155;
--text: #e5e7eb;
--muted: #94a3b8;
--ok: #16a34a;
--warn: #d97706;
--bad: #dc2626;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
background: radial-gradient(circle at 15% 15%, #1f2937 0%, var(--bg) 45%);
color: var(--text);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background: rgba(15, 23, 42, 0.85);
}
.layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
padding: 1rem;
}
.panel {
background: linear-gradient(180deg, rgba(17,24,39,0.95), rgba(17,24,39,0.7));
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.9rem;
}
.panel.wide { grid-column: 1 / -1; }
.btn {
border: 1px solid var(--line);
background: #1e293b;
color: var(--text);
padding: 0.45rem 0.7rem;
border-radius: 8px;
cursor: pointer;
}
.btn:hover { border-color: #64748b; }
.stack { display: grid; gap: 0.7rem; }
label { display: grid; gap: 0.35rem; font-size: 0.9rem; color: var(--muted); }
input, select {
width: 100%;
padding: 0.45rem 0.55rem;
color: var(--text);
background: #0b1220;
border: 1px solid var(--line);
border-radius: 8px;
}
.list { display: grid; gap: 0.4rem; margin-top: 0.7rem; }
.item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.45rem 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.code {
margin-top: 0.7rem;
background: #020617;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.7rem;
min-height: 120px;
overflow: auto;
}
.pill {
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.82rem;
border: 1px solid var(--line);
color: var(--muted);
}
.pill.ok { color: #86efac; border-color: #14532d; }
.pill.warn { color: #fcd34d; border-color: #78350f; }
.pill.bad { color: #fca5a5; border-color: #7f1d1d; }
+1
View File
@@ -22,6 +22,7 @@ tokio = { version = "1", features = [
"time", "time",
"signal", "signal",
] } ] }
tower-http = { version = "0.6.8", features = ["fs"] }
tracing = "0.1" tracing = "0.1"
tracing-opentelemetry = "0.32" tracing-opentelemetry = "0.32"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
+5
View File
@@ -4,10 +4,13 @@ use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::Router; use axum::Router;
use axum::response::Redirect;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::routing::get_service;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
use tokio::time::MissedTickBehavior; use tokio::time::MissedTickBehavior;
use tower_http::services::ServeDir;
use tracing::{info, warn}; use tracing::{info, warn};
use wakey_agent::protocol::{ErrorPayload, ServerMessage}; use wakey_agent::protocol::{ErrorPayload, ServerMessage};
@@ -42,6 +45,8 @@ pub enum AgentReply {
fn public_api_routes() -> Router<AppState> { fn public_api_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/ui", get(|| async { Redirect::temporary("/ui/") }))
.nest_service("/ui/", get_service(ServeDir::new("ui")))
.route("/healthz", get(api::healthz)) .route("/healthz", get(api::healthz))
.route("/api/v1/agents/enroll", post(api::enroll)) .route("/api/v1/agents/enroll", post(api::enroll))
.route("/api/v1/agent/ws", get(ws::agent_ws)) .route("/api/v1/agent/ws", get(ws::agent_ws))