Files
skills/orchestrator/templates/page-artifacts.js
T

312 lines
18 KiB
JavaScript
Raw Normal View History

const art = {repo: "", node: null};
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artefakt", "bi-box-seam"], task: ["Aufgabe", "bi-card-checklist"]};
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
const tail = (path, parts) => String(path).split("/").filter(Boolean).slice(-parts).join("/");
const epochOf = (text) => {
const value = Date.parse(text);
return Number.isFinite(value) ? value / 1000 : null;
};
function artifactModel(data) {
const nodes = [];
const links = [];
const add = (node) => {
nodes.push(node);
if (node.parent) {
links.push({source: node.parent, target: node.id, kind: "contains"});
}
return node.id;
};
const leaves = (holder, wt, depth) => {
for (const name of wt.workers || []) {
add({id: `agent:${name}@${wt.path}`, kind: "agent", label: name, depth, ts: holder.ts, parent: holder.id, data: {name}, repo: holder.repo});
}
for (const found of wt.artefacts || []) {
add({id: `artefact:${holder.id}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth, ts: epochOf(found.added) || holder.ts, parent: holder.id, data: found, repo: holder.repo});
}
};
for (const repo of data.repos || []) {
const base = epochOf(repo.scanned_at) || data.scanned || nowSeconds();
const rid = add({id: `repo:${repo.path}`, kind: "repo", label: repo.name, depth: 0, ts: base, parent: null, data: repo, repo: repo.path});
for (const branch of repo.branches) {
const last = branch.events && branch.events.length ? branch.events[branch.events.length - 1].ts : base;
const bid = add({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name, depth: 1, ts: last, parent: rid, data: branch, repo: repo.path});
for (const wt of branch.worktrees) {
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 2, ts: last, parent: bid, data: {...wt, branch: branch.name}, repo: repo.path};
add(holder);
leaves(holder, wt, 3);
}
}
for (const wt of repo.detached) {
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 1, ts: base, parent: rid, data: {...wt, branch: ""}, repo: repo.path};
add(holder);
leaves(holder, wt, 2);
}
for (const merge of repo.merges || []) {
links.push({source: `branch:${repo.path}\u0000${merge.from}`, target: `branch:${repo.path}\u0000${merge.into}`, kind: "merge", by: merge.by});
}
for (const overlap of repo.overlaps || []) {
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
}
}
for (const task of data.tasks || []) {
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
for (const found of task.artefacts) {
add({id: `artefact:${tid}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth: 1, ts: epochOf(found.added) || nowSeconds(), parent: tid, data: found, repo: ""});
}
}
const ids = new Set(nodes.map((node) => node.id));
return {nodes, links: links.filter((link) => ids.has(link.source) && ids.has(link.target)), byId: new Map(nodes.map((node) => [node.id, node]))};
}
function chosenRepo() {
const repos = tree.data && !tree.data.error ? tree.data.repos : [];
const weight = (repo) => repo.branches.length + repo.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0);
const found = repos.find((repo) => repo.path === art.repo) || [...repos].sort((a, b) => weight(b) - weight(a))[0] || null;
art.repo = found ? found.path : "";
return found;
}
function artSelect(id) {
art.node = art.node === id ? null : id;
drawArtifacts();
}
function artMark(node, at, radius, extra, caption) {
const [kindName] = ART_KINDS[node.kind];
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" ");
return s("g", {
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`,
on: {
click: () => artSelect(node.id),
keydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
artSelect(node.id);
}
},
},
}, s("title", {}, `${node.label} · ${kindName}`), s("circle", {r: radius}), caption ? s("text", {class: "label", y: radius + 14, "text-anchor": "middle"}, caption) : null);
}
function around(base, index, count, distance) {
const angle = (base.angle || 0) + ((index - (count - 1) / 2) * Math.PI) / Math.max(count + 2, 5);
return {x: base.x + distance * Math.cos(angle), y: base.y + distance * Math.sin(angle), angle};
}
const toneOf = (branch) => (branch.state === "merged-clean" ? "ok" : branch.state === "dirty" ? "warn" : branch.state === "protected" ? "violet" : branch.state === "stale" ? "warn" : branch.gone ? "bad" : "info");
function artOrbit(repo, model) {
const svg = chartFrame(420, `Orbit: ${repo.name} als Sonne, Branches als Planeten (Größe = Commits voraus), Worktrees als Monde, Agenten als Satelliten`);
const rings = repo.branches.length > 16 ? [200, 310] : [250];
svg.append(...rings.map((r) => s("circle", {class: "ring", r})));
const layer = s("g", {});
repo.branches.forEach((branch, index) => {
const ring = rings[index % rings.length];
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(repo.branches.length, 1);
const at = {x: ring * Math.cos(angle), y: ring * Math.sin(angle), angle};
const radius = Math.min(11 + 4 * Math.log2(1 + branch.ahead), 30);
const node = model.byId.get(`branch:${repo.path}\u0000${branch.name}`);
branch.worktrees.forEach((wt, slot) => {
const moon = around(at, slot, branch.worktrees.length, radius + 18);
const holder = model.byId.get(`wt:${wt.path}`);
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
});
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
});
layer.append(artMark(model.byId.get(`repo:${repo.path}`), {x: 0, y: 0, angle: 0}, 34, "sun", repo.name.split("/").pop()));
svg.append(layer);
return svg;
}
function artChord(repo) {
const radius = 300;
const branches = repo.branches;
const spots = new Map(branches.map((branch, index) => {
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(branches.length, 1);
return [branch.name, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
}));
const svg = chartFrame(440, `Chord: Überlappung der Branches von ${repo.name}; Band = gemeinsam geänderte Dateien, gestrichelt = Merge`);
const arcs = s("g", {});
for (const overlap of repo.overlaps) {
const from = spots.get(overlap.a);
const to = spots.get(overlap.b);
if (from && to) {
arcs.append(s("path", {class: "edge t-alarm", d: `M${fixed(from.x)},${fixed(from.y)} Q0,0 ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": fixed(width(overlap.count))},
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} gemeinsame Dateien (Konfliktrisiko)\n${overlap.files.join("\n")}`)));
}
}
for (const merge of repo.merges) {
const from = spots.get(merge.from);
const to = spots.get(merge.into);
if (from && to && from !== to) {
const control = {x: (from.x + to.x) * 0.3, y: (from.y + to.y) * 0.3};
arcs.append(s("path", {class: "edge t-grant merge", d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-grant)"},
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
}
}
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
const labels = branches.map((branch) => {
const at = spots.get(branch.name);
const cos = Math.cos(at.angle);
return s("text", {class: "label", x: fixed(at.x * 1.07), y: fixed(at.y * 1.07), dy: "0.35em", "text-anchor": cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"}, short(branch.name).slice(0, 26));
});
svg.append(arcs, s("g", {}, marks), s("g", {}, labels));
return svg;
}
function artSwimlane(repo) {
const lanes = repo.branches;
const begin = state.since;
const end = Math.max(state.until, state.since + 1);
const [left, right, top, lane, full] = [200, 24, 40, 34, 1120];
const height = top + lanes.length * lane + 12;
const x = (ts) => left + ((Math.min(Math.max(ts, begin), end) - begin) / (end - begin)) * (full - left - right);
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: Commits, Reviews, Gates und Pushes je Branch von ${repo.name}`}, defs());
lanes.forEach((branch, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
});
for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6;
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8}));
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
}
const marks = s("g", {});
lanes.forEach((branch, index) => {
const y = top + index * lane + lane / 2;
const shapes = {commit: `M-4,0 a4,4 0 1,0 8,0 a4,4 0 1,0 -8,0`, review: "M-4,-4 h8 v8 h-8 z", gate: "M0,-5 L5,4 L-5,4 z", push: "M0,-6 L5,0 L2,0 L2,5 L-2,5 L-2,0 L-5,0 z"};
for (const event of branch.events.filter((entry) => entry.ts >= begin && entry.ts <= end)) {
const tone = event.ok === false ? "bad" : event.kind === "push" ? "info" : event.kind === "commit" ? "ok" : event.ok ? "ok" : "neutral";
marks.append(s("g", {class: `lane-mark tone-${tone}`, transform: `translate(${fixed(x(event.ts))} ${y})`},
s("title", {}, `${clock.format(stampOf(event.ts))} · ${LANE_MARKS[event.kind][0]}: ${event.title}`), s("path", {d: shapes[event.kind]})));
}
});
svg.append(marks);
return svg;
}
function artLegend(view) {
const words = {
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation",
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
};
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
}
function artPanel(model) {
const node = model.byId.get(art.node);
if (!node) {
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf ein Repository, einen Branch, einen Worktree, einen Agenten oder ein Artefakt.")];
}
const [kindName, kindIcon] = ART_KINDS[node.kind];
const data = node.data;
const body = [];
if (node.kind === "repo") {
body.push(meta([["Pfad", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["Stand", data.scanned_at]]),
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
} else if (node.kind === "branch") {
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
} else if (node.kind === "agent") {
body.push(h("div", {class: "actions"}, btn("Agent öffnen", "btn small soft", () => openAgent(data.name))));
} else if (node.kind === "artefact") {
body.push(meta([["Ref", data.ref], ["Art", data.kind], ["Aufgabe", data.task], ["Seit", data.added]]), data.ended ? pill("Aufgabe beendet: aufräumen", "warn") : null);
} else {
body.push(meta([["Titel", data.title], ["Status", data.status]]));
}
return [
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => artSelect(node.id))),
...body,
];
}
function artifactsScreen() {
const view = ui.views.artifacts;
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
art.repo = event.target.value;
art.node = null;
drawArtifacts();
}}});
const count = h("span", {class: "muted"});
const reload = iconBtn("bi-arrow-clockwise", "Neu laden", "btn small soft", fetchTree, "Neu laden: den Tree-Stand von pkgmgr lesen");
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const legendBox = h("div", {});
const list = h("div", {class: "repos"});
const search = view === "tree" ? h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
tree.q = event.target.value;
drawArtifacts();
}}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll};
window.setTimeout(() => {
drawArtifacts();
fetchTree();
}, 0);
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside);
return h("div", {class: "graph"}, controls, body);
}
async function fetchTree() {
if (tree.busy) {
return;
}
tree.busy = true;
try {
const response = await fetch("/api/tree", {cache: "no-store"});
if (response.ok) {
tree.data = await response.json();
tree.model = artifactModel(tree.data);
} else {
tree.data = {repos: [], scanned: null, error: `/api/tree antwortet ${response.status}`};
}
} catch {
ui.offline = true;
tree.data = tree.data || {repos: [], scanned: null, error: "Hub nicht erreichbar"};
}
tree.busy = false;
drawArtifacts();
}
function drawArtifacts() {
if (!tree.dom) {
return;
}
if (tree.dom.view === "tree") {
drawTree();
return;
}
const empty = !tree.data || tree.data.error;
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) {
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
tree.dom.aside.replaceChildren();
return;
}
const model = tree.model;
if (art.node !== null && !model.byId.has(art.node)) {
art.node = null;
}
tree.dom.aside.replaceChildren(...artPanel(model));
if (tree.dom.view === "3d") {
stage3d("artifacts", tree.dom.stage);
return;
}
const repo = chosenRepo();
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("Kein Repository", "pkgmgr nennt kein Repository."));
}