demo a thing WEB UI IS BACK
This commit is contained in:
Generated
+33
@@ -812,6 +812,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1234,6 +1240,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2512,14 +2528,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2684,6 +2710,12 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -2830,6 +2862,7 @@ dependencies = [
|
||||
"sled",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
`wakey` is usable as a local/operator CLI.
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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; }
|
||||
@@ -22,6 +22,7 @@ tokio = { version = "1", features = [
|
||||
"time",
|
||||
"signal",
|
||||
] }
|
||||
tower-http = { version = "0.6.8", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-opentelemetry = "0.32"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
|
||||
|
||||
@@ -4,10 +4,13 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::get_service;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
|
||||
use tokio::time::MissedTickBehavior;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::{info, warn};
|
||||
use wakey_agent::protocol::{ErrorPayload, ServerMessage};
|
||||
|
||||
@@ -42,6 +45,8 @@ pub enum AgentReply {
|
||||
|
||||
fn public_api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/ui", get(|| async { Redirect::temporary("/ui/") }))
|
||||
.nest_service("/ui/", get_service(ServeDir::new("ui")))
|
||||
.route("/healthz", get(api::healthz))
|
||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
||||
|
||||
Reference in New Issue
Block a user