helmd: inbäddad webbklient (vanilla SPA) + Docker-image för Pi5 (LAN-only)
- web/: token-login, sessionslista, ANSI-skärmvy (SSE+poll), promptfält, frågekort, tangentrad, delningsflik (bild/HTML-sandbox/markdown), inställningar - go:embed via webfs.go — servern förblir en enda binär, strikt CSP - browser-verifierad mot riktig agy (fråga -> kort -> svar -> kört) - Dockerfile (alpine + tmux/bash) + helmd-image.yaml -> localhost:5000/helmd - integrationstest utökat till 29 gröna Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
335
helmd/web/app.js
Normal file
335
helmd/web/app.js
Normal file
@@ -0,0 +1,335 @@
|
||||
import { ansiToHtml } from "./ansi.js";
|
||||
|
||||
// ---------- state ----------
|
||||
let token = localStorage.getItem("helmd_token") || "";
|
||||
let sessions = [];
|
||||
let current = null; // valt sessionsnamn
|
||||
let currentSeq = -1;
|
||||
let shares = [];
|
||||
let currentShare = null;
|
||||
let es = null; // EventSource
|
||||
let pollTimer = null;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ---------- 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.ok) {
|
||||
let msg = r.statusText;
|
||||
try { msg = (await r.json()).error || msg; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const ct = r.headers.get("content-type") || "";
|
||||
return ct.includes("json") ? r.json() : r.text();
|
||||
}
|
||||
const post = (p, body) => api(p, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
// ---------- login ----------
|
||||
function showLogin(err = "") {
|
||||
stopLive();
|
||||
$("login").classList.remove("hidden");
|
||||
$("app").classList.add("hidden");
|
||||
$("loginError").textContent = err;
|
||||
}
|
||||
|
||||
async function tryConnect() {
|
||||
try {
|
||||
const h = await api("/api/health");
|
||||
$("login").classList.add("hidden");
|
||||
$("app").classList.remove("hidden");
|
||||
$("verInfo").textContent = "helmd " + h.version;
|
||||
startLive();
|
||||
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();
|
||||
localStorage.setItem("helmd_token", token);
|
||||
tryConnect();
|
||||
};
|
||||
$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); });
|
||||
$("logoutBtn").onclick = () => { localStorage.removeItem("helmd_token"); token = ""; showLogin(); };
|
||||
|
||||
// ---------- tabs ----------
|
||||
document.querySelectorAll(".tab").forEach((b) => {
|
||||
b.onclick = () => {
|
||||
document.querySelectorAll(".tab").forEach((x) => x.classList.toggle("active", x === b));
|
||||
["sessions", "shares", "settings"].forEach((t) =>
|
||||
$("tab-" + t).classList.toggle("hidden", b.dataset.tab !== t));
|
||||
if (b.dataset.tab === "shares") { $("shareBadge").classList.add("hidden"); $("shareBadge").textContent = ""; }
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- SSE + poll ----------
|
||||
function startLive() {
|
||||
stopLive();
|
||||
es = new EventSource("/api/events?token=" + encodeURIComponent(token));
|
||||
es.onopen = () => $("connDot").classList.add("ok");
|
||||
es.onerror = () => $("connDot").classList.remove("ok");
|
||||
es.addEventListener("screen", (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.session === current) refreshScreen();
|
||||
refreshSessions();
|
||||
});
|
||||
es.addEventListener("question", (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.session === current) renderQuestion(ev.data);
|
||||
});
|
||||
es.addEventListener("session", () => refreshSessions());
|
||||
es.addEventListener("share", (e) => {
|
||||
refreshShares();
|
||||
const b = $("shareBadge");
|
||||
if ($("tab-shares").classList.contains("hidden")) {
|
||||
b.textContent = String(Number(b.textContent || 0) + 1);
|
||||
b.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
// fallback-poll om SSE tappas
|
||||
pollTimer = setInterval(() => {
|
||||
if (es && es.readyState === 2) { startLive(); return; }
|
||||
if (current) refreshScreen();
|
||||
}, 5000);
|
||||
}
|
||||
function stopLive() {
|
||||
if (es) { es.close(); es = null; }
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
$("connDot").classList.remove("ok");
|
||||
}
|
||||
|
||||
// ---------- sessioner ----------
|
||||
async function refreshSessions() {
|
||||
sessions = (await api("/api/sessions")) || [];
|
||||
sessions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const box = $("sessItems");
|
||||
box.innerHTML = "";
|
||||
for (const s of sessions) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "sess-item" + (s.name === current ? " active" : "");
|
||||
d.innerHTML = `<span class="dot ${s.alive ? "ok" : ""}"></span>
|
||||
<span>${s.name}</span>${s.question ? '<span class="q" title="väntar på svar">❓</span>' : ""}`;
|
||||
d.onclick = () => selectSession(s.name);
|
||||
box.appendChild(d);
|
||||
}
|
||||
if (current) {
|
||||
const cur = sessions.find((s) => s.name === current);
|
||||
if (!cur) { current = null; $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); }
|
||||
else if (cur.seq !== currentSeq) refreshScreen();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSession(name) {
|
||||
current = name; currentSeq = -1;
|
||||
$("noSess").classList.add("hidden");
|
||||
$("sessPane").classList.remove("hidden");
|
||||
$("sessTitle").textContent = name;
|
||||
const s = sessions.find((x) => x.name === name);
|
||||
renderQuestion(s && s.question);
|
||||
await refreshScreen();
|
||||
refreshSessions();
|
||||
}
|
||||
|
||||
async function refreshScreen() {
|
||||
if (!current) 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 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}`);
|
||||
renderQuestion(st.question);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function renderQuestion(q) {
|
||||
const card = $("questionCard");
|
||||
if (!q) { card.classList.add("hidden"); card.innerHTML = ""; return; }
|
||||
card.classList.remove("hidden");
|
||||
card.innerHTML = `<h3>❓ ${escapeHtml(q.text)}</h3><div class="opts"></div>`;
|
||||
const opts = card.querySelector(".opts");
|
||||
q.options.forEach((o, i) => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = (o.num ? o.num + ". " : "") + o.label;
|
||||
if (i === q.selected) b.classList.add("selected");
|
||||
b.onclick = async () => {
|
||||
b.disabled = true;
|
||||
try { await post(`/api/sessions/${current}/answer`, { option: i + 1 }); }
|
||||
catch (e) { alert("Kunde inte svara: " + e.message); b.disabled = false; }
|
||||
};
|
||||
opts.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
$("newSessBtn").onclick = () => $("newSessForm").classList.toggle("hidden");
|
||||
$("newSessCancel").onclick = () => $("newSessForm").classList.add("hidden");
|
||||
$("newSessForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await post("/api/sessions", {
|
||||
name: $("newSessName").value.trim(),
|
||||
cmd: $("newSessCmd").value.trim(),
|
||||
cwd: $("newSessCwd").value.trim(),
|
||||
});
|
||||
$("newSessForm").reset();
|
||||
$("newSessForm").classList.add("hidden");
|
||||
await refreshSessions();
|
||||
selectSession($("newSessName").value.trim() || 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" });
|
||||
current = null;
|
||||
refreshSessions();
|
||||
$("sessPane").classList.add("hidden");
|
||||
$("noSess").classList.remove("hidden");
|
||||
};
|
||||
|
||||
const sendKeys = (keys) => current && post(`/api/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(() => {});
|
||||
|
||||
$("promptForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const t = $("promptText").value;
|
||||
if (!t.trim() || !current) return;
|
||||
try {
|
||||
await post(`/api/sessions/${current}/prompt`, { text: t });
|
||||
$("promptText").value = "";
|
||||
} catch (err) { alert("Kunde inte skicka: " + err.message); }
|
||||
};
|
||||
$("promptText").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
$("promptForm").requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- delningar ----------
|
||||
async function refreshShares() {
|
||||
shares = (await api("/api/shares")) || [];
|
||||
const box = $("shareItems");
|
||||
box.innerHTML = "";
|
||||
for (const sh of shares) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "share-item" + (currentShare === sh.id ? " active" : "");
|
||||
const when = new Date(sh.created).toLocaleString("sv-SE", { dateStyle: "short", timeStyle: "short" });
|
||||
d.innerHTML = `<button class="del danger" title="ta bort">✕</button>
|
||||
<div class="name">${escapeHtml(sh.name)}</div>
|
||||
<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" });
|
||||
if (currentShare === sh.id) { currentShare = null; $("shareView").innerHTML = "Välj en delning i listan"; }
|
||||
refreshShares();
|
||||
};
|
||||
d.onclick = () => viewShare(sh);
|
||||
box.appendChild(d);
|
||||
}
|
||||
}
|
||||
|
||||
async function viewShare(sh) {
|
||||
currentShare = sh.id;
|
||||
refreshShares();
|
||||
const v = $("shareView");
|
||||
v.classList.remove("empty");
|
||||
const raw = `/api/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`);
|
||||
v.innerHTML = `<div class="md">${mdToHtml(txt)}</div>`;
|
||||
} else {
|
||||
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||
v.innerHTML = `<pre>${escapeHtml(txt)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- inställningar ----------
|
||||
async function loadConfig() {
|
||||
const c = await api("/api/config");
|
||||
$("cfgCmd").value = c.agent_cmd || "";
|
||||
$("cfgArgs").value = (c.agent_args || []).join(" ");
|
||||
$("cfgWorkdir").value = c.workdir || "";
|
||||
$("cfgPoll").value = c.poll_ms || 500;
|
||||
$("cfgNtfyOn").checked = !!c.ntfy?.enabled;
|
||||
$("cfgNtfyQ").checked = !!c.ntfy?.notify_on_question;
|
||||
$("cfgNtfyServer").value = c.ntfy?.server || "";
|
||||
$("cfgNtfyTopic").value = c.ntfy?.topic || "";
|
||||
window._ntfyAllowed = c.ntfy?.allowed_topics || ["agent-helm", "claude"];
|
||||
}
|
||||
|
||||
$("cfgForm").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api("/api/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
agent_cmd: $("cfgCmd").value.trim(),
|
||||
agent_args: $("cfgArgs").value.trim() ? $("cfgArgs").value.trim().split(/\s+/) : [],
|
||||
workdir: $("cfgWorkdir").value.trim(),
|
||||
poll_ms: Number($("cfgPoll").value),
|
||||
ntfy: {
|
||||
enabled: $("cfgNtfyOn").checked,
|
||||
notify_on_question: $("cfgNtfyQ").checked,
|
||||
server: $("cfgNtfyServer").value.trim(),
|
||||
topic: $("cfgNtfyTopic").value.trim(),
|
||||
allowed_topics: window._ntfyAllowed,
|
||||
},
|
||||
}),
|
||||
});
|
||||
$("cfgStatus").textContent = "Sparat ✓";
|
||||
setTimeout(() => ($("cfgStatus").textContent = ""), 2500);
|
||||
} catch (err) { $("cfgStatus").textContent = "Fel: " + err.message; }
|
||||
};
|
||||
|
||||
// ---------- hjälp ----------
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Miniminimal markdown: rubriker, fet/kursiv, kod, listor, länkar, hr.
|
||||
function mdToHtml(md) {
|
||||
const blocks = md.split(/\n{2,}/);
|
||||
return blocks.map((b) => {
|
||||
if (/^```/.test(b)) return `<pre>${escapeHtml(b.replace(/^```\w*\n?|```$/g, ""))}</pre>`;
|
||||
if (/^#{1,6} /.test(b)) {
|
||||
const n = b.match(/^#+/)[0].length;
|
||||
return `<h${n}>${inline(b.replace(/^#+ /, ""))}</h${n}>`;
|
||||
}
|
||||
if (/^([-*] |\d+\. )/m.test(b)) {
|
||||
const items = b.split("\n").filter(Boolean).map((l) => `<li>${inline(l.replace(/^([-*]|\d+\.) /, ""))}</li>`).join("");
|
||||
return /^\d/.test(b) ? `<ol>${items}</ol>` : `<ul>${items}</ul>`;
|
||||
}
|
||||
if (/^(-{3,}|_{3,}|\*{3,})$/.test(b.trim())) return "<hr>";
|
||||
return `<p>${inline(b)}</p>`;
|
||||
}).join("\n");
|
||||
function inline(s) {
|
||||
return escapeHtml(s)
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- start ----------
|
||||
if (token) tryConnect(); else showLogin();
|
||||
Reference in New Issue
Block a user