Files
2026-07-24 21:37:03 +02:00

259 lines
10 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boehmitools · Dashboard</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<style>
.hero {
display: flex; align-items: flex-end; gap: 16px;
flex-wrap: wrap; margin: 6px 0 22px;
}
.hero h1 { margin: 0; font-size: 27px; font-weight: 850; letter-spacing: -.03em; }
.hero p { margin: 6px 0 0; color: var(--bt-muted); max-width: 62ch; }
.hero .bt-spacer { flex: 1; min-width: 0; }
.health { display: flex; gap: 8px; flex-wrap: wrap; }
.hero-side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.hero-side .health { justify-content: flex-end; }
#reloadPlugins { white-space: nowrap; }
.side-btns { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
#importPlugin { white-space: nowrap; }
.cat { margin: 26px 0 10px; display: flex; align-items: center; gap: 10px; }
.cat h2 {
margin: 0; font-size: 11.5px; font-weight: 800; letter-spacing: .1em;
text-transform: uppercase; color: var(--bt-muted);
}
.cat .line { flex: 1; height: 1px; background: var(--bt-line); }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 14px; }
.tool {
display: flex; flex-direction: column; gap: 10px;
background: var(--bt-surface); border: 1px solid var(--bt-line);
border-radius: var(--bt-r-lg); padding: 16px; box-shadow: var(--bt-shadow);
color: inherit; transition: border-color .15s, transform .1s, box-shadow .15s;
position: relative; overflow: hidden;
}
.tool::before {
content: ""; position: absolute; inset: 0 auto 0 0; width: 3px;
background: var(--bt-accent); opacity: 0; transition: opacity .15s;
}
.tool:hover { text-decoration: none; border-color: var(--bt-accent); transform: translateY(-2px); box-shadow: var(--bt-shadow-lg); }
.tool:hover::before { opacity: 1; }
.tool.broken { border-style: dashed; opacity: .85; }
.tool.broken:hover { transform: none; border-color: var(--bt-err); }
.tool-head { display: flex; align-items: flex-start; gap: 11px; }
.tool-ico {
width: 40px; height: 40px; flex: none; border-radius: 11px;
display: grid; place-items: center; font-size: 20px;
background: var(--bt-accent-soft); border: 1px solid var(--bt-line-soft);
}
.tool-head h3 { margin: 0; font-size: 15.5px; font-weight: 750; letter-spacing: -.01em; }
.tool-head .ver { font-size: 11px; color: var(--bt-muted); font-weight: 600; }
.tool p.desc { margin: 0; font-size: 13px; color: var(--bt-ink-soft); line-height: 1.5; }
.tool ul {
margin: 0; padding: 0; list-style: none;
display: flex; flex-direction: column; gap: 4px;
}
.tool ul li {
font-size: 12px; color: var(--bt-muted); padding-left: 15px; position: relative;
}
.tool ul li::before {
content: ""; position: absolute; left: 3px; color: var(--bt-accent); font-weight: 800;
}
.tool-foot {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-top: auto; padding-top: 4px;
}
.tool-foot .go { margin-left: auto; font-size: 12.5px; font-weight: 700; color: var(--bt-accent); }
</style>
</head>
<body data-bt-title="Dashboard" data-bt-icon="▦">
<main class="bt-main">
<div class="hero">
<div>
<h1>Werkzeugkasten</h1>
<p>Alle Tools laufen unter einem Dach und teilen sich Zugänge, Design und
Protokolle. Jedes Tool bleibt dabei eigenständig — es lässt sich einzeln
starten, aktualisieren oder entfernen.</p>
</div>
<span class="bt-spacer"></span>
<div class="hero-side">
<div class="side-btns">
<button class="bt-btn" id="importPlugin" title="Plugin als ZIP hochladen">
⤓ Plugin importieren</button>
<button class="bt-btn" id="reloadPlugins" title="plugins/ neu einlesen — ohne Neustart">
⟳ Plugins neu laden</button>
</div>
<input type="file" id="pluginFile" accept=".zip,application/zip" hidden>
<div class="health" id="health"></div>
</div>
</div>
<div id="cats"></div>
<div class="bt-notice info" style="margin-top:28px">
<b>Baukasten:</b> Ein neues Tool wird zum Plugin, indem sein Ordner unter
<code>plugins/&lt;name&gt;/</code> abgelegt wird — mit einer <code>plugin.json</code>
und einer <code>backend.py</code>, die <code>create_app(ctx)</code> zurückgibt.
Mit <b>„Plugins neu laden“</b> erscheint es sofort hier — ohne Neustart.
Details stehen in <code>plugins/README.md</code>.
</div>
</main>
<script src="/shared/boehmi.js"></script>
<script>
const catsEl = document.getElementById("cats");
const healthEl = document.getElementById("health");
function chip(label, ok, hint) {
return `<span class="bt-badge ${ok ? "ok" : "warn"}" title="${BT.escape(hint || "")}">
<span class="bt-dot"></span>${BT.escape(label)}</span>`;
}
function card(p) {
const broken = !p.ok;
const href = broken ? "#" : p.mount + "/";
return `
<a class="tool ${broken ? "broken" : ""}" href="${href}" ${broken ? 'onclick="return false"' : ""}>
<div class="tool-head">
<span class="tool-ico">${BT.escape(p.icon)}</span>
<div>
<h3>${BT.escape(p.name)}</h3>
<span class="ver">v${BT.escape(p.version)} · ${BT.escape(p.mount)}</span>
</div>
</div>
<p class="desc">${BT.escape(p.description || p.summary)}</p>
${p.features && p.features.length
? `<ul>${p.features.slice(0, 4).map((f) => `<li>${BT.escape(f)}</li>`).join("")}</ul>` : ""}
<div class="tool-foot">
${(p.requires || []).map((r) => `<span class="bt-badge">${BT.escape(r)}</span>`).join("")}
${broken
? `<span class="bt-badge err">Ladefehler</span>`
: `<span class="go">Öffnen →</span>`}
</div>
${broken ? `<div class="bt-notice err" style="margin:0">${BT.escape(p.error)}</div>` : ""}
</a>`;
}
async function render() {
const [{ plugins }, health] = await Promise.all([
fetch("/api/plugins").then((r) => r.json()),
fetch("/api/health").then((r) => r.json()).catch(() => ({})),
]);
healthEl.innerHTML = [
chip("Tandoor", health.tandoor_configured, "URL und Token in den Einstellungen"),
chip("OpenAI", health.openai_configured, "API-Key und Modell in den Einstellungen"),
health.authentication_enabled
? chip("Zugangsschutz", true, "APP_PASSWORD ist gesetzt")
: chip("Kein Zugangsschutz", false, "APP_PASSWORD setzen, wenn erreichbar"),
].join("");
const cats = [...new Set(plugins.map((p) => p.category))];
catsEl.innerHTML = cats.map((c) => `
<div class="cat"><h2>${BT.escape(c)}</h2><span class="line"></span></div>
<div class="cards">${plugins.filter((p) => p.category === c).map(card).join("")}</div>
`).join("");
if (!plugins.length) {
catsEl.innerHTML = `<div class="bt-empty">Noch keine Plugins unter <code>plugins/</code>.</div>`;
}
}
const reloadBtn = document.getElementById("reloadPlugins");
reloadBtn.addEventListener("click", async () => {
const alt = reloadBtn.textContent;
reloadBtn.disabled = true;
reloadBtn.textContent = "⟳ lädt …";
try {
const r = await fetch("/api/plugins/reload", { method: "POST" });
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
await render();
const fehler = (data.fehler || []).length;
BT.toast(
`${data.geladen}/${data.gesamt} Plugins geladen`
+ (fehler ? `, ${fehler} mit Fehler` : ""),
fehler ? "err" : "ok",
);
} catch (err) {
BT.toast("Neu laden fehlgeschlagen: " + err.message, "err");
} finally {
reloadBtn.disabled = false;
reloadBtn.textContent = alt;
}
});
const importBtn = document.getElementById("importPlugin");
const fileInput = document.getElementById("pluginFile");
importBtn.addEventListener("click", () => fileInput.click());
async function sendePlugin(datei, { nurPruefen = false, force = false } = {}) {
const form = new FormData();
form.append("file", datei);
if (nurPruefen) form.append("pruefen_only", "true");
if (force) form.append("force", "true");
const r = await fetch("/api/plugins/import", { method: "POST", body: form });
let daten = {};
try { daten = await r.json(); } catch (_) {}
if (!r.ok) throw new Error(daten.detail || `HTTP ${r.status}`);
return daten;
}
fileInput.addEventListener("change", async () => {
const datei = fileInput.files && fileInput.files[0];
fileInput.value = ""; // erneutes Wählen derselben Datei
if (!datei) return;
const alt = importBtn.textContent;
importBtn.disabled = true;
importBtn.textContent = "⤓ prüft …";
try {
// Erst schauen, was passieren würde — geschrieben wird noch nichts.
const { vorschau: v } = await sendePlugin(datei, { nurPruefen: true });
let force = false;
if (v.bestaetigung_noetig) {
const frage = `${v.warnung}\n\n` +
`Plugin: ${v.name} (${v.id})\n` +
`Installiert: ${v.version_alt} → Archiv: ${v.version}\n\n` +
`Fortfahren? Dateien werden ergänzt und überschrieben, nichts ` +
`gelöscht. Der Datenordner bleibt unberührt.`;
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
force = true;
} else if (v.art === "aktualisierung") {
const frage = `${v.name} (${v.id})\n` +
`Aktualisierung ${v.version_alt}${v.version}\n\n` +
`Der bisherige Stand wird gesichert, Daten bleiben erhalten.`;
if (!confirm(frage)) { BT.toast("Abgebrochen.", ""); return; }
}
importBtn.textContent = "⤓ lädt hoch …";
const b = await sendePlugin(datei, { force });
await render();
if (b.aktiv) {
const wort = b.art === "neu" ? "installiert"
: b.art === "aelter" ? "zurückgesetzt" : "aktualisiert";
BT.toast(`${b.name} ${b.version} ${wort} · ${b.geschrieben} Dateien`, "ok");
} else {
BT.toast(`${b.name} geschrieben, lädt aber nicht: ${b.ladefehler || "?"}`, "err");
}
} catch (err) {
BT.toast("Import fehlgeschlagen: " + err.message, "err");
} finally {
importBtn.disabled = false;
importBtn.textContent = alt;
}
});
render();
</script>
</body>
</html>