259 lines
10 KiB
HTML
259 lines
10 KiB
HTML
<!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/<name>/</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>
|