init
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>creator3</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2106
frontend/package-lock.json
generated
Normal file
2106
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "creator3-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@panzoom/panzoom": "^4.6.0",
|
||||
"@shikijs/markdown-it": "^4.3.1",
|
||||
"dompurify": "^3.4.7",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-anchor": "^9.2.0",
|
||||
"shiki": "^4.3.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
182
frontend/src/App.vue
Normal file
182
frontend/src/App.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { api, wsVerbinden } from "./api.js";
|
||||
import { layout, store } from "./store.js";
|
||||
import Graph from "./components/Graph.vue";
|
||||
import Guide from "./components/Guide.vue";
|
||||
import Kennzahlen from "./components/Kennzahlen.vue";
|
||||
|
||||
const tab = ref("graph");
|
||||
const navOffen = ref(false);
|
||||
const fehler = ref("");
|
||||
const neuName = ref("");
|
||||
let wsTrennen = null;
|
||||
let debounceId = null;
|
||||
let pollId = null;
|
||||
|
||||
const laeuft = computed(() => store.run?.status === "running");
|
||||
const quellenMax = computed(() => {
|
||||
const t = store.topics.find((x) => x.name === store.topic);
|
||||
return t?.quellen_max ?? 24;
|
||||
});
|
||||
|
||||
async function quellenSetzen(ev) {
|
||||
// Regler-Position 1 = ∞ (API-Wert 0); echte Deckel beginnen bei 2,
|
||||
// denn 1 Quelle kann nie bestätigen (Konsens braucht 2 unabhängige)
|
||||
const pos = parseInt(ev.target.value, 10);
|
||||
const wert = pos === 1 ? 0 : pos;
|
||||
try {
|
||||
await api.einstellen(store.topic, wert);
|
||||
await topicsLaden();
|
||||
} catch (e) {
|
||||
fehler.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function topicsLaden() {
|
||||
store.topics = await api.topics();
|
||||
if (!store.topic && store.topics.length) store.topic = store.topics[0].name;
|
||||
}
|
||||
|
||||
async function graphLaden() {
|
||||
if (!store.topic) return;
|
||||
try {
|
||||
const g = await api.graph(store.topic);
|
||||
layout.value = g.layout;
|
||||
store.zaehler = g.zaehler;
|
||||
store.gates = g.gates;
|
||||
store.run = g.run;
|
||||
} catch (e) {
|
||||
fehler.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onWs(ev) {
|
||||
if (ev.typ === "verbunden") return alles();
|
||||
clearTimeout(debounceId);
|
||||
debounceId = setTimeout(() => {
|
||||
graphLaden();
|
||||
topicsLaden();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function alles() {
|
||||
await topicsLaden();
|
||||
await graphLaden();
|
||||
}
|
||||
|
||||
async function waehlen(name) {
|
||||
store.topic = name;
|
||||
navOffen.value = false;
|
||||
await graphLaden();
|
||||
}
|
||||
|
||||
async function anlegen() {
|
||||
if (!neuName.value.trim()) return;
|
||||
try {
|
||||
await api.anlegen(neuName.value.trim(), "");
|
||||
neuName.value = "";
|
||||
await alles();
|
||||
} catch (e) {
|
||||
fehler.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function aktion(fn) {
|
||||
fehler.value = "";
|
||||
try {
|
||||
await fn(store.topic);
|
||||
await graphLaden();
|
||||
} catch (e) {
|
||||
fehler.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function starten() {
|
||||
let budget = null;
|
||||
const r = store.run;
|
||||
const knapp = r && r.budget_tokens > 0 &&
|
||||
(r.verbraucht || 0) >= r.budget_tokens * 0.9;
|
||||
if (r?.status === "budget" || (r?.status === "paused" && knapp)) {
|
||||
const alt = r.budget_tokens || 2000000;
|
||||
const eingabe = window.prompt(
|
||||
`Budget ${r.status === "budget" ? "erschöpft" : "fast erschöpft"} ` +
|
||||
`(${((r.verbraucht || 0) / 1e6).toFixed(1)}M verbraucht). ` +
|
||||
"Neues Token-Budget für diesen Lauf:",
|
||||
String(alt + 2000000),
|
||||
);
|
||||
if (eingabe === null) return;
|
||||
budget = parseInt(eingabe, 10) || null;
|
||||
}
|
||||
await aktion((t) => api.start(t, budget));
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (!window.confirm(`„${store.topic}" komplett zurücksetzen? ` +
|
||||
"Quellen, Atome und Guide werden gelöscht."))
|
||||
return;
|
||||
await aktion(api.reset);
|
||||
await topicsLaden();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
alles();
|
||||
wsTrennen = wsVerbinden(onWs);
|
||||
pollId = setInterval(() => laeuft.value && graphLaden(), 5000);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
wsTrennen?.();
|
||||
clearInterval(pollId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<button class="burger" @click="navOffen = !navOffen">☰</button>
|
||||
<nav class="tabs">
|
||||
<button :class="{ aktiv: tab === 'graph' }" @click="tab = 'graph'">Pipeline</button>
|
||||
<button :class="{ aktiv: tab === 'guide' }" @click="tab = 'guide'">Guide</button>
|
||||
<button :class="{ aktiv: tab === 'kennzahlen' }" @click="tab = 'kennzahlen'">
|
||||
Kennzahlen
|
||||
</button>
|
||||
</nav>
|
||||
<span class="spacer" />
|
||||
<span v-if="store.run" class="runstatus" :data-status="store.run.status">
|
||||
{{ store.run.status }}<template v-if="store.run.grund"> · {{ store.run.grund }}</template>
|
||||
</span>
|
||||
<button v-if="!laeuft" class="primaer" :disabled="!store.topic"
|
||||
@click="starten()">Start</button>
|
||||
<button v-else @click="aktion(api.pause)">Pause</button>
|
||||
<button v-if="!laeuft && store.topic" title="Pipeline auf null zurücksetzen"
|
||||
@click="reset()">Reset</button>
|
||||
<label v-if="store.topic" class="quellen-regler"
|
||||
:title="'Quellen-Deckel — ganz links: unbegrenzt, es stoppt nur die Sättigung'">
|
||||
<span>Quellen: <b>{{ quellenMax === 0 ? "∞" : quellenMax }}</b></span>
|
||||
<input type="range" min="1" max="48" step="1"
|
||||
:value="quellenMax === 0 ? 1 : quellenMax"
|
||||
:disabled="laeuft" @change="quellenSetzen($event)" />
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<aside class="sidebar" :class="{ offen: navOffen }">
|
||||
<form @submit.prevent="anlegen">
|
||||
<input v-model="neuName" placeholder="Neues Thema…" />
|
||||
</form>
|
||||
<ul>
|
||||
<li v-for="t in store.topics" :key="t.name"
|
||||
:class="{ aktiv: t.name === store.topic }" @click="waehlen(t.name)">
|
||||
<span>{{ t.titel || t.name }}</span>
|
||||
<small>{{ t.status }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<p v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }}</p>
|
||||
<Graph v-if="tab === 'graph'" />
|
||||
<Guide v-else-if="tab === 'guide'" />
|
||||
<Kennzahlen v-else />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
69
frontend/src/api.js
Normal file
69
frontend/src/api.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Alle Pfad-Parameter durch encodeURIComponent (creator2-Lektion: '#'/'/'
|
||||
// im Topic-Namen machten Topics unlöschbar).
|
||||
const BASE = import.meta.env.DEV ? "http://localhost:8000" : "";
|
||||
|
||||
async function req(pfad, opts = {}) {
|
||||
const r = await fetch(BASE + pfad, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const e = encodeURIComponent;
|
||||
|
||||
export const api = {
|
||||
topics: () => req("/api/topics"),
|
||||
anlegen: (name, titel) =>
|
||||
req("/api/topics", { method: "POST", body: JSON.stringify({ name, titel }) }),
|
||||
loeschen: (t) => req(`/api/topics/${e(t)}`, { method: "DELETE" }),
|
||||
start: (t, budget) =>
|
||||
req(`/api/topics/${e(t)}/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ budget }),
|
||||
}),
|
||||
pause: (t) => req(`/api/topics/${e(t)}/pause`, { method: "POST" }),
|
||||
stop: (t) => req(`/api/topics/${e(t)}/stop`, { method: "POST" }),
|
||||
reset: (t) => req(`/api/topics/${e(t)}/reset`, { method: "POST" }),
|
||||
einstellen: (t, quellen_max) =>
|
||||
req(`/api/topics/${e(t)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ quellen_max }),
|
||||
}),
|
||||
graph: (t) => req(`/api/topics/${e(t)}/graph`),
|
||||
knotenTasks: (t, k) => req(`/api/topics/${e(t)}/knoten/${e(k)}/tasks`),
|
||||
guide: (t) => req(`/api/topics/${e(t)}/guide`),
|
||||
kennzahlen: (t) => req(`/api/topics/${e(t)}/kennzahlen`),
|
||||
};
|
||||
|
||||
// Reconnect mit Cleanup-Flag; onopen → synthetisches Event zum Resync.
|
||||
export function wsVerbinden(onEvent) {
|
||||
let offen = true;
|
||||
let ws = null;
|
||||
function verbinden() {
|
||||
if (!offen) return;
|
||||
const url =
|
||||
(location.protocol === "https:" ? "wss://" : "ws://") +
|
||||
(import.meta.env.DEV ? "localhost:8000" : location.host) +
|
||||
"/ws";
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch {
|
||||
setTimeout(verbinden, 2000);
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => onEvent({ typ: "verbunden" });
|
||||
ws.onmessage = (m) => {
|
||||
try {
|
||||
onEvent(JSON.parse(m.data));
|
||||
} catch {}
|
||||
};
|
||||
ws.onclose = () => offen && setTimeout(verbinden, 2000);
|
||||
}
|
||||
verbinden();
|
||||
return () => {
|
||||
offen = false;
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
59
frontend/src/components/Graph.vue
Normal file
59
frontend/src/components/Graph.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
// Einfache Kachel-Ansicht: alle Knoten in Fluss-Reihenfolge,
|
||||
// Titel + Task-Anzahl als Badge. Klick öffnet das Detail-Panel.
|
||||
import { computed, ref } from "vue";
|
||||
import { layout, store } from "../store.js";
|
||||
import KnotenPanel from "./KnotenPanel.vue";
|
||||
|
||||
const gewaehlt = ref("");
|
||||
|
||||
const knoten = computed(() => {
|
||||
if (!layout.value) return [];
|
||||
return Object.entries(layout.value.knoten)
|
||||
.map(([id, k]) => ({
|
||||
id,
|
||||
...k,
|
||||
z: store.zaehler[id] || {},
|
||||
gate: store.gates[id],
|
||||
}))
|
||||
.sort((a, b) => a.zeile - b.zeile || a.spalte - b.spalte)
|
||||
.map((k) => ({
|
||||
...k,
|
||||
gesamt: (k.z.offen || 0) + (k.z.laufend || 0) + (k.z.fertig || 0) +
|
||||
(k.z.fehler || 0),
|
||||
}));
|
||||
});
|
||||
|
||||
function statusKlasse(k) {
|
||||
if (k.typ === "gate") return "gate-" + (k.gate?.status || "offen");
|
||||
if ((k.z.fehler || 0) > 0) return "hat-fehler";
|
||||
if ((k.z.laufend || 0) > 0) return "laeuft";
|
||||
if ((k.z.offen || 0) > 0) return "wartet";
|
||||
if ((k.z.fertig || 0) > 0) return "fertig";
|
||||
return "";
|
||||
}
|
||||
|
||||
function zahlKlasse(k) {
|
||||
if (k.z.fehler) return "b-fehler";
|
||||
if (k.z.laufend) return "b-laufend";
|
||||
if (k.z.offen) return "b-offen";
|
||||
return "b-fertig";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-wrap">
|
||||
<div class="kacheln">
|
||||
<button v-for="k in knoten" :key="k.id" class="knoten"
|
||||
:class="[statusKlasse(k), { gewaehlt: k.id === gewaehlt }]"
|
||||
@click="gewaehlt = k.id">
|
||||
<span class="kn-titel">{{ k.titel }}</span>
|
||||
<span class="kn-beschr">{{ k.beschreibung }}</span>
|
||||
<span v-if="k.gate" class="kn-stufe">Runde {{ k.gate.runde }}</span>
|
||||
<b v-if="k.gesamt" class="kn-zahl" :class="zahlKlasse(k)">{{
|
||||
k.gesamt }}</b>
|
||||
</button>
|
||||
</div>
|
||||
<KnotenPanel v-if="gewaehlt" :knoten="gewaehlt" @zu="gewaehlt = ''" />
|
||||
</div>
|
||||
</template>
|
||||
97
frontend/src/components/Guide.vue
Normal file
97
frontend/src/components/Guide.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
// Guide-Ansicht (R10): 68ch-Prose, Kapitel-Sections mit content-visibility,
|
||||
// TOC + IntersectionObserver-Scroll-Spy. HTML einmal rendern und cachen.
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { api } from "../api.js";
|
||||
import { initMarkdown, render, slugify } from "../markdown.js";
|
||||
import { store } from "../store.js";
|
||||
|
||||
const daten = ref(null);
|
||||
const bereit = ref(false);
|
||||
const aktiv = ref("");
|
||||
const cache = new Map();
|
||||
let beobachter = null;
|
||||
let seq = 0;
|
||||
|
||||
async function laden() {
|
||||
const meine = ++seq;
|
||||
await initMarkdown();
|
||||
bereit.value = true;
|
||||
if (!store.topic) return;
|
||||
const d = await api.guide(store.topic);
|
||||
if (meine !== seq) return;
|
||||
cache.clear();
|
||||
daten.value = d;
|
||||
await nextTick();
|
||||
spyAufsetzen();
|
||||
}
|
||||
|
||||
function html(key, text) {
|
||||
if (!cache.has(key)) cache.set(key, render(text));
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const toc = computed(
|
||||
() =>
|
||||
daten.value?.kapitel.map((k) => ({
|
||||
id: "kap-" + k.id,
|
||||
titel: k.titel,
|
||||
sections: k.sections.map((s) => ({ id: slugify(s.titel), titel: s.titel })),
|
||||
})) || [],
|
||||
);
|
||||
|
||||
function spyAufsetzen() {
|
||||
beobachter?.disconnect();
|
||||
beobachter = new IntersectionObserver(
|
||||
(eintraege) => {
|
||||
for (const e of eintraege) if (e.isIntersecting) aktiv.value = e.target.id;
|
||||
},
|
||||
{ rootMargin: "0px 0px -70% 0px" },
|
||||
);
|
||||
document
|
||||
.querySelectorAll(".prose section[id]")
|
||||
.forEach((el) => beobachter.observe(el));
|
||||
}
|
||||
|
||||
function springe(id) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
watch(() => store.topic, laden);
|
||||
watch(
|
||||
() => store.run?.status,
|
||||
(neu, alt) => alt === "running" && neu !== "running" && laden(),
|
||||
);
|
||||
onMounted(laden);
|
||||
onUnmounted(() => beobachter?.disconnect());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guide-wrap">
|
||||
<nav class="toc">
|
||||
<template v-for="k in toc" :key="k.id">
|
||||
<a :class="{ aktiv: aktiv === k.id }" @click="springe(k.id)">{{
|
||||
k.titel
|
||||
}}</a>
|
||||
<a v-for="s in k.sections" :key="s.id" class="toc-sub"
|
||||
:class="{ aktiv: aktiv === s.id }" @click="springe(s.id)">{{
|
||||
s.titel
|
||||
}}</a>
|
||||
</template>
|
||||
</nav>
|
||||
<article v-if="daten && bereit" class="prose">
|
||||
<p v-if="!daten.kapitel.length" class="leer">
|
||||
Noch kein Guide — Lauf starten.
|
||||
</p>
|
||||
<section v-for="k in daten.kapitel" :id="'kap-' + k.id" :key="k.id"
|
||||
class="kapitel">
|
||||
<h2>{{ k.titel }}</h2>
|
||||
<p v-if="k.intro" class="intro">{{ k.intro }}</p>
|
||||
<section v-for="s in k.sections" :id="slugify(s.titel)" :key="s.id">
|
||||
<h3>{{ s.titel }}</h3>
|
||||
<div v-html="html(s.id + ':' + (s.text || '').length, s.text)" />
|
||||
</section>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
108
frontend/src/components/Kennzahlen.vue
Normal file
108
frontend/src/components/Kennzahlen.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { api } from "../api.js";
|
||||
import { store } from "../store.js";
|
||||
|
||||
const kz = ref(null);
|
||||
|
||||
async function laden() {
|
||||
if (store.topic) kz.value = await api.kennzahlen(store.topic);
|
||||
}
|
||||
watch(() => store.topic, laden);
|
||||
watch(() => store.run?.status, laden);
|
||||
onMounted(laden);
|
||||
|
||||
function fmtDauer(ms) {
|
||||
const s = (ms || 0) / 1000;
|
||||
return s >= 60 ? (s / 60).toFixed(1) + " m" : s.toFixed(1) + " s";
|
||||
}
|
||||
|
||||
function fmtTok(n) {
|
||||
n = n || 0;
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const gesamt = computed(() => {
|
||||
const g = { calls: 0, ok: 0, fehler: 0, tok_in: 0, tok_out: 0,
|
||||
cache_read: 0, dur_ms: 0, wait_ms: 0 };
|
||||
for (const z of kz.value?.zeilen || []) {
|
||||
for (const k of Object.keys(g)) g[k] += z[k] || 0;
|
||||
}
|
||||
return g;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="kz" class="kennzahlen">
|
||||
<div class="kacheln">
|
||||
<div class="kachel">
|
||||
<b>{{ kz.coverage ?? "–" }}<small v-if="kz.coverage !== null">%</small></b>
|
||||
<span>Abdeckung ({{ kz.soll_gedeckt }}/{{ kz.soll_gesamt }} Soll-Punkte)</span>
|
||||
</div>
|
||||
<div class="kachel">
|
||||
<b>{{ kz.redundanz_offen }}</b>
|
||||
<span>offene Dopplungen</span>
|
||||
</div>
|
||||
<div class="kachel">
|
||||
<b>{{ kz.eskaliert ?? 0 }}</b>
|
||||
<span>eskalierte Befunde (Grenze dokumentiert)</span>
|
||||
</div>
|
||||
<div class="kachel">
|
||||
<b>{{ kz.atome_aktiv ?? "–" }}</b>
|
||||
<span>aktive Atome ({{ kz.verdichtet_punkte ?? 0 }}/{{ kz.soll_gesamt }}
|
||||
Punkte verdichtet)</span>
|
||||
</div>
|
||||
<div class="kachel">
|
||||
<b>{{ kz.beleg_abdeckung ?? "–" }}<small
|
||||
v-if="kz.beleg_abdeckung !== null">%</small></b>
|
||||
<span>Beleg-Abdeckung (Atome nahe jedem Soll-Beleg)</span>
|
||||
</div>
|
||||
<div class="kachel">
|
||||
<b>{{ fmtTok(kz.verbraucht) }}</b>
|
||||
<span>Tokens<template v-if="kz.budget"> / {{ fmtTok(kz.budget) }} Budget</template></span>
|
||||
</div>
|
||||
<div class="kachel">
|
||||
<b>${{ kz.kosten_usd }}</b>
|
||||
<span>geschätzte Kosten</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stufe</th><th>Knoten</th><th>Calls</th><th>ok</th><th>Fehler</th>
|
||||
<th>Tok ein</th><th>Tok aus</th><th>Cache</th><th>Dauer</th><th>Warte</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(z, i) in kz.zeilen" :key="i">
|
||||
<td>{{ z.stufe }}</td>
|
||||
<td>{{ z.knoten }}</td>
|
||||
<td>{{ z.calls }}</td>
|
||||
<td>{{ z.ok }}</td>
|
||||
<td>{{ z.fehler }}</td>
|
||||
<td>{{ fmtTok(z.tok_in) }}</td>
|
||||
<td>{{ fmtTok(z.tok_out) }}</td>
|
||||
<td>{{ fmtTok(z.cache_read) }}</td>
|
||||
<td>{{ fmtDauer(z.dur_ms) }}</td>
|
||||
<td>{{ fmtDauer(z.wait_ms) }}</td>
|
||||
</tr>
|
||||
<tr v-if="kz.zeilen.length" class="gesamt">
|
||||
<td>Gesamt</td>
|
||||
<td></td>
|
||||
<td>{{ gesamt.calls }}</td>
|
||||
<td>{{ gesamt.ok }}</td>
|
||||
<td>{{ gesamt.fehler }}</td>
|
||||
<td>{{ fmtTok(gesamt.tok_in) }}</td>
|
||||
<td>{{ fmtTok(gesamt.tok_out) }}</td>
|
||||
<td>{{ fmtTok(gesamt.cache_read) }}</td>
|
||||
<td>{{ fmtDauer(gesamt.dur_ms) }}</td>
|
||||
<td>{{ fmtDauer(gesamt.wait_ms) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
51
frontend/src/components/KnotenPanel.vue
Normal file
51
frontend/src/components/KnotenPanel.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
// Seitenpanel: Task-Liste eines Knotens, lazy geladen (kein State-Monolith).
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { api } from "../api.js";
|
||||
import { layout, store } from "../store.js";
|
||||
|
||||
const props = defineProps({ knoten: String });
|
||||
defineEmits(["zu"]);
|
||||
const info = computed(() => layout.value?.knoten[props.knoten] || {});
|
||||
const skills = computed(() => info.value.skills || []);
|
||||
const daten = ref(null);
|
||||
let seq = 0;
|
||||
|
||||
async function laden() {
|
||||
const meine = ++seq;
|
||||
const d = await api.knotenTasks(store.topic, props.knoten);
|
||||
if (meine === seq) daten.value = d;
|
||||
}
|
||||
watch(() => props.knoten, laden, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="panel">
|
||||
<header>
|
||||
<strong>{{ info.titel || knoten }}</strong>
|
||||
<button @click="$emit('zu')">×</button>
|
||||
</header>
|
||||
<p v-if="info.beschreibung" class="kn-beschr">{{ info.beschreibung }}</p>
|
||||
<span v-if="skills.length" class="sk-badges">
|
||||
<i v-for="s in skills" :key="s.name" :data-art="s.art">{{ s.name }}</i>
|
||||
</span>
|
||||
<template v-if="daten">
|
||||
<h4 v-if="daten.befunde.length">Befunde</h4>
|
||||
<ul class="befunde">
|
||||
<li v-for="(b, i) in daten.befunde" :key="i" :data-status="b.status">
|
||||
<b>{{ b.art }}</b> {{ b.item }}
|
||||
<small v-if="b.detail">{{ b.detail }}</small>
|
||||
<em>{{ b.status }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
<h4>Tasks ({{ daten.tasks.length }})</h4>
|
||||
<ul class="tasks">
|
||||
<li v-for="t in daten.tasks" :key="t.id" :data-status="t.status">
|
||||
<code>{{ t.item }}</code>
|
||||
<em>{{ t.status }}</em>
|
||||
<small v-if="t.fehler">{{ t.fehler }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import "@fontsource-variable/inter";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
61
frontend/src/markdown.js
Normal file
61
frontend/src/markdown.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// markdown-it (html:false) + anchor + DOMPurify + Shiki Dual-Theme (R10).
|
||||
// Shiki einmal async initialisieren, danach synchron rendern.
|
||||
import DOMPurify from "dompurify";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import anchor from "markdown-it-anchor";
|
||||
import { fromHighlighter } from "@shikijs/markdown-it/core";
|
||||
import { createHighlighterCore } from "shiki/core";
|
||||
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
||||
|
||||
export function slugify(s) {
|
||||
return s
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9äöüß-]/g, "");
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true });
|
||||
md.use(anchor, { slugify, level: [2, 3] });
|
||||
|
||||
// Tabellen scrollen in eigenem Wrapper (Seite scrollt nie horizontal)
|
||||
md.renderer.rules.table_open = () => '<div class="table-wrap"><table>';
|
||||
md.renderer.rules.table_close = () => "</table></div>";
|
||||
|
||||
let bereit = null;
|
||||
export function initMarkdown() {
|
||||
if (bereit) return bereit;
|
||||
bereit = createHighlighterCore({
|
||||
themes: [
|
||||
import("shiki/themes/github-light.mjs"),
|
||||
import("shiki/themes/github-dark.mjs"),
|
||||
],
|
||||
langs: [
|
||||
import("shiki/langs/php.mjs"),
|
||||
import("shiki/langs/twig.mjs"),
|
||||
import("shiki/langs/yaml.mjs"),
|
||||
import("shiki/langs/bash.mjs"),
|
||||
import("shiki/langs/sql.mjs"),
|
||||
import("shiki/langs/json.mjs"),
|
||||
import("shiki/langs/xml.mjs"),
|
||||
import("shiki/langs/javascript.mjs"),
|
||||
import("shiki/langs/python.mjs"),
|
||||
],
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
}).then((hl) => {
|
||||
md.use(
|
||||
fromHighlighter(hl, {
|
||||
themes: { light: "github-light", dark: "github-dark" },
|
||||
defaultLanguage: "text",
|
||||
fallbackLanguage: "text",
|
||||
}),
|
||||
);
|
||||
});
|
||||
return bereit;
|
||||
}
|
||||
|
||||
export function render(text) {
|
||||
const ohneMarker = (text || "").replace(/<!--\s*atom:\s*\d+\s*-->/g, "");
|
||||
return DOMPurify.sanitize(md.render(ohneMarker));
|
||||
}
|
||||
13
frontend/src/store.js
Normal file
13
frontend/src/store.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Zentraler Zustand: Zähler-Snapshot (reaktiv) + statische Graphstruktur
|
||||
// (shallowRef — nur die Zähler patchen, kein Full-Redraw, R7).
|
||||
import { reactive, shallowRef } from "vue";
|
||||
|
||||
export const store = reactive({
|
||||
topics: [],
|
||||
topic: "",
|
||||
zaehler: {},
|
||||
gates: {},
|
||||
run: null,
|
||||
});
|
||||
|
||||
export const layout = shallowRef(null);
|
||||
267
frontend/src/style.css
Normal file
267
frontend/src/style.css
Normal file
@@ -0,0 +1,267 @@
|
||||
/* Custom-Properties-Themes (dark default), EIN Breakpoint 768px (R1/R10). */
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--bg2: #1c1f25;
|
||||
--rand: #2b303a;
|
||||
--text: #d8dce3;
|
||||
--text2: #8b93a1;
|
||||
--akzent: #5b9dff;
|
||||
--gruen: #3fb47d;
|
||||
--gelb: #d9a441;
|
||||
--rot: #d9534f;
|
||||
--lese-text: #cfd4dc;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #fafafa;
|
||||
--bg2: #ffffff;
|
||||
--rand: #dfe3e8;
|
||||
--text: #23272e;
|
||||
--text2: #667085;
|
||||
--lese-text: #2b3037;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Inter Variable", system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
button {
|
||||
background: var(--bg2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primaer { background: var(--akzent); color: #fff; border: none; }
|
||||
input {
|
||||
background: var(--bg2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template: "top top" auto "side main" 1fr / 230px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
.topbar {
|
||||
grid-area: top;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
background: var(--bg2);
|
||||
}
|
||||
.topbar .spacer { flex: 1; }
|
||||
.tabs { display: flex; gap: 0.3rem; }
|
||||
.tabs button { border: none; background: none; color: var(--text2); }
|
||||
.tabs button.aktiv { color: var(--text); border-bottom: 2px solid var(--akzent); border-radius: 0; }
|
||||
.burger { display: none; }
|
||||
.runstatus { font-size: 0.8rem; color: var(--text2); max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quellen-regler { display: flex; align-items: center; gap: 0.45rem; font-size: 0.8rem; color: var(--text2); }
|
||||
.quellen-regler input { width: 110px; accent-color: var(--akzent); }
|
||||
.quellen-regler b { color: var(--text); }
|
||||
.runstatus[data-status="running"] { color: var(--gruen); }
|
||||
.runstatus[data-status="paused"], .runstatus[data-status="budget"] { color: var(--gelb); }
|
||||
.runstatus[data-status="failed"] { color: var(--rot); }
|
||||
|
||||
.sidebar {
|
||||
grid-area: side;
|
||||
border-right: 1px solid var(--rand);
|
||||
padding: 0.7rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar ul { list-style: none; margin: 0.7rem 0; padding: 0; }
|
||||
.sidebar li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sidebar li.aktiv { background: var(--bg2); }
|
||||
.sidebar small { color: var(--text2); }
|
||||
|
||||
main { grid-area: main; overflow: auto; position: relative; }
|
||||
.fehler {
|
||||
margin: 0.6rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: color-mix(in srgb, var(--rot) 18%, transparent);
|
||||
border: 1px solid var(--rot);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Knoten-Kacheln ── */
|
||||
.graph-wrap { display: flex; height: 100%; }
|
||||
.kacheln {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.7rem;
|
||||
padding: 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
.knoten {
|
||||
position: relative;
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-align: left;
|
||||
border-radius: 9px;
|
||||
border: 1.5px solid var(--rand);
|
||||
background: var(--bg2);
|
||||
}
|
||||
.kn-beschr { font-size: 0.74rem; color: var(--text2); line-height: 1.35; }
|
||||
.knoten .kn-titel { font-size: 0.9rem; padding-right: 2.2rem; }
|
||||
.kn-zahl {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 0.6rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.panel .kn-beschr { margin: 0.35rem 0 0; }
|
||||
.knoten.gewaehlt { outline: 2px solid var(--akzent); }
|
||||
.knoten.laeuft { border-color: var(--akzent); }
|
||||
.knoten.wartet { border-color: var(--text2); }
|
||||
.knoten.fertig { border-color: var(--gruen); }
|
||||
.knoten.hat-fehler { border-color: var(--rot); }
|
||||
.knoten.gate-gruen { border-color: var(--gruen); background: color-mix(in srgb, var(--gruen) 12%, var(--bg2)); }
|
||||
.knoten.gate-rot { border-color: var(--rot); background: color-mix(in srgb, var(--rot) 12%, var(--bg2)); }
|
||||
.kn-titel { font-weight: 600; font-size: 0.82rem; }
|
||||
.kn-stufe { font-size: 0.68rem; color: var(--text2); }
|
||||
.sk-badges { display: flex; flex-wrap: wrap; gap: 3px 4px; margin: 0.5rem 0 0.2rem; }
|
||||
.sk-badges i {
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.6;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--rand);
|
||||
color: var(--text2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sk-badges i[data-art="rolle"] { border-color: var(--akzent); color: var(--akzent); }
|
||||
.sk-badges i[data-art="aufgabe"] { border-color: var(--gruen); color: var(--gruen); }
|
||||
.sk-badges i[data-art="stil"] { border-color: var(--gelb); color: var(--gelb); }
|
||||
.b-offen { background: var(--rand); }
|
||||
.b-laufend { background: var(--akzent); color: #fff; }
|
||||
.b-fertig { background: var(--gruen); color: #fff; }
|
||||
.b-fehler { background: var(--rot); color: #fff; }
|
||||
|
||||
.panel {
|
||||
width: 330px;
|
||||
border-left: 1px solid var(--rand);
|
||||
overflow-y: auto;
|
||||
padding: 0.7rem;
|
||||
background: var(--bg2);
|
||||
}
|
||||
.panel header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.panel ul { list-style: none; padding: 0; }
|
||||
.panel li {
|
||||
padding: 0.35rem 0.4rem;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.panel li em { margin-left: auto; color: var(--text2); font-style: normal; }
|
||||
.panel li[data-status="fehler"] em, .panel li[data-status="offen"] em { color: var(--rot); }
|
||||
.panel li[data-status="fertig"] em, .panel li[data-status="behoben"] em { color: var(--gruen); }
|
||||
.panel small { flex-basis: 100%; color: var(--text2); }
|
||||
|
||||
/* ── Guide (Lesetypografie, R10) ── */
|
||||
.guide-wrap { display: flex; gap: 1rem; height: 100%; }
|
||||
.toc {
|
||||
width: 230px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toc a { padding: 0.25rem 0.5rem; color: var(--text2); cursor: pointer; border-radius: 5px; font-size: 0.85rem; }
|
||||
.toc a.toc-sub { padding-left: 1.3rem; font-size: 0.8rem; }
|
||||
.toc a.aktiv { color: var(--text); background: var(--bg2); }
|
||||
.prose {
|
||||
max-width: 68ch;
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem 1rem 5rem;
|
||||
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.125rem);
|
||||
line-height: 1.65;
|
||||
color: var(--lese-text);
|
||||
}
|
||||
.prose h2, .prose h3 { line-height: 1.25; margin: 2em 0 0.6em; scroll-margin-top: 5rem; color: var(--text); }
|
||||
.prose .intro { color: var(--text2); font-style: italic; }
|
||||
.prose section.kapitel { content-visibility: auto; contain-intrinsic-size: auto 1200px; }
|
||||
.prose a { color: var(--akzent); }
|
||||
.prose pre { overflow-x: auto; padding: 1rem; border-radius: 8px; font-size: 0.875em; line-height: 1.5; }
|
||||
.prose :not(pre) > code { background: var(--bg2); padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table-wrap table { border-collapse: collapse; width: 100%; }
|
||||
.table-wrap th, .table-wrap td { border: 1px solid var(--rand); padding: 0.4rem 0.6rem; max-width: 40ch; overflow-wrap: anywhere; text-align: left; }
|
||||
.table-wrap tr.gesamt td { font-weight: 600; background: var(--bg2); }
|
||||
.leer { color: var(--text2); text-align: center; margin-top: 4rem; }
|
||||
:root:not([data-theme="light"]) .prose .shiki,
|
||||
:root:not([data-theme="light"]) .prose .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
/* ── Kennzahlen ── */
|
||||
.kennzahlen { padding: 1rem; max-width: 900px; margin-inline: auto; }
|
||||
.kacheln { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.7rem; margin-bottom: 1.2rem; }
|
||||
.kachel { background: var(--bg2); border: 1px solid var(--rand); border-radius: 9px; padding: 0.8rem; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.kachel b { font-size: 1.5rem; }
|
||||
.kachel span { color: var(--text2); font-size: 0.8rem; }
|
||||
.kennzahlen th, .kennzahlen td { font-size: 0.82rem; white-space: nowrap; }
|
||||
|
||||
/* ── Mobil ── */
|
||||
@media (max-width: 768px) {
|
||||
.app { grid-template: "top" auto "main" 1fr / 1fr; }
|
||||
.burger { display: block; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(85vw, 300px);
|
||||
background: var(--bg);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.sidebar.offen { transform: none; }
|
||||
.toc { display: none; }
|
||||
.panel { position: fixed; z-index: 15; right: 0; top: 48px; bottom: 0; width: min(90vw, 330px); }
|
||||
.runstatus { display: none; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
.topbar, .sidebar, .toc, .panel { display: none !important; }
|
||||
.prose { max-width: none; }
|
||||
.prose h2, .prose h3 { break-after: avoid; }
|
||||
.prose pre, .table-wrap { break-inside: avoid; }
|
||||
.prose p { orphans: 3; widows: 3; }
|
||||
}
|
||||
6
frontend/vite.config.js
Normal file
6
frontend/vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
Reference in New Issue
Block a user