helmd hub: central server på Pi5 + agent-uplink + helmd ctl + web-login
Hub-läge i samma binär (doc/hub-plan.md): agenter ansluter utåt med delad agent-nyckel (hub-sektion i config.json), web-/CLI-klienter loggar in (lokala konton, pbkdf2; Authenticator-interface för LDAP senare) och styr alla agenters sessioner via proxy + merged SSE. Webbklienten autodetekterar hub/local via /api/mode. Nya kommandon: helmd hub [setpass|users|agentkey], helmd ctl (login/agents/sessions/ prompt/screen/answer/events …). Enhetstester + scripts/run-hub-smoke.sh (15-stegs e2e: hub + agent + ctl). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
This commit is contained in:
172
helmd/web/app.js
172
helmd/web/app.js
@@ -1,7 +1,12 @@
|
||||
import { ansiToHtml } from "./ansi.js";
|
||||
|
||||
// ---------- state ----------
|
||||
// Samma SPA för båda lägena: "local" (en helmd, token-login) och
|
||||
// "hub" (Pi5-hubben: konto-login + agentväljare). GET /api/mode avgör.
|
||||
let mode = "local";
|
||||
let token = localStorage.getItem("helmd_token") || "";
|
||||
let agents = [];
|
||||
let agent = localStorage.getItem("helmd_agent") || "";
|
||||
let sessions = [];
|
||||
let current = null; // valt sessionsnamn
|
||||
let currentSeq = -1;
|
||||
@@ -12,11 +17,15 @@ let pollTimer = null;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// base är prefixet för allt agent-scopat (sessions, shares, config)
|
||||
const base = () => (mode === "hub" ? `/api/agents/${encodeURIComponent(agent)}` : "/api");
|
||||
const hasAgent = () => mode !== "hub" || !!agent;
|
||||
|
||||
// ---------- api ----------
|
||||
async function api(path, opts = {}) {
|
||||
opts.headers = Object.assign({ Authorization: "Bearer " + token }, opts.headers);
|
||||
const r = await fetch(path, opts);
|
||||
if (r.status === 401) { showLogin("Ogiltig token."); throw new Error("401"); }
|
||||
if (r.status === 401) { showLogin(mode === "hub" ? "Inte inloggad." : "Ogiltig token."); throw new Error("401"); }
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try { msg = (await r.json()).error || msg; } catch {}
|
||||
@@ -28,6 +37,17 @@ async function api(path, opts = {}) {
|
||||
const post = (p, body) => api(p, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
// ---------- login ----------
|
||||
function applyModeUI() {
|
||||
const hub = mode === "hub";
|
||||
$("userInput").classList.toggle("hidden", !hub);
|
||||
$("tokenInput").placeholder = hub ? "lösenord" : "token";
|
||||
$("loginHint").innerHTML = hub
|
||||
? "Logga in med ditt hub-konto."
|
||||
: "Klistra in API-token från servern (<code>helmd token</code>).";
|
||||
$("agentBar").classList.toggle("hidden", !hub);
|
||||
$("hubSettings").classList.toggle("hidden", !hub);
|
||||
}
|
||||
|
||||
function showLogin(err = "") {
|
||||
stopLive();
|
||||
$("login").classList.remove("hidden");
|
||||
@@ -37,25 +57,91 @@ function showLogin(err = "") {
|
||||
|
||||
async function tryConnect() {
|
||||
try {
|
||||
if (mode === "hub") await refreshAgents();
|
||||
const h = await api("/api/health");
|
||||
$("login").classList.add("hidden");
|
||||
$("app").classList.remove("hidden");
|
||||
$("verInfo").textContent = "helmd " + h.version;
|
||||
$("verInfo").textContent = (mode === "hub" ? "hub " : "helmd ") + h.version;
|
||||
startLive();
|
||||
await Promise.all([refreshSessions(), refreshShares(), loadConfig()]);
|
||||
if (!current && sessions.length) selectSession(sessions[0].name);
|
||||
if (mode === "hub") {
|
||||
if (!agent || !agents.some((a) => a.name === agent)) {
|
||||
const first = agents.find((a) => a.online) || agents[0];
|
||||
agent = first ? first.name : "";
|
||||
}
|
||||
renderAgentSelect();
|
||||
if (agent) await switchAgent(agent, true);
|
||||
} else {
|
||||
await Promise.all([refreshSessions(), refreshShares(), loadConfig()]);
|
||||
if (!current && sessions.length) selectSession(sessions[0].name);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message !== "401") showLogin("Kunde inte nå servern: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
$("tokenSave").onclick = () => {
|
||||
token = $("tokenInput").value.trim();
|
||||
$("tokenSave").onclick = async () => {
|
||||
if (mode === "hub") {
|
||||
try {
|
||||
const r = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: $("userInput").value.trim(), password: $("tokenInput").value }),
|
||||
});
|
||||
if (!r.ok) { $("loginError").textContent = "Fel användarnamn eller lösenord."; return; }
|
||||
token = (await r.json()).token;
|
||||
} catch (e) { $("loginError").textContent = "Kunde inte nå hubben: " + e.message; return; }
|
||||
} else {
|
||||
token = $("tokenInput").value.trim();
|
||||
}
|
||||
localStorage.setItem("helmd_token", token);
|
||||
$("tokenInput").value = "";
|
||||
tryConnect();
|
||||
};
|
||||
$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); });
|
||||
$("logoutBtn").onclick = () => { localStorage.removeItem("helmd_token"); token = ""; showLogin(); };
|
||||
$("logoutBtn").onclick = async () => {
|
||||
if (mode === "hub") { try { await post("/api/logout", {}); } catch {} }
|
||||
localStorage.removeItem("helmd_token");
|
||||
token = "";
|
||||
showLogin();
|
||||
};
|
||||
|
||||
// ---------- agenter (hub-läge) ----------
|
||||
async function refreshAgents() {
|
||||
agents = (await api("/api/agents")) || [];
|
||||
agents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
renderAgentSelect();
|
||||
}
|
||||
|
||||
function renderAgentSelect() {
|
||||
const sel = $("agentSelect");
|
||||
sel.innerHTML = "";
|
||||
for (const a of agents) {
|
||||
const o = document.createElement("option");
|
||||
o.value = a.name;
|
||||
o.textContent = (a.online ? "● " : "○ ") + a.name;
|
||||
if (a.name === agent) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (!agents.length) {
|
||||
const o = document.createElement("option");
|
||||
o.textContent = "(inga agenter anslutna)";
|
||||
o.disabled = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAgent(name, force = false) {
|
||||
if (!force && name === agent) return;
|
||||
agent = name;
|
||||
localStorage.setItem("helmd_agent", agent);
|
||||
current = null; currentSeq = -1; currentShare = null;
|
||||
$("sessPane").classList.add("hidden");
|
||||
$("noSess").classList.remove("hidden");
|
||||
$("shareView").innerHTML = "Välj en delning i listan";
|
||||
await Promise.all([refreshSessions(), refreshShares(), loadConfig().catch(() => {})]);
|
||||
if (!current && sessions.length) selectSession(sessions[0].name);
|
||||
}
|
||||
|
||||
$("agentSelect").onchange = (e) => switchAgent(e.target.value);
|
||||
|
||||
// ---------- tabs ----------
|
||||
document.querySelectorAll(".tab").forEach((b) => {
|
||||
@@ -68,22 +154,32 @@ document.querySelectorAll(".tab").forEach((b) => {
|
||||
});
|
||||
|
||||
// ---------- SSE + poll ----------
|
||||
// I hub-läge har alla events fältet "agent" — vi bryr oss bara om den
|
||||
// valda agentens events, plus "agent"-events som uppdaterar väljaren.
|
||||
function startLive() {
|
||||
stopLive();
|
||||
es = new EventSource("/api/events?token=" + encodeURIComponent(token));
|
||||
es.onopen = () => $("connDot").classList.add("ok");
|
||||
es.onerror = () => $("connDot").classList.remove("ok");
|
||||
const mine = (ev) => mode !== "hub" || ev.agent === agent;
|
||||
es.addEventListener("screen", (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (!mine(ev)) return;
|
||||
if (ev.session === current) refreshScreen();
|
||||
refreshSessions();
|
||||
});
|
||||
es.addEventListener("question", (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.session === current) renderQuestion(ev.data);
|
||||
if (mine(ev) && ev.session === current) renderQuestion(ev.data);
|
||||
});
|
||||
es.addEventListener("session", () => refreshSessions());
|
||||
es.addEventListener("session", (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (mine(ev)) refreshSessions();
|
||||
});
|
||||
es.addEventListener("agent", () => refreshAgents());
|
||||
es.addEventListener("share", (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (!mine(ev)) return;
|
||||
refreshShares();
|
||||
const b = $("shareBadge");
|
||||
if ($("tab-shares").classList.contains("hidden")) {
|
||||
@@ -105,7 +201,8 @@ function stopLive() {
|
||||
|
||||
// ---------- sessioner ----------
|
||||
async function refreshSessions() {
|
||||
sessions = (await api("/api/sessions")) || [];
|
||||
if (!hasAgent()) { $("sessItems").innerHTML = ""; return; }
|
||||
sessions = (await api(base() + "/sessions")) || [];
|
||||
sessions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const box = $("sessItems");
|
||||
box.innerHTML = "";
|
||||
@@ -136,16 +233,16 @@ async function selectSession(name) {
|
||||
}
|
||||
|
||||
async function refreshScreen() {
|
||||
if (!current) return;
|
||||
if (!current || !hasAgent()) return;
|
||||
try {
|
||||
const s = sessions.find((x) => x.name === current);
|
||||
if (s) currentSeq = s.seq;
|
||||
const txt = await api(`/api/sessions/${current}/screen?ansi=1`);
|
||||
const txt = await api(`${base()}/sessions/${current}/screen?ansi=1`);
|
||||
const el = $("screen");
|
||||
const stick = el.scrollTop + el.clientHeight >= el.scrollHeight - 30;
|
||||
el.innerHTML = ansiToHtml(txt);
|
||||
if (stick) el.scrollTop = el.scrollHeight;
|
||||
const st = await api(`/api/sessions/${current}`);
|
||||
const st = await api(`${base()}/sessions/${current}`);
|
||||
renderQuestion(st.question);
|
||||
} catch {}
|
||||
}
|
||||
@@ -162,7 +259,7 @@ function renderQuestion(q) {
|
||||
if (i === q.selected) b.classList.add("selected");
|
||||
b.onclick = async () => {
|
||||
b.disabled = true;
|
||||
try { await post(`/api/sessions/${current}/answer`, { option: i + 1 }); }
|
||||
try { await post(`${base()}/sessions/${current}/answer`, { option: i + 1 }); }
|
||||
catch (e) { alert("Kunde inte svara: " + e.message); b.disabled = false; }
|
||||
};
|
||||
opts.appendChild(b);
|
||||
@@ -173,42 +270,44 @@ $("newSessBtn").onclick = () => $("newSessForm").classList.toggle("hidden");
|
||||
$("newSessCancel").onclick = () => $("newSessForm").classList.add("hidden");
|
||||
$("newSessForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!hasAgent()) { alert("Ingen agent vald."); return; }
|
||||
try {
|
||||
await post("/api/sessions", {
|
||||
await post(base() + "/sessions", {
|
||||
name: $("newSessName").value.trim(),
|
||||
cmd: $("newSessCmd").value.trim(),
|
||||
cwd: $("newSessCwd").value.trim(),
|
||||
});
|
||||
const name = $("newSessName").value.trim();
|
||||
$("newSessForm").reset();
|
||||
$("newSessForm").classList.add("hidden");
|
||||
await refreshSessions();
|
||||
selectSession($("newSessName").value.trim() || sessions[sessions.length - 1]?.name);
|
||||
selectSession(name || sessions[sessions.length - 1]?.name);
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
$("killBtn").onclick = async () => {
|
||||
if (!current || !confirm(`Döda sessionen ${current}?`)) return;
|
||||
await api(`/api/sessions/${current}`, { method: "DELETE" });
|
||||
await api(`${base()}/sessions/${current}`, { method: "DELETE" });
|
||||
current = null;
|
||||
refreshSessions();
|
||||
$("sessPane").classList.add("hidden");
|
||||
$("noSess").classList.remove("hidden");
|
||||
};
|
||||
|
||||
const sendKeys = (keys) => current && post(`/api/sessions/${current}/keys`, { keys }).catch(() => {});
|
||||
const sendKeys = (keys) => current && post(`${base()}/sessions/${current}/keys`, { keys }).catch(() => {});
|
||||
$("keyEsc").onclick = () => sendKeys(["Escape"]);
|
||||
$("keyUp").onclick = () => sendKeys(["Up"]);
|
||||
$("keyDown").onclick = () => sendKeys(["Down"]);
|
||||
$("keyEnter").onclick = () => sendKeys(["Enter"]);
|
||||
$("keyCtrlC").onclick = () => sendKeys(["C-c"]);
|
||||
$("modeBtn").onclick = () => current && post(`/api/sessions/${current}/mode`, { action: "cycle" }).catch(() => {});
|
||||
$("modeBtn").onclick = () => current && post(`${base()}/sessions/${current}/mode`, { action: "cycle" }).catch(() => {});
|
||||
|
||||
$("promptForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const t = $("promptText").value;
|
||||
if (!t.trim() || !current) return;
|
||||
try {
|
||||
await post(`/api/sessions/${current}/prompt`, { text: t });
|
||||
await post(`${base()}/sessions/${current}/prompt`, { text: t });
|
||||
$("promptText").value = "";
|
||||
} catch (err) { alert("Kunde inte skicka: " + err.message); }
|
||||
};
|
||||
@@ -221,7 +320,8 @@ $("promptText").addEventListener("keydown", (e) => {
|
||||
|
||||
// ---------- delningar ----------
|
||||
async function refreshShares() {
|
||||
shares = (await api("/api/shares")) || [];
|
||||
if (!hasAgent()) { $("shareItems").innerHTML = ""; return; }
|
||||
shares = (await api(base() + "/shares")) || [];
|
||||
const box = $("shareItems");
|
||||
box.innerHTML = "";
|
||||
for (const sh of shares) {
|
||||
@@ -233,7 +333,7 @@ async function refreshShares() {
|
||||
<div class="meta">${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "<br>" + escapeHtml(sh.note) : ""}</div>`;
|
||||
d.querySelector(".del").onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
await api(`/api/shares/${sh.id}`, { method: "DELETE" });
|
||||
await api(`${base()}/shares/${sh.id}`, { method: "DELETE" });
|
||||
if (currentShare === sh.id) { currentShare = null; $("shareView").innerHTML = "Välj en delning i listan"; }
|
||||
refreshShares();
|
||||
};
|
||||
@@ -247,24 +347,25 @@ async function viewShare(sh) {
|
||||
refreshShares();
|
||||
const v = $("shareView");
|
||||
v.classList.remove("empty");
|
||||
const raw = `/api/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`;
|
||||
const raw = `${base()}/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`;
|
||||
const ext = sh.name.toLowerCase().split(".").pop();
|
||||
if (["png", "jpg", "jpeg", "gif", "svg"].includes(ext)) {
|
||||
v.innerHTML = `<img src="${raw}" alt="${escapeHtml(sh.name)}">`;
|
||||
} else if (["html", "htm", "pdf"].includes(ext)) {
|
||||
v.innerHTML = `<iframe src="${raw}" sandbox="allow-scripts"></iframe>`;
|
||||
} else if (ext === "md") {
|
||||
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||
const txt = await api(`${base()}/shares/${sh.id}/raw`);
|
||||
v.innerHTML = `<div class="md">${mdToHtml(txt)}</div>`;
|
||||
} else {
|
||||
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||
const txt = await api(`${base()}/shares/${sh.id}/raw`);
|
||||
v.innerHTML = `<pre>${escapeHtml(txt)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- inställningar ----------
|
||||
async function loadConfig() {
|
||||
const c = await api("/api/config");
|
||||
if (!hasAgent()) return;
|
||||
const c = await api(base() + "/config");
|
||||
$("cfgCmd").value = c.agent_cmd || "";
|
||||
$("cfgArgs").value = (c.agent_args || []).join(" ");
|
||||
$("cfgWorkdir").value = c.workdir || "";
|
||||
@@ -279,7 +380,7 @@ async function loadConfig() {
|
||||
$("cfgForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api("/api/config", {
|
||||
await api(base() + "/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
agent_cmd: $("cfgCmd").value.trim(),
|
||||
@@ -300,6 +401,14 @@ $("cfgForm").onsubmit = async (e) => {
|
||||
} catch (err) { $("cfgStatus").textContent = "Fel: " + err.message; }
|
||||
};
|
||||
|
||||
// agent-nyckeln (hub-läge): visas på begäran för att koppla in nya agenter
|
||||
$("agentKeyBtn").onclick = async () => {
|
||||
try {
|
||||
const r = await api("/api/agentkey");
|
||||
$("agentKeyOut").textContent = r.agent_key;
|
||||
} catch (e) { $("agentKeyOut").textContent = "Fel: " + e.message; }
|
||||
};
|
||||
|
||||
// ---------- hjälp ----------
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
@@ -332,4 +441,11 @@ function mdToHtml(md) {
|
||||
}
|
||||
|
||||
// ---------- start ----------
|
||||
if (token) tryConnect(); else showLogin();
|
||||
(async () => {
|
||||
try {
|
||||
const m = await fetch("/api/mode");
|
||||
if (m.ok) mode = (await m.json()).mode || "local";
|
||||
} catch {}
|
||||
applyModeUI();
|
||||
if (token) tryConnect(); else showLogin();
|
||||
})();
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<div id="login" class="login hidden">
|
||||
<div class="login-card">
|
||||
<h1>⛵ agent-helm</h1>
|
||||
<p>Klistra in API-token från servern (<code>helmd token</code>).</p>
|
||||
<p id="loginHint">Klistra in API-token från servern (<code>helmd token</code>).</p>
|
||||
<input id="userInput" placeholder="användarnamn" autocomplete="username" class="hidden">
|
||||
<input id="tokenInput" type="password" placeholder="token" autocomplete="off">
|
||||
<button id="tokenSave" class="primary">Anslut</button>
|
||||
<p id="loginError" class="error"></p>
|
||||
@@ -33,6 +34,10 @@
|
||||
|
||||
<main id="tab-sessions" class="tab-page">
|
||||
<aside class="sess-list">
|
||||
<div id="agentBar" class="agent-bar hidden">
|
||||
<label for="agentSelect">Agent</label>
|
||||
<select id="agentSelect"></select>
|
||||
</div>
|
||||
<div class="sess-head">
|
||||
<span>Sessioner</span>
|
||||
<button id="newSessBtn" title="Ny session">+</button>
|
||||
@@ -96,9 +101,17 @@
|
||||
<button type="submit" class="primary">Spara</button>
|
||||
<span id="cfgStatus"></span>
|
||||
</div>
|
||||
<div id="hubSettings" class="hidden">
|
||||
<h2>Hub</h2>
|
||||
<p class="muted">Agent-nyckeln behövs i nya agenters <code>config.json</code> (<code>hub.key</code>).</p>
|
||||
<div class="row">
|
||||
<button type="button" id="agentKeyBtn">Visa agent-nyckel</button>
|
||||
<code id="agentKeyOut"></code>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Klient</h2>
|
||||
<div class="row">
|
||||
<button type="button" id="logoutBtn" class="danger">Glöm token (logga ut)</button>
|
||||
<button type="button" id="logoutBtn" class="danger">Logga ut</button>
|
||||
<span id="verInfo" class="muted"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -77,6 +77,14 @@ nav { display: flex; gap: 4px; flex: 1; }
|
||||
width: 240px; border-right: 1px solid var(--border); background: var(--bg2);
|
||||
display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow-y: auto;
|
||||
}
|
||||
.agent-bar { display: flex; flex-direction: column; gap: 4px; }
|
||||
.agent-bar label { color: var(--muted); font-size: 0.85rem; }
|
||||
.agent-bar select {
|
||||
background: var(--bg3); color: var(--fg); border: 1px solid var(--border);
|
||||
border-radius: 7px; padding: 7px 8px; font: inherit; width: 100%;
|
||||
}
|
||||
.agent-bar select:focus { outline: none; border-color: var(--accent); }
|
||||
#agentKeyOut { word-break: break-all; user-select: all; }
|
||||
.sess-head { display: flex; justify-content: space-between; align-items: center; color: var(--muted); }
|
||||
.sess-item {
|
||||
padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
|
||||
|
||||
Reference in New Issue
Block a user