diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 10c146d..2137930 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -1795,3 +1795,116 @@ td.klein { .team-flaeche { flex-direction: column; } .team-seitenleiste { width: auto; } } + +/* ─── Organisation (#683) ─── */ + +.org-ansicht { + display: flex; + flex-direction: column; + gap: 12px; +} + +.org-raster { + display: grid; + grid-template-columns: minmax(260px, 360px) 1fr; + gap: 16px; + align-items: start; +} + +.org-baum { + background: var(--surface); + border: 1px solid var(--divider); + padding: 12px; +} + +.org-baum-kopf { + justify-content: space-between; + margin-bottom: 8px; +} + +.org-knoten-liste { + display: flex; + flex-direction: column; +} + +.org-ebene-titel { + padding: 6px 12px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +button.org-knoten { + width: 100%; + justify-content: space-between; + border: none; + border-left: 3px solid transparent; + background: transparent; + padding-top: 5px; + padding-bottom: 5px; + text-align: left; +} + +button.org-knoten.aktiv { + border-left-color: var(--accent); + background: var(--surface-hover); +} + +button.org-einheit .org-name { + font-weight: 600; +} + +.org-detail { + display: flex; + flex-direction: column; + gap: 12px; +} + +.org-detail .karte { + display: flex; + flex-direction: column; + gap: 8px; +} + +.org-grund input { + width: 100%; +} + +.org-werkzeuge { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 4px 12px; +} + +.org-werkzeug { + font-size: 13px; +} + +.org-einstellung { + flex-wrap: wrap; +} + +.org-liste { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; +} + +tr.org-ausreisser td { + background: rgba(181, 130, 42, 0.08); +} + +.org-probleme { + border: 1px solid var(--gefahr); + border-left-width: 4px; + padding: 10px 14px; + font-size: 13px; +} + +.org-probleme ul { + margin: 6px 0 0 18px; +} + +@media (max-width: 900px) { + .org-raster { grid-template-columns: 1fr; } +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index efe8187..cc0fcc4 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -36,6 +36,7 @@ import { renderUsageTimeline } from "./views/usage-timeline.js"; import { renderRunDetail } from "./views/run-detail.js"; import { renderPromptHistory } from "./views/prompt-history.js"; import { renderTeam, leaveTeam } from "./views/team.js"; +import { renderOrganisation } from "./views/organisation.js"; // ─── 1. Routen registrieren ─── @@ -53,6 +54,7 @@ registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); }); registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); }); registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); }); +registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); }); registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); }); registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); }); registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); }); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 30a58ba..15194c8 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -68,6 +68,65 @@ "nav.agents": "Agenten", "nav.tasks": "Aufgaben", "nav.team": "Teamübersicht", + "common.saved": "Gespeichert.", + "common.create": "Anlegen", + "nav.organisation": "Organisation", + "org.title": "Organisation", + "org.subtitle": "Einheiten, Leitungen und Mitglieder. Jeder Agent erreicht nur seine Einheit und seine Leitung — die Struktur ist das Adressbuch.", + "org.problems": "Probleme in der Struktur", + "org.structure": "Struktur", + "org.newUnit": "Neue Einheit", + "org.newSubUnit": "Neue Untereinheit", + "org.topLevel": "Oberste Ebene", + "org.leadShort": "Leitung", + "org.unitId": "Kennung (Buchstaben, Ziffern, - und _)", + "org.unitName": "Name", + "org.parent": "Übergeordnete Einheit", + "org.goal": "Ziel der Einheit", + "org.reason": "Begründung der Änderung (geht ins Audit)", + "org.reasonPlaceholder": "z. B. Marktplätze bekommen ein eigenes Team", + "org.selectHint": "Links eine Einheit oder einen Agenten wählen.", + "org.lead": "Leitung", + "org.noLead": "— keine Leitung (die Leitung darüber übernimmt) —", + "org.leadHint": "Die Leitung muss Mitglied der Einheit sein.", + "org.position": "Platz im Baum", + "org.move": "Verschieben", + "org.moveUnitHint": "Die Mitglieder bleiben in der Einheit; es ändert sich, was darüber liegt. Nicht möglich, solange ein betroffener Agent läuft.", + "org.ceiling": "Rechte-Obergrenze", + "org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.", + "org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen", + "org.ceilingInherited": "Von oben begrenzt auf: {tools}", + "org.settings": "Sammelwerte", + "org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.", + "org.setting.DailyCostUsd": "Tagesbudget (USD)", + "org.setting.DailyTokens": "Tagesbudget (Tokens)", + "org.setting.Model": "Modell", + "org.setting.WhenExhausted": "Wenn das Budget erschöpft ist", + "org.modePerAgent": "jeder Agent bekommt den Wert", + "org.modeDistribute": "Wert ist ein Gesamtbetrag, verteilt", + "org.preview": "Vorschau", + "org.apply": "Gewählte Werte schreiben", + "org.agent": "Agent", + "org.current": "Bisher", + "org.proposed": "Neu", + "org.outlier": "Ausreißer", + "org.outlierHint": "Ausreißer weichen stark von ihren Kollegen ab — oft ein Spezialist mit bewusst anderem Wert. Sie sind standardmäßig nicht gewählt.", + "org.rememberDefault": "Als Vorgabe für neue Mitglieder merken", + "org.settingInvalid": "Dieser Wert ist für diese Einstellung nicht gültig.", + "org.members": "Mitglieder", + "org.noMembers": "Noch keine Mitglieder.", + "org.deleteUnit": "Einheit löschen", + "org.unit": "Einheit", + "org.answersTo": "Berichtet an", + "org.human": "einen Menschen", + "org.since": "In dieser Einheit seit {time}", + "org.tools": "Werkzeuge", + "org.moveAgent": "Agent verschieben", + "org.moveAgentHint": "Adressbuch, Leitung und womöglich Werkzeuge ändern sich. Der Agent und beide Leitungen erfahren es in ihrem nächsten Lauf.", + "org.toolsLostTitle": "Werkzeuge würden entfernt", + "org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:", + "org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen", + "org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.", "team.title": "Teamübersicht", "team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.", "team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index ee7f842..1e90287 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -68,6 +68,65 @@ "nav.agents": "Agents", "nav.tasks": "Tasks", "nav.team": "Team overview", + "common.saved": "Saved.", + "common.create": "Create", + "nav.organisation": "Organisation", + "org.title": "Organisation", + "org.subtitle": "Units, leads and members. Every agent only reaches its unit and its lead — the structure is the address book.", + "org.problems": "Problems in the structure", + "org.structure": "Structure", + "org.newUnit": "New unit", + "org.newSubUnit": "New sub-unit", + "org.topLevel": "Top level", + "org.leadShort": "lead", + "org.unitId": "Id (letters, digits, - and _)", + "org.unitName": "Name", + "org.parent": "Parent unit", + "org.goal": "Goal of the unit", + "org.reason": "Reason for the change (goes into the audit log)", + "org.reasonPlaceholder": "e.g. marketplaces get their own team", + "org.selectHint": "Select a unit or an agent on the left.", + "org.lead": "Lead", + "org.noLead": "— no lead (the lead above takes over) —", + "org.leadHint": "The lead must be a member of the unit.", + "org.position": "Position in the tree", + "org.move": "Move", + "org.moveUnitHint": "The members stay in the unit; what changes is what lies above it. Not possible while an affected agent is running.", + "org.ceiling": "Tool ceiling", + "org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.", + "org.ceilingLimit": "Limit the tools of this unit", + "org.ceilingInherited": "Limited from above to: {tools}", + "org.settings": "Unit-wide values", + "org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.", + "org.setting.DailyCostUsd": "Daily budget (USD)", + "org.setting.DailyTokens": "Daily budget (tokens)", + "org.setting.Model": "Model", + "org.setting.WhenExhausted": "When the budget is exhausted", + "org.modePerAgent": "each agent gets the value", + "org.modeDistribute": "value is a total, distributed", + "org.preview": "Preview", + "org.apply": "Write the selected values", + "org.agent": "Agent", + "org.current": "Current", + "org.proposed": "New", + "org.outlier": "outlier", + "org.outlierHint": "Outliers differ strongly from their colleagues — often a specialist with a deliberately different value. They are not selected by default.", + "org.rememberDefault": "Remember as default for new members", + "org.settingInvalid": "That value is not valid for this setting.", + "org.members": "Members", + "org.noMembers": "No members yet.", + "org.deleteUnit": "Delete unit", + "org.unit": "Unit", + "org.answersTo": "Answers to", + "org.human": "a human", + "org.since": "In this unit since {time}", + "org.tools": "Tools", + "org.moveAgent": "Move agent", + "org.moveAgentHint": "Its address book, its lead and possibly its tools change. The agent and both leads are told in their next run.", + "org.toolsLostTitle": "Tools would be removed", + "org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:", + "org.toolsLostConfirm": "Remove the tools and apply", + "org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.", "team.title": "Team overview", "team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.", "team.noInstance": "No instance selected. Choose an active instance in the header.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html index 34b1ee7..8526081 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html +++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html @@ -61,6 +61,12 @@ Team overview + + + + + Organisation + diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js new file mode 100644 index 0000000..d571e6d --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js @@ -0,0 +1,471 @@ +// ClawdDotNet — Organisation (#683) +// +// Units, leads, members — and the three things that change rights: moving, the tool +// ceiling, unit-wide values. Every rule is the instance's (OrgAdministration); this page +// asks for a preview first wherever something could be lost, and shows it before writing. +// +// A list, not a graph: this is where the structure is edited. Watching it work is the team +// overview (#711). + +import { api } from "../modules/api.js"; +import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js"; +import { t } from "../modules/i18n.js"; + +const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"]; +const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]); + +let data = null; +let selection = null; // { kind: "unit" | "agent", id } +let treeBox = null; +let detailBox = null; +let reasonInput = null; + +export async function renderOrganisation() { + const container = document.getElementById("ansicht-inhalt"); + if (!container) return; + + treeBox = erstelleElement("div", { klasse: "org-baum" }); + detailBox = erstelleElement("div", { klasse: "org-detail" }); + + container.replaceChildren(erstelleElement( + "div", + { klasse: "org-ansicht" }, + erstelleElement("h1", { text: t("org.title") }), + erstelleElement("p", { klasse: "gedaempft", text: t("org.subtitle") }), + erstelleElement("div", { id: "org-probleme" }), + erstelleElement("div", { klasse: "org-raster" }, treeBox, detailBox) + )); + + await load(); +} + +async function load() { + try { + data = await api("/organisation"); + } catch (err) { + treeBox.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })); + return; + } + + if (selection && !find(selection)) selection = null; + renderProblems(); + renderTree(); + renderDetail(); +} + +// ─── helpers ─── + +function find(sel) { + if (!sel || !data) return null; + return sel.kind === "unit" + ? data.units.find((u) => u.id === sel.id) + : data.agents.find((a) => a.agentId === sel.id); +} + +function childrenOf(unitId) { + return data.units.filter((u) => (u.parentId ?? null) === unitId); +} + +function membersOf(unitId) { + return data.agents.filter((a) => (a.unitId ?? null) === unitId); +} + +function descendantIds(unitId) { + const ids = new Set([unitId]); + let grew = true; + while (grew) { + grew = false; + for (const u of data.units) { + if (u.parentId && ids.has(u.parentId) && !ids.has(u.id)) { ids.add(u.id); grew = true; } + } + } + return ids; +} + +function reason() { + return reasonInput?.value.trim() ?? ""; +} + +async function send(path, method, body, successText) { + try { + const result = await api(path, { method, body }); + zeigeMeldung(result?.message ?? successText ?? t("common.saved"), "erfolg"); + await load(); + return true; + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + return false; + } +} + +/** + * Moves and ceiling changes: ask for a preview, stop on problems or running agents, show + * the tools that would be lost and let the human confirm — only then write. + */ +async function withPreview(path, method, body) { + let preview; + try { + preview = await api(path, { method, body: { ...body, preview: true } }); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + return; + } + + if (preview.problems.length) { zeigeMeldung(preview.problems.join(" "), "gefahr", 8000); return; } + if (preview.running.length) { + zeigeMeldung(t("org.runningRefusal", { agents: preview.running.join(", ") }), "warnung", 8000); + return; + } + + const lost = Object.entries(preview.toolsLost ?? {}); + if (!lost.length) { + await send(path, method, { ...body, preview: false }); + return; + } + + const list = erstelleElement("ul", { klasse: "org-liste" }, + ...lost.map(([agent, tools]) => erstelleElement("li", { text: `${agent}: ${tools.join(", ")}` }))); + const content = erstelleElement("div", {}, + erstelleElement("p", { text: t("org.toolsLostIntro") }), list); + + const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") }); + const confirm = erstelleElement("button", { type: "button", klasse: "gefahr", text: t("org.toolsLostConfirm") }); + const modal = erstelleModal(t("org.toolsLostTitle"), content, [cancel, confirm]); + cancel.addEventListener("click", () => modal.schliessen()); + confirm.addEventListener("click", async () => { + modal.schliessen(); + await send(path, method, { ...body, preview: false, dropExcessTools: true }); + }); + modal.oeffnen(); +} + +function unitOptions(select, current, exclude = new Set(), topLabel = t("org.topLevel")) { + select.append(erstelleElement("option", { value: "", text: topLabel })); + for (const unit of data.units) { + if (exclude.has(unit.id)) continue; + const option = erstelleElement("option", { value: unit.id, text: unit.path }); + if (unit.id === current) option.selected = true; + select.append(option); + } + if (!current) select.value = ""; + return select; +} + +// ─── problems ─── + +function renderProblems() { + const box = document.getElementById("org-probleme"); + if (!box) return; + box.replaceChildren(...(data.problems.length + ? [erstelleElement("div", { klasse: "org-probleme" }, + erstelleElement("strong", { text: t("org.problems") }), + erstelleElement("ul", {}, ...data.problems.map((p) => erstelleElement("li", { text: p }))))] + : [])); +} + +// ─── tree ─── + +function renderTree() { + const newUnit = erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("org.newUnit"), onclick: () => openCreateUnit() + }); + + const list = erstelleElement("div", { klasse: "org-knoten-liste" }); + const root = erstelleElement("div", { klasse: "org-ebene" }, + erstelleElement("div", { klasse: "org-ebene-titel gedaempft text-klein", text: t("org.topLevel") })); + for (const agent of membersOf(null)) root.append(agentRow(agent, 0)); + list.append(root); + for (const unit of childrenOf(null)) list.append(unitBlock(unit, 0)); + + treeBox.replaceChildren( + erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" }, + erstelleElement("h2", { text: t("org.structure") }), newUnit), + list); +} + +function unitBlock(unit, depth) { + const lead = unit.leaderAgentId ? ` · ${t("org.leadShort")}: ${unit.leaderAgentId}` : ""; + const count = membersOf(unit.id).length; + const row = erstelleElement("button", { + type: "button", + klasse: `org-knoten org-einheit ${selection?.kind === "unit" && selection.id === unit.id ? "aktiv" : ""}`, + stil: `padding-left: ${12 + depth * 18}px`, + onclick: () => select({ kind: "unit", id: unit.id }) + }, + erstelleElement("span", { klasse: "org-name", text: `▣ ${unit.name}` }), + erstelleElement("span", { klasse: "gedaempft text-klein", text: `${count}${lead}` })); + + const block = erstelleElement("div", {}, row); + for (const agent of membersOf(unit.id)) block.append(agentRow(agent, depth + 1)); + for (const child of childrenOf(unit.id)) block.append(unitBlock(child, depth + 1)); + return block; +} + +function agentRow(agent, depth) { + return erstelleElement("button", { + type: "button", + klasse: `org-knoten org-agent ${selection?.kind === "agent" && selection.id === agent.agentId ? "aktiv" : ""}`, + stil: `padding-left: ${12 + depth * 18}px`, + onclick: () => select({ kind: "agent", id: agent.agentId }) + }, + erstelleElement("span", { text: `${agent.isLead ? "★ " : "· "}${agent.displayName || agent.agentId}` }), + erstelleElement("span", { klasse: "gedaempft text-klein", text: agent.agentId })); +} + +function select(sel) { + selection = sel; + renderTree(); + renderDetail(); +} + +function openCreateUnit(parentId = null) { + const id = erstelleElement("input", { type: "text", placeholder: "marketing", required: true }); + const name = erstelleElement("input", { type: "text", placeholder: "Marketing", required: true }); + const parent = unitOptions(erstelleElement("select"), parentId); + const goal = erstelleElement("textarea", { rows: 2 }); + + const form = erstelleElement("form", { klasse: "formular-raster" }, + erstelleElement("label", { text: t("org.unitId") }), id, + erstelleElement("label", { text: t("org.unitName") }), name, + erstelleElement("label", { text: t("org.parent") }), parent, + erstelleElement("label", { text: t("org.goal") }), goal); + + const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") }); + const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") }); + const modal = erstelleModal(t("org.newUnit"), form, [cancel, create]); + cancel.addEventListener("click", () => modal.schliessen()); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const ok = await send("/organisation/units", "POST", { + id: id.value.trim(), name: name.value.trim(), parentId: parent.value || null, goal: goal.value, reason: reason() + }); + if (ok) { + modal.schliessen(); + select({ kind: "unit", id: id.value.trim() }); + } + }); + modal.oeffnen(); +} + +// ─── detail ─── + +function renderDetail() { + const item = find(selection); + reasonInput = erstelleElement("input", { type: "text", placeholder: t("org.reasonPlaceholder") }); + const reasonCard = erstelleKarte(null, [ + erstelleElement("label", { klasse: "text-klein gedaempft", text: t("org.reason") }), reasonInput + ], { klasse: "org-grund" }); + + if (!item) { + detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") })); + return; + } + + detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item))); +} + +function unitPanel(unit) { + const cards = []; + + // Name and goal. + const name = erstelleElement("input", { type: "text", value: unit.name }); + const goal = erstelleElement("textarea", { rows: 2 }); + goal.value = unit.goal ?? ""; + cards.push(erstelleKarte(unit.path, [ + erstelleElement("div", { klasse: "formular-raster" }, + erstelleElement("label", { text: t("org.unitName") }), name, + erstelleElement("label", { text: t("org.goal") }), goal, + erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("common.save"), + onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}`, "PATCH", + { name: name.value, goal: goal.value, reason: reason() }) + })) + ])); + + // Lead. + const lead = erstelleElement("select", {}, erstelleElement("option", { value: "", text: t("org.noLead") })); + for (const member of membersOf(unit.id)) { + const option = erstelleElement("option", { value: member.agentId, text: member.displayName || member.agentId }); + if (member.agentId === unit.leaderAgentId) option.selected = true; + lead.append(option); + } + cards.push(erstelleKarte(t("org.lead"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.leadHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, lead, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("common.save"), + onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/leader`, "PUT", + { agentId: lead.value || null, reason: reason() }) + })) + ])); + + // Position in the tree. + const parent = unitOptions(erstelleElement("select"), unit.parentId, descendantIds(unit.id)); + cards.push(erstelleKarte(t("org.position"), [ + erstelleElement("div", { klasse: "gruppe-horizontal" }, parent, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("org.move"), + onclick: () => withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/move`, "POST", + { target: parent.value || null, reason: reason() }) + })), + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") }) + ])); + + cards.push(ceilingCard(unit)); + cards.push(settingsCard(unit)); + + // Members. + const members = membersOf(unit.id); + cards.push(erstelleKarte(t("org.members"), [ + members.length + ? erstelleElement("ul", { klasse: "org-liste" }, ...members.map((m) => erstelleElement("li", {}, + erstelleElement("button", { + type: "button", klasse: "flach klein", text: `${m.isLead ? "★ " : ""}${m.displayName || m.agentId}`, + onclick: () => select({ kind: "agent", id: m.agentId }) + })))) + : erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noMembers") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleElement("button", { + type: "button", klasse: "flach klein", text: t("org.newSubUnit"), onclick: () => openCreateUnit(unit.id) + }), + erstelleElement("button", { + type: "button", klasse: "gefahr klein", text: t("org.deleteUnit"), + onclick: async () => { + if (await send(`/organisation/units/${encodeURIComponent(unit.id)}?reason=${encodeURIComponent(reason())}`, "DELETE")) { + selection = null; + renderDetail(); + } + } + })) + ])); + + return cards; +} + +function ceilingCard(unit) { + const limited = erstelleElement("input", { type: "checkbox" }); + limited.checked = Array.isArray(unit.toolCeiling); + const boxes = data.knownTools.map((tool) => { + const box = erstelleElement("input", { type: "checkbox", value: tool }); + // Pre-selected: the unit's own ceiling — or, without one, what is allowed from above anyway. + const allowed = unit.toolCeiling ?? unit.effectiveToolCeiling; + box.checked = !allowed || allowed.includes(tool); + return erstelleElement("label", { klasse: "org-werkzeug" }, box, ` ${tool}`); + }); + const grid = erstelleElement("div", { klasse: "org-werkzeuge" }, ...boxes); + const sync = () => { grid.hidden = !limited.checked; }; + limited.addEventListener("change", sync); + sync(); + + const inherited = unit.effectiveToolCeiling && !unit.toolCeiling + ? erstelleElement("p", { klasse: "text-klein", text: t("org.ceilingInherited", { tools: unit.effectiveToolCeiling.join(", ") }) }) + : null; + + return erstelleKarte(t("org.ceiling"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.ceilingHint") }), + inherited, + erstelleElement("label", { klasse: "gruppe-horizontal" }, limited, t("org.ceilingLimit")), + grid, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("common.save"), + onclick: () => { + const tools = limited.checked + ? [...grid.querySelectorAll("input:checked")].map((b) => b.value) + : null; + withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/ceiling`, "PUT", { tools, reason: reason() }); + } + }) + ]); +} + +function settingsCard(unit) { + const setting = erstelleElement("select", {}, + ...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) }))); + const value = erstelleElement("input", { type: "text", placeholder: "10" }); + const mode = erstelleElement("select", {}, + erstelleElement("option", { value: "PerAgent", text: t("org.modePerAgent") }), + erstelleElement("option", { value: "Distribute", text: t("org.modeDistribute") })); + const remember = erstelleElement("input", { type: "checkbox" }); + remember.checked = true; + const result = erstelleElement("div"); + + setting.addEventListener("change", () => { mode.disabled = !NUMERIC.has(setting.value); }); + + const preview = async () => { + let rows; + try { + rows = await api(`/organisation/units/${encodeURIComponent(unit.id)}/settings/preview`, { + method: "POST", body: { setting: setting.value, value: value.value.trim(), mode: mode.disabled ? "PerAgent" : mode.value } + }); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + return; + } + if (!rows.length) { result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.settingInvalid") })); return; } + + const checks = new Map(); + const table = erstelleElement("table", { klasse: "daten-tabelle" }, + erstelleElement("thead", {}, erstelleElement("tr", {}, + ...["", t("org.agent"), t("org.current"), t("org.proposed"), ""].map((h) => erstelleElement("th", { text: h })))), + erstelleElement("tbody", {}, ...rows.map((row) => { + const box = erstelleElement("input", { type: "checkbox" }); + box.checked = row.selectedByDefault; + checks.set(row.agentId, { box, value: row.proposed }); + return erstelleElement("tr", { klasse: row.outlier ? "org-ausreisser" : "" }, + erstelleElement("td", {}, box), + erstelleElement("td", { text: row.displayName || row.agentId }), + erstelleElement("td", { text: row.current }), + erstelleElement("td", { text: row.proposed }), + erstelleElement("td", {}, row.outlier ? erstellePlakette(`⚠ ${t("org.outlier")}`, "warnung") : "")); + }))); + + const apply = erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("org.apply"), + onclick: () => { + const values = {}; + for (const [agentId, { box, value: v }] of checks) if (box.checked) values[agentId] = v; + send(`/organisation/units/${encodeURIComponent(unit.id)}/settings`, "POST", { + setting: setting.value, values, reason: reason(), + defaultValue: remember.checked && mode.value === "PerAgent" ? value.value.trim() : null + }); + } + }); + + result.replaceChildren( + rows.some((r) => r.outlier) ? erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("org.outlierHint")}` }) : "", + table, + erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, remember, t("org.rememberDefault")), + apply); + }; + + return erstelleKarte(t("org.settings"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.settingsHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal org-einstellung" }, setting, value, mode, + erstelleElement("button", { type: "button", klasse: "klein", text: t("org.preview"), onclick: preview })), + result + ]); +} + +function agentPanel(agent) { + const unit = data.units.find((u) => u.id === agent.unitId); + const target = unitOptions(erstelleElement("select"), agent.unitId); + + return [ + erstelleKarte(agent.displayName || agent.agentId, [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: `${agent.agentId} · ${agent.model}` }), + erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }), + erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }), + agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null, + erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` }) + ]), + erstelleKarte(t("org.moveAgent"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, target, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("org.move"), + onclick: () => withPreview(`/organisation/agents/${encodeURIComponent(agent.agentId)}/move`, "POST", + { target: target.value || null, reason: reason() }) + })) + ]) + ]; +} diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index f00057f..9905fe7 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -97,7 +97,7 @@ public sealed record ServiceDto( public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = ""); /// Ein neuer Agent. -public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = ""); +public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null); /// Eine Aufgabe vom Taskboard. public sealed record TaskDto( diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs new file mode 100644 index 0000000..2bddeda --- /dev/null +++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs @@ -0,0 +1,63 @@ +namespace ClawdDotNet.Web.Contracts; + +/// #683: The organisation of an instance — units, members, and what is wrong with it. +public sealed record OrganisationDto( + IReadOnlyList Units, + IReadOnlyList Agents, + IReadOnlyList Problems, + IReadOnlyList KnownTools); + +public sealed record UnitDto( + string Id, + string Name, + string? ParentId, + string? LeaderAgentId, + string Goal, + IReadOnlyList? ToolCeiling, + IReadOnlyList? EffectiveToolCeiling, + UnitDefaultsDto Defaults, + string Path, + int Depth); + +public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted); + +/// An agent as the organisation page shows it: where it is, whom it answers to. +public sealed record OrgMemberDto( + string AgentId, + string DisplayName, + string? UnitId, + bool IsLead, + string? EffectiveLeadId, + DateTime? UnitSinceUtc, + IReadOnlyList Tools, + decimal DailyCostUsd, + long DailyTokens, + string Model); + +public sealed record UnitInputDto(string Id, string Name, string? ParentId = null, string? Goal = null, string Reason = ""); + +public sealed record UnitUpdateDto(string? Name = null, string? Goal = null, string Reason = ""); + +public sealed record LeaderInputDto(string? AgentId, string Reason = ""); + +/// +/// A move or a ceiling change. With nothing is written — the answer +/// says what would happen. confirms removing the tools beyond a +/// ceiling that the preview named. +/// +public sealed record MoveInputDto(string? Target, string Reason = "", bool DropExcessTools = false, bool Preview = false); + +public sealed record CeilingInputDto(IReadOnlyList? Tools, string Reason = "", bool DropExcessTools = false, bool Preview = false); + +public sealed record StructurePreviewDto( + IReadOnlyList AffectedAgents, + IReadOnlyDictionary> ToolsLost, + IReadOnlyList Running, + IReadOnlyList Problems, + bool CanApply); + +public sealed record SettingPreviewInputDto(string Setting, string Value, string Mode = "PerAgent"); + +public sealed record SettingChangeDto(string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault); + +public sealed record SettingApplyDto(string Setting, IReadOnlyDictionary Values, string Reason = "", string? DefaultValue = null); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index bc01936..3d6bfcb 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -31,6 +31,7 @@ internal static class ApiEndpoints api.MapUsage(); api.MapAnalytics(); api.MapLogs(); + api.MapOrganisation(); api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); app.MapEvents(); @@ -126,7 +127,7 @@ internal static class ApiEndpoints agents.MapPost("/", (AppHost host, NewAgentDto input) => host.AgentAdmin.Add(new NewAgent( - input.AgentId, input.DisplayName, input.Model, input.Description)).ToResponse()); + input.AgentId, input.DisplayName, input.Model, input.Description, input.UnitId)).ToResponse()); agents.MapDelete("/{agentId}", (AppHost host, string agentId) => host.AgentAdmin.Remove(agentId).ToResponse()); diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs new file mode 100644 index 0000000..e476b42 --- /dev/null +++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs @@ -0,0 +1,90 @@ +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.Web.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace ClawdDotNet.Web.Endpoints; + +/// +/// #683: The organisation — units, leads, moves, tool ceilings, unit-wide values. Every +/// decision is made by ; this only translates. +/// +internal static class OrganisationEndpoints +{ + public static void MapOrganisation(this RouteGroupBuilder api) + { + var org = api.MapGroup("/organisation"); + + org.MapGet("/", (AppHost host) => + { + var chart = host.Organisation.Current(); + var units = chart.Units + .OrderBy(u => chart.PathOf(u.Id), StringComparer.OrdinalIgnoreCase) + .Select(u => new UnitDto( + u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling, + chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(), + new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted), + chart.PathOf(u.Id), chart.Depth(u.Id))) + .ToList(); + + var agents = host.Instance.Agents + .OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase) + .Select(a => new OrgMemberDto( + a.AgentId, a.DisplayName, chart.UnitOf(a.AgentId)?.Id, chart.LedBy(a.AgentId) is not null, + chart.EffectiveLeaderOf(a.AgentId)?.AgentId, a.UnitSinceUtc, + a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(), + a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model)) + .ToList(); + + return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools); + }); + + org.MapPost("/units", (AppHost host, UnitInputDto input) => + host.Organisation.CreateUnit(input.Id, input.Name, input.ParentId, input.Goal, input.Reason).ToResponse()); + + org.MapPatch("/units/{unitId}", (AppHost host, string unitId, UnitUpdateDto input) => + host.Organisation.UpdateUnit(unitId, input.Name, input.Goal, input.Reason).ToResponse()); + + org.MapDelete("/units/{unitId}", (AppHost host, string unitId, string? reason) => + host.Organisation.DeleteUnit(unitId, reason ?? "").ToResponse()); + + org.MapPut("/units/{unitId}/leader", (AppHost host, string unitId, LeaderInputDto input) => + host.Organisation.SetLeader(unitId, input.AgentId, input.Reason).ToResponse()); + + org.MapPost("/units/{unitId}/move", (AppHost host, string unitId, MoveInputDto input) => + input.Preview + ? Results.Ok(ToDto(host.Organisation.PreviewMoveUnit(unitId, input.Target))) + : host.Organisation.MoveUnit(unitId, input.Target, input.Reason, input.DropExcessTools).ToResponse()); + + org.MapPost("/agents/{agentId}/move", (AppHost host, string agentId, MoveInputDto input) => + input.Preview + ? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target))) + : host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse()); + + org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) => + input.Preview + ? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools))) + : host.Organisation.SetToolCeiling(unitId, input.Tools, input.Reason, input.DropExcessTools).ToResponse()); + + org.MapPost("/units/{unitId}/settings/preview", (AppHost host, string unitId, SettingPreviewInputDto input) => + { + if (!Enum.TryParse(input.Setting, ignoreCase: true, out var setting) + || !Enum.TryParse(input.Mode, ignoreCase: true, out var mode)) + return Results.BadRequest(new OutcomeDto(false, "Unknown setting or mode.")); + + return Results.Ok(host.Organisation.PreviewSetting(unitId, setting, input.Value, mode) + .Select(c => new SettingChangeDto(c.AgentId, c.DisplayName, c.Current, c.Proposed, c.Outlier, c.SelectedByDefault)) + .ToList()); + }); + + org.MapPost("/units/{unitId}/settings", (AppHost host, string unitId, SettingApplyDto input) => + Enum.TryParse(input.Setting, ignoreCase: true, out var setting) + ? host.Organisation.ApplySetting(unitId, setting, input.Values, input.Reason, input.DefaultValue).ToResponse() + : Results.BadRequest(new OutcomeDto(false, "Unknown setting."))); + } + + private static StructurePreviewDto ToDto(StructurePreview preview) + => new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply); +}