chore: initial import
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>boehmitools · nicht gefunden</title>
|
||||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||
</head>
|
||||
<body data-bt-title="Nicht gefunden" data-bt-icon="∅">
|
||||
<main class="bt-main narrow">
|
||||
<div class="bt-card" style="text-align:center;padding:48px 20px">
|
||||
<h1 style="margin:0 0 6px;font-size:44px;letter-spacing:-.04em">404</h1>
|
||||
<p class="bt-muted" style="margin:0 0 18px">Diese Seite gehört zu keinem Tool der Suite.</p>
|
||||
<a class="bt-btn primary" href="/">Zum Dashboard</a>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/shared/boehmi.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,258 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>boehmitools · Läufe</title>
|
||||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||
</head>
|
||||
<body data-bt-title="Läufe" data-bt-icon="↻">
|
||||
<main class="bt-main">
|
||||
<div class="bt-pagehead">
|
||||
<h1>Läufe</h1>
|
||||
<p>Alle Ausführungen der Kommandozeilen-Tools dieser Sitzung.</p>
|
||||
</div>
|
||||
<div class="bt-card bt-card-flush">
|
||||
<table class="bt-table">
|
||||
<thead><tr><th>Tool</th><th>Aufgabe</th><th>Status</th><th>Dauer</th><th></th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="bt-empty bt-hidden">Noch keine Läufe in dieser Sitzung.</div>
|
||||
</main>
|
||||
<script src="/shared/boehmi.js"></script>
|
||||
<script>
|
||||
const badge = (s) => ({
|
||||
running: '<span class="bt-badge info"><span class="bt-dot"></span>läuft</span>',
|
||||
done: '<span class="bt-badge ok">erfolgreich</span>',
|
||||
failed: '<span class="bt-badge err">fehlgeschlagen</span>',
|
||||
cancelled: '<span class="bt-badge warn">abgebrochen</span>',
|
||||
}[s] || s);
|
||||
|
||||
async function load() {
|
||||
const { jobs } = await fetch("/api/jobs").then((r) => r.json());
|
||||
document.getElementById("empty").classList.toggle("bt-hidden", jobs.length > 0);
|
||||
document.getElementById("rows").innerHTML = jobs.map((j) => `
|
||||
<tr>
|
||||
<td><b>${BT.escape(j.plugin_name)}</b></td>
|
||||
<td>${BT.escape(j.label)}<div class="bt-muted bt-mono" style="font-size:11px">${BT.escape(j.command)}</div></td>
|
||||
<td>${badge(j.status)}</td>
|
||||
<td class="bt-muted">${j.duration}s</td>
|
||||
<td><a class="bt-btn mini" href="/plugins/${BT.escape(j.plugin)}/?job=${BT.escape(j.id)}">Protokoll</a></td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
load();
|
||||
setInterval(load, 4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>boehmitools · Einstellungen</title>
|
||||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||
<style>
|
||||
.field { margin-bottom: 12px; }
|
||||
.field .help { font-size: 11.5px; color: var(--bt-muted); margin-top: 4px; }
|
||||
.sticky-actions {
|
||||
position: sticky; bottom: 0; margin-top: 18px; padding: 12px 0;
|
||||
background: linear-gradient(transparent, var(--bt-bg) 40%);
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-bt-title="Einstellungen" data-bt-icon="⚙">
|
||||
|
||||
<main class="bt-main narrow">
|
||||
<div class="bt-pagehead">
|
||||
<h1>Einstellungen</h1>
|
||||
<p>Diese Zugänge gelten für alle Tools der Suite. Sie liegen in
|
||||
<code>data/settings.json</code> und werden den Tools als Umgebungsvariablen
|
||||
übergeben — genau so, wie sie es als Einzeltool erwarten.</p>
|
||||
</div>
|
||||
|
||||
<div class="bt-notice info">
|
||||
Tokens und Schlüssel bleiben im lokalen Backend. Bereits gespeicherte
|
||||
Geheimnisse werden hier nur maskiert angezeigt; ein leeres Feld lässt den
|
||||
bestehenden Wert unverändert.
|
||||
</div>
|
||||
|
||||
<div id="groups"></div>
|
||||
|
||||
<div class="sticky-actions">
|
||||
<button class="primary" id="save">Speichern</button>
|
||||
<span id="state" class="bt-muted"></span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/shared/boehmi.js"></script>
|
||||
<script>
|
||||
const groupsEl = document.getElementById("groups");
|
||||
const stateEl = document.getElementById("state");
|
||||
let schema = [];
|
||||
|
||||
function fieldHtml(f, value) {
|
||||
const id = "f_" + f.key;
|
||||
let control;
|
||||
if (f.kind === "bool") {
|
||||
control = `<label class="bt-check"><input type="checkbox" id="${id}"
|
||||
${String(value).toLowerCase() === "true" ? "checked" : ""}> aktiv</label>`;
|
||||
} else if (f.kind === "select") {
|
||||
control = `<select id="${id}">${f.options.map((o) =>
|
||||
`<option ${o === value ? "selected" : ""}>${BT.escape(o)}</option>`).join("")}</select>`;
|
||||
} else if (f.kind === "secret") {
|
||||
control = `<input type="password" id="${id}" value="${BT.escape(value)}"
|
||||
placeholder="${value ? "unverändert" : "nicht gesetzt"}" autocomplete="new-password">`;
|
||||
} else {
|
||||
control = `<input type="${f.kind === "number" ? "number" : "text"}" id="${id}"
|
||||
value="${BT.escape(value)}" placeholder="${BT.escape(f.placeholder || f.default || "")}">`;
|
||||
}
|
||||
return `<div class="field">
|
||||
<label for="${id}">${BT.escape(f.label)}</label>
|
||||
${control}
|
||||
${f.help ? `<div class="help">${BT.escape(f.help)}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const data = await fetch("/api/settings").then((r) => r.json());
|
||||
schema = data.schema;
|
||||
groupsEl.innerHTML = schema.map((g) => `
|
||||
<section class="bt-card">
|
||||
<h2>${BT.escape(g.group)}</h2>
|
||||
<p class="hint">${g.group === "Tandoor"
|
||||
? "Wird vom AI-Webimport, vom Rezeptimport und vom Vereinheitlichen genutzt."
|
||||
: g.group === "OpenAI" ? "Nur für den AI-Webimport nötig."
|
||||
: "Schutz beim Abruf externer Rezeptseiten."}</p>
|
||||
${g.fields.map((f) => fieldHtml(f, data.values[f.key] ?? "")).join("")}
|
||||
</section>`).join("");
|
||||
}
|
||||
|
||||
document.getElementById("save").addEventListener("click", async () => {
|
||||
const payload = {};
|
||||
schema.forEach((g) => g.fields.forEach((f) => {
|
||||
const el = document.getElementById("f_" + f.key);
|
||||
if (!el) return;
|
||||
payload[f.key] = f.kind === "bool" ? String(el.checked) : el.value;
|
||||
}));
|
||||
try {
|
||||
stateEl.textContent = "Speichern …";
|
||||
await BT.api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
|
||||
stateEl.textContent = "";
|
||||
BT.toast("Einstellungen gespeichert.", "ok");
|
||||
load();
|
||||
} catch (e) {
|
||||
stateEl.textContent = "";
|
||||
BT.toast(e.message, "err");
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user