Files
govoplan/tools/release/webui/index.html
Albrecht Degering 05ae81d641
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 16s
Security Audit / security-audit (push) Failing after 11s
intermediate commit
2026-07-14 13:32:09 +02:00

1782 lines
70 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GovOPlaN Release Console</title>
<style>
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--panel-alt: #eef3f8;
--line: #d7dde5;
--text: #18212f;
--muted: #637083;
--accent: #1261a6;
--accent-strong: #0a4c87;
--danger: #b42318;
--warning: #a15c07;
--ok: #23704f;
--shadow: 0 1px 2px rgba(20, 31, 48, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
background: #101820;
color: #fff;
padding: 18px 28px;
position: sticky;
top: 0;
}
header h1 {
margin: 0;
font-size: 20px;
font-weight: 700;
}
header p {
margin: 6px 0 0;
color: #c7d0dd;
font-size: 13px;
}
main {
padding: 22px 28px 36px;
}
.toolbar,
.summary,
.layout {
width: min(1600px, 100%);
margin: 0 auto 18px;
}
.toolbar {
display: grid;
grid-template-columns: minmax(120px, 160px) repeat(4, auto) auto;
gap: 10px;
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
box-shadow: var(--shadow);
}
.target-controls {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: end;
}
.target-controls button {
min-width: 44px;
padding: 0 10px;
}
label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
input[type="text"],
input[type="password"] {
width: 100%;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 7px 9px;
color: var(--text);
font: inherit;
}
.check {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
color: var(--text);
font-size: 13px;
}
button {
min-height: 36px;
border: 1px solid var(--accent-strong);
border-radius: 6px;
background: var(--accent);
color: white;
padding: 0 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.secondary {
border-color: var(--line);
background: #fff;
color: var(--text);
}
button.danger {
border-color: #8f1d14;
background: var(--danger);
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.form-grid {
display: grid;
gap: 10px;
}
.unit-check {
width: 18px;
height: 18px;
}
.units-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.summary {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 10px;
}
.insight-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 10px;
padding: 14px;
}
.insight {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: #fff;
}
.insight span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.insight strong {
display: block;
margin-top: 5px;
font-size: 18px;
}
.issue-list {
display: grid;
gap: 8px;
padding: 0 14px 14px;
}
.narrow-table {
min-width: 760px;
}
.metric {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
box-shadow: var(--shadow);
}
.metric span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 6px;
font-size: 24px;
line-height: 1;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 18px;
align-items: start;
}
.main-stack {
display: grid;
gap: 18px;
min-width: 0;
}
section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.section-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 13px 14px;
border-bottom: 1px solid var(--line);
}
.section-head h2 {
margin: 0;
font-size: 15px;
}
.section-head small {
color: var(--muted);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1220px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
font-size: 13px;
}
th {
background: var(--panel-alt);
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.target-column {
width: 330px;
min-width: 330px;
}
.target-cell {
display: flex;
gap: 6px;
align-items: center;
}
.target-value {
display: inline-flex;
align-items: center;
min-width: 58px;
min-height: 30px;
padding: 0 2px;
color: var(--text);
font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
font-weight: 700;
}
.target-tag-value {
font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.row-target-controls {
gap: 4px;
}
.row-target-controls button {
min-height: 30px;
min-width: 34px;
padding: 0 8px;
font-size: 12px;
}
td code {
font-size: 12px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 22px;
border-radius: 999px;
padding: 2px 8px;
background: var(--panel-alt);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.pill.ok {
background: #e8f5ef;
color: var(--ok);
}
.pill.warn {
background: #fff4df;
color: var(--warning);
}
.pill.block {
background: #fdeceb;
color: var(--danger);
}
.side {
display: grid;
gap: 18px;
}
.details {
padding: 14px;
}
.kv {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 8px;
margin: 8px 0;
font-size: 13px;
}
.kv span {
color: var(--muted);
}
.actions {
display: grid;
gap: 10px;
padding: 14px;
}
.workflow-stages {
display: grid;
gap: 10px;
padding: 14px;
}
.action {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
}
.action h3 {
margin: 0 0 5px;
font-size: 13px;
}
.stage-title {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.stage-title h3 {
margin: 0;
}
.action p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.action .action-meta,
.hint {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.action pre {
margin: 9px 0 0;
padding: 8px;
overflow: auto;
background: #101820;
color: #edf3f7;
border-radius: 6px;
font-size: 12px;
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
}
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(16, 24, 32, 0.42);
}
.modal-backdrop[hidden] {
display: none;
}
.modal {
width: min(460px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 18px 52px rgba(16, 24, 32, 0.28);
}
.modal-head,
.modal-body,
.modal-actions {
padding: 14px;
}
.modal-head {
border-bottom: 1px solid var(--line);
}
.modal-head h2 {
margin: 0;
font-size: 16px;
}
.modal-body p {
margin: 0 0 10px;
color: var(--muted);
font-size: 13px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--line);
}
@media (max-width: 1100px) {
.toolbar,
.layout,
.summary,
.insight-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1>GovOPlaN Release Console</h1>
<p>Local release dashboard with guarded candidate generation and publication workflow actions.</p>
</header>
<main>
<div class="toolbar">
<div>
<label for="channel">Channel</label>
<input id="channel" type="text" value="stable" />
</div>
<label class="check"><input id="publicCatalog" type="checkbox" checked /> Published channel</label>
<label class="check"><input id="online" type="checkbox" /> Remote tags</label>
<label class="check"><input id="migrations" type="checkbox" /> Migrations</label>
<label class="check"><input id="website" type="checkbox" /> Website repo</label>
<button id="refresh">Refresh</button>
</div>
<div class="toolbar" style="grid-template-columns: 1fr;">
<div class="muted" id="statusLine">Loading...</div>
</div>
<div class="summary" id="summary"></div>
<div class="layout">
<div class="main-stack">
<section>
<div class="section-head">
<h2>Release Control</h2>
<small id="releaseControlStatus"></small>
</div>
<div class="insight-grid" id="releaseInsights"></div>
<div class="issue-list" id="releaseIssues"></div>
</section>
<section>
<div class="section-head">
<h2>Published Modules</h2>
<small id="publishedModuleCount"></small>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Module</th>
<th>Version</th>
<th>Repo</th>
<th>Local</th>
<th>Interfaces</th>
<th>Migration</th>
<th>Refs</th>
</tr>
</thead>
<tbody id="publishedModules"></tbody>
</table>
</div>
</section>
<section>
<div class="section-head">
<h2>Compatibility</h2>
<small id="compatibilityStatus"></small>
</div>
<div class="table-wrap">
<table class="narrow-table">
<thead>
<tr>
<th>Consumer</th>
<th>Interface</th>
<th>Range</th>
<th>Status</th>
<th>Provider</th>
</tr>
</thead>
<tbody id="compatibilityRows"></tbody>
</table>
</div>
</section>
<section>
<div class="section-head">
<h2>Repositories</h2>
<small id="unitCount"></small>
</div>
<div class="units-toolbar">
<button id="buildSelectedPlan">Build Plan</button>
<button id="generateSelectedCandidate">Generate Candidate</button>
<button class="secondary" id="selectUnpushedUnits" title="Select repositories with committed branch changes to push.">Select Unpushed</button>
<button class="secondary" id="selectChangedUnits" title="Select repositories with dirty/untracked paths, missing HEAD, or unpushed commits.">Select Dirty/Changed</button>
<button class="secondary" id="selectUnreleasedUnits">Select Unreleased</button>
<button class="secondary" id="clearSelectedUnits">Clear</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Release</th>
<th>Repository</th>
<th>Type</th>
<th>Current</th>
<th class="target-column">Target</th>
<th>Target tag</th>
<th>Branch / Upstream</th>
<th>Git</th>
<th>Notes</th>
</tr>
</thead>
<tbody id="releaseUnits"></tbody>
</table>
</div>
</section>
</div>
<div class="side">
<section>
<div class="section-head">
<h2>Release Workflow</h2>
<small id="workflowStageStatus"></small>
</div>
<div class="workflow-stages" id="workflowStages"></div>
</section>
<section>
<div class="section-head">
<h2>1. Git Sync</h2>
<small id="pushStatus"></small>
</div>
<div class="details">
<p class="hint">Sync selected fetches remote refs and tags only. It does not merge, rebase, commit, tag, or push.</p>
<div class="form-grid">
<div>
<label for="pushRemote">Remote</label>
<input id="pushRemote" type="text" value="origin" />
</div>
<div>
<label for="pushConfirmText">Confirm</label>
<input id="pushConfirmText" type="text" placeholder="SYNC or PUSH" />
</div>
</div>
<div class="button-row">
<button class="secondary" id="previewRepoSync">Preview Sync</button>
<button id="syncRepos">Sync Selected</button>
<button class="secondary" id="previewRepoPush">Preview Push</button>
<button class="danger" id="pushRepos">Push Selected</button>
</div>
<div class="actions" id="pushOutput"></div>
</div>
</section>
<section>
<div class="section-head">
<h2>2. Prepare Changes</h2>
<small id="prepareStatus"></small>
</div>
<div class="details">
<p class="hint">Prepare commits selected dirty worktrees only. It does not tag or push.</p>
<div class="form-grid">
<div>
<label for="prepareMessage">Commit message</label>
<input id="prepareMessage" type="text" placeholder="default Release <repo> <tag>" />
</div>
<div>
<label for="prepareConfirmText">Confirm</label>
<input id="prepareConfirmText" type="text" placeholder="COMMIT" />
</div>
</div>
<div class="button-row">
<button class="secondary" id="previewPrepare">Preview Prepare</button>
<button class="danger" id="commitPrepare">Commit Selected</button>
</div>
<div class="actions" id="prepareOutput"></div>
</div>
</section>
<section>
<div class="section-head">
<h2>3. Release Tag Plan</h2>
<small id="planStatus"></small>
</div>
<div class="actions" id="actions"></div>
</section>
<section>
<div class="section-head">
<h2>4. Signed Release</h2>
<small id="workflowStatus"></small>
</div>
<div class="details">
<div class="form-grid">
<div>
<label for="signingKey">Signing key</label>
<input id="signingKey" type="text" placeholder="default release-key-1" />
</div>
<div>
<label for="candidateDir">Candidate dir</label>
<input id="candidateDir" type="text" placeholder="runtime/release-candidates/..." />
</div>
<div>
<label for="confirmText">Confirm</label>
<input id="confirmText" type="text" placeholder="APPLY or PUSH" />
</div>
</div>
<div class="button-row">
<button id="generateCandidate">Generate</button>
<button class="secondary" id="previewPublish">Preview</button>
<button class="secondary" id="applyPublish">Apply + Tag</button>
<button class="danger" id="pushPublish">Push</button>
</div>
<div class="actions" id="workflowOutput"></div>
</div>
</section>
<section>
<div class="section-head">
<h2>Catalog State</h2>
<small id="catalogStatus"></small>
</div>
<div class="details" id="catalog"></div>
</section>
<section>
<div class="section-head">
<h2>5. Install Catalog</h2>
<small>planned</small>
</div>
<div class="details">
<p class="hint">Future work: signed module archives and an installer/update catalog for deployments.</p>
</div>
</section>
</div>
</div>
</main>
<div class="modal-backdrop" id="manualTargetModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="manualTargetTitle">
<div class="modal-head">
<h2 id="manualTargetTitle">Manual Target Version</h2>
</div>
<div class="modal-body">
<p id="manualTargetHelp"></p>
<label for="manualTargetInput">Version</label>
<input id="manualTargetInput" type="text" placeholder="0.2.0-alpha1" />
<p id="manualTargetError" class="error"></p>
</div>
<div class="modal-actions">
<button class="secondary" id="cancelManualTarget">Cancel</button>
<button id="applyManualTarget">Apply</button>
</div>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get("token") || sessionStorage.getItem("releaseConsoleToken") || "";
if (token) {
sessionStorage.setItem("releaseConsoleToken", token);
}
const elements = {
channel: document.getElementById("channel"),
publicCatalog: document.getElementById("publicCatalog"),
online: document.getElementById("online"),
migrations: document.getElementById("migrations"),
website: document.getElementById("website"),
statusLine: document.getElementById("statusLine"),
summary: document.getElementById("summary"),
releaseControlStatus: document.getElementById("releaseControlStatus"),
releaseInsights: document.getElementById("releaseInsights"),
releaseIssues: document.getElementById("releaseIssues"),
publishedModuleCount: document.getElementById("publishedModuleCount"),
publishedModules: document.getElementById("publishedModules"),
compatibilityStatus: document.getElementById("compatibilityStatus"),
compatibilityRows: document.getElementById("compatibilityRows"),
releaseUnits: document.getElementById("releaseUnits"),
unitCount: document.getElementById("unitCount"),
workflowStageStatus: document.getElementById("workflowStageStatus"),
workflowStages: document.getElementById("workflowStages"),
catalog: document.getElementById("catalog"),
catalogStatus: document.getElementById("catalogStatus"),
actions: document.getElementById("actions"),
planStatus: document.getElementById("planStatus"),
signingKey: document.getElementById("signingKey"),
candidateDir: document.getElementById("candidateDir"),
confirmText: document.getElementById("confirmText"),
workflowStatus: document.getElementById("workflowStatus"),
workflowOutput: document.getElementById("workflowOutput"),
generateCandidate: document.getElementById("generateCandidate"),
generateSelectedCandidate: document.getElementById("generateSelectedCandidate"),
previewPublish: document.getElementById("previewPublish"),
applyPublish: document.getElementById("applyPublish"),
pushPublish: document.getElementById("pushPublish"),
previewRepoSync: document.getElementById("previewRepoSync"),
syncRepos: document.getElementById("syncRepos"),
previewRepoPush: document.getElementById("previewRepoPush"),
pushRepos: document.getElementById("pushRepos"),
pushRemote: document.getElementById("pushRemote"),
pushConfirmText: document.getElementById("pushConfirmText"),
pushStatus: document.getElementById("pushStatus"),
pushOutput: document.getElementById("pushOutput"),
prepareMessage: document.getElementById("prepareMessage"),
prepareConfirmText: document.getElementById("prepareConfirmText"),
prepareStatus: document.getElementById("prepareStatus"),
prepareOutput: document.getElementById("prepareOutput"),
previewPrepare: document.getElementById("previewPrepare"),
commitPrepare: document.getElementById("commitPrepare"),
buildSelectedPlan: document.getElementById("buildSelectedPlan"),
selectUnpushedUnits: document.getElementById("selectUnpushedUnits"),
selectChangedUnits: document.getElementById("selectChangedUnits"),
selectUnreleasedUnits: document.getElementById("selectUnreleasedUnits"),
clearSelectedUnits: document.getElementById("clearSelectedUnits"),
manualTargetModal: document.getElementById("manualTargetModal"),
manualTargetHelp: document.getElementById("manualTargetHelp"),
manualTargetInput: document.getElementById("manualTargetInput"),
manualTargetError: document.getElementById("manualTargetError"),
cancelManualTarget: document.getElementById("cancelManualTarget"),
applyManualTarget: document.getElementById("applyManualTarget"),
refresh: document.getElementById("refresh"),
};
const releaseState = {
rows: {},
dashboard: null,
manualRepo: "",
};
elements.refresh.addEventListener("click", load);
elements.generateCandidate.addEventListener("click", generateCandidate);
elements.generateSelectedCandidate.addEventListener("click", generateCandidate);
elements.previewPublish.addEventListener("click", () => publishCandidate({}));
elements.applyPublish.addEventListener("click", () => publishCandidate({ apply: true, commit: true, tag: true }));
elements.pushPublish.addEventListener("click", () => publishCandidate({ apply: true, commit: true, tag: true, push: true }));
elements.previewRepoSync.addEventListener("click", () => syncSelectedRepos({ apply: false }));
elements.syncRepos.addEventListener("click", () => syncSelectedRepos({ apply: true }));
elements.previewRepoPush.addEventListener("click", () => pushSelectedRepos({ apply: false }));
elements.pushRepos.addEventListener("click", () => pushSelectedRepos({ apply: true }));
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan);
elements.selectUnpushedUnits.addEventListener("click", selectUnpushedUnits);
elements.selectChangedUnits.addEventListener("click", selectChangedUnits);
elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits);
elements.clearSelectedUnits.addEventListener("click", clearSelectedUnits);
elements.cancelManualTarget.addEventListener("click", closeManualTarget);
elements.applyManualTarget.addEventListener("click", applyManualTarget);
elements.manualTargetInput.addEventListener("input", validateManualTarget);
elements.candidateDir.addEventListener("input", () => {
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
});
function api(path) {
const query = new URLSearchParams();
if (elements.channel.value.trim()) query.set("channel", elements.channel.value.trim());
if (path === "/api/selective-plan") {
const selected = selectedRepoVersions();
const repoNames = Object.keys(selected);
if (repoNames.length) {
query.set("repos", repoNames.join(","));
query.set("repo_versions", Object.entries(selected).map(([repo, version]) => `${repo}:${version}`).join(","));
}
}
if (elements.publicCatalog.checked) query.set("public_catalog", "true");
else query.set("public_catalog", "false");
if (elements.online.checked) query.set("remote_tags", "true");
if (elements.migrations.checked) query.set("include_migrations", "true");
if (elements.website.checked && path === "/api/dashboard") query.set("include_website", "true");
const suffix = query.toString() ? `?${query}` : "";
return fetch(`${path}${suffix}`, {
headers: token ? { "X-Release-Console-Token": token } : {},
}).then((response) => {
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
});
}
function postJson(path, body) {
return fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { "X-Release-Console-Token": token } : {}),
},
body: JSON.stringify(body),
}).then((response) => {
if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => {
throw new Error(payload.detail || `${response.status} ${response.statusText}`);
});
}
return response.json();
});
}
async function load() {
elements.statusLine.textContent = "Refreshing...";
try {
const [dashboard, intelligence] = await Promise.all([api("/api/dashboard"), api("/api/release-intelligence")]);
renderDashboard(dashboard);
renderReleaseIntelligence(intelligence);
renderIdlePlan();
elements.statusLine.textContent = `Generated ${dashboard.generated_at}`;
} catch (error) {
elements.statusLine.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
}
}
function renderDashboard(data) {
const s = data.summary;
elements.summary.innerHTML = [
metric("Status", s.status),
metric("Repos", s.repository_count),
metric("Dirty", s.dirty_count),
metric("Ahead", s.ahead_count),
metric("Behind", s.behind_count),
metric("No HEAD", s.no_head_count),
metric("Contracts", data.contracts.length),
].join("");
releaseState.dashboard = data;
renderReleaseUnits(data);
renderWorkflowStages(data);
renderCatalog(data.catalog, data.target_version);
}
function renderWorkflowStages(data) {
const s = data.summary || {};
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;
const gitBlockers = (s.missing_count || 0) + (s.safe_directory_count || 0);
const branchDrift = (s.ahead_count || 0) + (s.behind_count || 0);
const releaseBlockers = gitBlockers + (s.behind_count || 0) + (s.no_head_count || 0) + (s.dirty_count || 0);
const catalog = data.catalog || {};
const hasCandidate = Boolean(elements.candidateDir?.value.trim());
const catalogPublished = catalog.catalog_matches_public === true && catalog.keyring_matches_public === true;
const gitStatus = gitBlockers ? "blocked" : branchDrift ? "sync" : "current";
const tagStatus = releaseBlockers ? "blocked" : selected ? "plan ready" : "select repos";
const signedStatus = hasCandidate ? "candidate" : "waiting";
const catalogStatus = catalogPublished ? "published" : catalog.public_checked ? "review" : "local";
const prepareStatus = s.dirty_count ? "dirty" : "clean";
elements.workflowStageStatus.textContent = data.summary?.status || "unknown";
elements.workflowStages.innerHTML = [
workflowStage(
"1. Sync local/remote Git",
gitStatus,
gitBlockers ? "block" : branchDrift ? "warn" : "ok",
`${s.ahead_count || 0} ahead / ${s.behind_count || 0} behind / ${s.dirty_count || 0} dirty repositories`
),
workflowStage(
"2. Prepare changes",
prepareStatus,
s.dirty_count ? "warn" : "ok",
s.dirty_count
? `${s.dirty_count} dirty repositories need explicit prepare commits.`
: "No dirty worktrees in the current dashboard."
),
workflowStage(
"3. Generate release tag",
tagStatus,
releaseBlockers ? "block" : selected ? "ok" : "warn",
selected
? `${selected} selected; cockpit builds the tag plan, release tagging still runs through the guarded release script.`
: "Select repositories, set target versions, then build the release tag plan."
),
workflowStage(
"4. Sign and publish release",
signedStatus,
hasCandidate ? "ok" : "warn",
hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "Generate a signed candidate, preview it, then apply/tag/push the website publication."
),
workflowStage(
"5. Maintain install catalog",
catalogStatus,
catalogPublished ? "ok" : "warn",
"Future: signed module archives for installer and module update workflows."
),
].join("");
}
function workflowStage(title, status, kind, detail) {
return `<div class="action">
<div class="stage-title"><h3>${escapeHtml(title)}</h3>${pill(status, kind)}</div>
<p>${escapeHtml(detail)}</p>
</div>`;
}
function renderReleaseIntelligence(data) {
const summary = data.summary || {};
const catalog = data.catalog || {};
const keyring = data.keyring || {};
elements.releaseControlStatus.textContent = `${data.catalog_source || "local"} catalog / ${data.keyring_source || "local"} keyring`;
elements.releaseInsights.innerHTML = [
insight("Health", summary.status || "-"),
insight("Published Modules", summary.published_modules ?? 0),
insight("Catalog", catalog.generated_at ? `seq ${catalog.sequence || "-"}` : "-"),
insight("Core", catalog.core_version || "-"),
insight("Signatures", `${summary.trusted_signatures ?? 0}/${catalog.signature_count ?? 0} trusted`),
insight("Keys", `${keyring.active_key_count ?? 0}/${keyring.key_count ?? 0} active`),
insight("Catalog Drift", stateText(summary.catalog_matches_public)),
insight("Keyring Drift", stateText(summary.keyring_matches_public)),
].join("");
renderReleaseIssues(data.issues || []);
renderPublishedModules(data.modules || []);
renderCompatibility(data.compatibility || []);
}
function renderReleaseIssues(issues) {
if (!issues.length) {
elements.releaseIssues.innerHTML = `<div class="muted">No release health issues detected.</div>`;
return;
}
elements.releaseIssues.innerHTML = issues.map((issue) => {
const kind = issue.severity === "blocker" ? "block" : issue.severity === "warning" ? "warn" : "ok";
return `<div class="action">
<h3>${pill(issue.severity, kind)} ${escapeHtml(issue.code)}</h3>
<p>${escapeHtml(issue.message)}</p>
</div>`;
}).join("");
}
function renderPublishedModules(modules) {
elements.publishedModuleCount.textContent = `${modules.length} in channel`;
if (!modules.length) {
elements.publishedModules.innerHTML = `<tr><td colspan="7" class="muted">No published modules found in the selected channel.</td></tr>`;
return;
}
elements.publishedModules.innerHTML = modules.map((module) => {
const localState = module.local_matches_catalog === true ? pill(module.local_version, "ok") : module.local_matches_catalog === false ? pill(module.local_version || "differs", "warn") : pill("unknown", "warn");
const interfaces = `${(module.provides_interfaces || []).length} provided / ${(module.requires_interfaces || []).length} required`;
const refs = [module.python_tag ? `py ${module.python_tag}` : "", module.webui_tag ? `ui ${module.webui_tag}` : ""].filter(Boolean).join(", ") || "-";
return `<tr>
<td><strong>${escapeHtml(module.module_id || "-")}</strong><br><span class="muted">${escapeHtml(module.name || "")}</span></td>
<td>${escapeHtml(module.version || "-")}</td>
<td>${escapeHtml(module.repo || "-")}</td>
<td>${localState}</td>
<td>${escapeHtml(interfaces)}</td>
<td>${escapeHtml(module.migration_safety || "-")}</td>
<td>${escapeHtml(refs)}</td>
</tr>`;
}).join("");
}
function renderCompatibility(rows) {
const blockers = rows.filter((row) => row.severity === "blocker").length;
const warnings = rows.filter((row) => row.severity === "warning").length;
elements.compatibilityStatus.textContent = `${blockers} blockers / ${warnings} warnings / ${rows.length} requirements`;
if (!rows.length) {
elements.compatibilityRows.innerHTML = `<tr><td colspan="5" class="muted">No catalog interface requirements found.</td></tr>`;
return;
}
elements.compatibilityRows.innerHTML = rows.map((row) => {
const kind = row.severity === "blocker" ? "block" : row.severity === "warning" ? "warn" : "ok";
const providers = Array.isArray(row.satisfied_by) && row.satisfied_by.length
? row.satisfied_by.map((provider) => `${provider.module_id || provider.repo || "-"} ${provider.version || ""}`).join(", ")
: Array.isArray(row.providers) && row.providers.length
? row.providers.map((provider) => `${provider.module_id || provider.repo || "-"} ${provider.version || ""}`).join(", ")
: "-";
return `<tr>
<td>${escapeHtml(row.module_id || row.repo || "-")}</td>
<td>${escapeHtml(row.interface || "-")}</td>
<td>${escapeHtml(row.requirement || "any")}</td>
<td>${pill(row.status || "-", kind)}</td>
<td>${escapeHtml(providers)}</td>
</tr>`;
}).join("");
}
function renderReleaseUnits(data) {
const units = data.repositories
.filter((repo) => repo.spec.name !== "govoplan")
.sort((a, b) => a.spec.name.localeCompare(b.spec.name));
elements.releaseUnits.innerHTML = units.length
? units.map(releaseUnitRow).join("")
: `<tr><td colspan="9" class="muted">No repositories found in the dashboard response.</td></tr>`;
updateUnitCount();
for (const input of elements.releaseUnits.querySelectorAll("[data-release-check]")) {
input.addEventListener("change", () => {
const repo = input.dataset.repo;
releaseState.rows[repo] = { ...releaseState.rows[repo], selected: input.checked };
updateUnitCount();
});
}
for (const button of elements.releaseUnits.querySelectorAll("[data-target-action]")) {
button.addEventListener("click", () => {
const repo = button.dataset.repo;
const action = button.dataset.targetAction;
if (!repo || !action) return;
if (action === "manual") openManualTarget(repo);
else if (action === "reset") resetTarget(repo);
else bumpTarget(action, repo);
});
}
}
function releaseUnitRow(repo) {
const current = primaryVersion(repo);
const stored = releaseState.rows[repo.spec.name] || {};
const selected = stored.selected === true;
const hasStoredTarget = Object.prototype.hasOwnProperty.call(stored, "target");
const target = hasStoredTarget ? stored.target : defaultTargetFor(repo);
const git = gitStateText(repo);
const branchCell = `${escapeHtml(repo.branch || "-")}<br><span class="muted">${escapeHtml(repo.upstream || "no upstream")}</span>`;
const targetTag = targetTagFor(target);
const notes = [
current ? "" : "initial release",
repo.errors && repo.errors.length ? repo.errors.join("; ") : "",
repo.safe_directory_required ? "safe.directory required" : "",
].filter(Boolean).join("; ") || "-";
const severity = !repo.exists || !repo.is_git || repo.behind || !repo.has_head || repo.safe_directory_required ? "block" : repo.dirty_entries.length || repo.ahead ? "warn" : "ok";
releaseState.rows[repo.spec.name] = hasStoredTarget ? { selected, target } : { selected };
return `<tr>
<td><input class="unit-check" type="checkbox" data-release-check data-repo="${escapeAttr(repo.spec.name)}" ${selected ? "checked" : ""} /></td>
<td><strong>${escapeHtml(repo.spec.name)}</strong></td>
<td>${escapeHtml(repo.spec.category)} / ${escapeHtml(repo.spec.subtype)}</td>
<td>${current ? escapeHtml(current) : pill("not released", "warn")}</td>
<td>
<div class="target-cell">
<span class="target-value" data-release-target data-repo="${escapeAttr(repo.spec.name)}" data-current="${escapeAttr(current)}" data-target-value="${escapeAttr(target)}">${escapeHtml(target)}</span>
<div class="target-controls row-target-controls">
<button class="secondary" data-target-action="major" data-repo="${escapeAttr(repo.spec.name)}" title="Next major version">x..</button>
<button class="secondary" data-target-action="minor" data-repo="${escapeAttr(repo.spec.name)}" title="Next minor version">.x.</button>
<button class="secondary" data-target-action="patch" data-repo="${escapeAttr(repo.spec.name)}" title="Next subversion">..x</button>
<button class="secondary" data-target-action="manual" data-repo="${escapeAttr(repo.spec.name)}" title="Manual target version">m</button>
<button class="secondary" data-target-action="reset" data-repo="${escapeAttr(repo.spec.name)}" title="Reset row target">&#8630;</button>
</div>
</div>
</td>
<td><span class="target-tag-value" data-target-tag data-repo="${escapeAttr(repo.spec.name)}">${escapeHtml(targetTag)}</span></td>
<td>${branchCell}</td>
<td>${pill(git, severity)}</td>
<td>${escapeHtml(notes)}</td>
</tr>`;
}
function targetTagFor(target) {
return target ? `v${String(target).replace(/^v/, "")}` : "-";
}
function defaultTargetFor(repo) {
return primaryVersion(repo) || "0.1.0";
}
function primaryVersion(repo) {
const versions = repo.versions || {};
if (versions.pyproject) return versions.pyproject;
if (versions.package) return versions.package;
if (versions.webui_package) return versions.webui_package;
if (Array.isArray(versions.manifests) && versions.manifests.length) return versions.manifests[0];
return "";
}
function updateUnitCount() {
const total = elements.releaseUnits.querySelectorAll("[data-release-check]").length;
const selected = elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length;
elements.unitCount.textContent = `${selected}/${total} selected`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function bumpTarget(kind, repoName) {
const base = baseVersionForTarget(repoName);
const parsed = parseVersionCore(base);
const next = parsed ? bumpParsedVersion(parsed, kind) : "0.1.0";
setRowTarget(repoName, next);
elements.statusLine.textContent = `${repoName} target set to ${next}`;
}
function bumpParsedVersion(parsed, kind) {
const next = [...parsed];
if (kind === "major") {
next[0] += 1;
next[1] = 0;
next[2] = 0;
} else if (kind === "minor") {
next[1] += 1;
next[2] = 0;
} else {
next[2] += 1;
}
return next.join(".");
}
function baseVersionForTarget(repoName) {
return currentVersionForRepo(repoName) || "0.0.0";
}
function openManualTarget(repoName) {
releaseState.manualRepo = repoName;
const base = baseVersionForTarget(repoName);
elements.manualTargetHelp.textContent = `Enter a version for ${repoName} higher than ${base} by its first three numbers. Suffixes like -alpha1 are allowed.`;
elements.manualTargetInput.value = currentRowTarget(repoName);
elements.manualTargetModal.hidden = false;
validateManualTarget();
elements.manualTargetInput.focus();
}
function closeManualTarget() {
elements.manualTargetModal.hidden = true;
releaseState.manualRepo = "";
}
function applyManualTarget() {
if (!validateManualTarget()) return;
const repoName = releaseState.manualRepo;
setRowTarget(repoName, elements.manualTargetInput.value.trim());
closeManualTarget();
elements.statusLine.textContent = `${repoName} target set to ${currentRowTarget(repoName)}`;
}
function validateManualTarget() {
const value = elements.manualTargetInput.value.trim();
const parsed = parseVersionCore(value);
const base = parseVersionCore(baseVersionForTarget(releaseState.manualRepo)) || [0, 0, 0];
let message = "";
if (!parsed) {
message = "Use a version like 0.2.0 or 0.2.0-alpha1.";
} else if (!versionCoreGreater(parsed, base)) {
message = `Version must be higher than ${base.join(".")} by the first three numbers.`;
}
elements.manualTargetError.textContent = message;
elements.applyManualTarget.disabled = Boolean(message);
return !message;
}
function resetTarget(repoName) {
const row = { ...releaseState.rows[repoName] };
delete row.target;
releaseState.rows[repoName] = row;
const repo = repoSnapshot(repoName);
const target = repo ? defaultTargetFor(repo) : "0.1.0";
const element = rowTargetElement(repoName);
if (element) {
element.dataset.targetValue = target;
element.textContent = target;
}
setRowTargetTag(repoName, target);
elements.statusLine.textContent = `${repoName} target reset.`;
}
function currentRowTarget(repoName) {
return rowTargetElement(repoName)?.dataset.targetValue?.trim() || releaseState.rows[repoName]?.target || "";
}
function setRowTarget(repoName, value) {
releaseState.rows[repoName] = { ...releaseState.rows[repoName], target: value };
const element = rowTargetElement(repoName);
if (element) {
element.dataset.targetValue = value;
element.textContent = value;
}
setRowTargetTag(repoName, value);
}
function rowTargetElement(repoName) {
return elements.releaseUnits.querySelector(`[data-release-target][data-repo="${cssEscape(repoName)}"]`);
}
function setRowTargetTag(repoName, target) {
const element = rowTargetTagElement(repoName);
if (element) element.textContent = targetTagFor(target);
}
function rowTargetTagElement(repoName) {
return elements.releaseUnits.querySelector(`[data-target-tag][data-repo="${cssEscape(repoName)}"]`);
}
function repoSnapshot(repoName) {
return releaseState.dashboard?.repositories.find((repo) => repo.spec.name === repoName) || null;
}
function currentVersionForRepo(repoName) {
const repo = repoSnapshot(repoName);
return repo ? primaryVersion(repo) : "";
}
function parseVersionCore(value) {
const match = String(value || "").match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
}
function versionCoreGreater(candidate, base) {
for (let index = 0; index < 3; index += 1) {
if (candidate[index] > base[index]) return true;
if (candidate[index] < base[index]) return false;
}
return false;
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`;
}
function insight(label, value) {
return `<div class="insight"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`;
}
function stateText(value) {
if (value === true) return "matches";
if (value === false) return "differs";
return "not checked";
}
function repoRow(repo) {
const version = primaryVersion(repo) || "-";
const branch = repo.upstream ? `${repo.branch || "-"} -> ${repo.upstream}` : repo.branch || "-";
const git = gitStateText(repo);
const tag = repo.local_target_tag_exists === null
? "-"
: repo.local_target_tag_exists
? pill("local", "ok")
: pill("missing", "warn");
const notes = [
repo.errors && repo.errors.length ? repo.errors.join("; ") : "",
repo.safe_directory_required ? "safe.directory required" : "",
].filter(Boolean).join("; ") || "-";
const severity = !repo.exists || !repo.is_git || repo.behind || repo.safe_directory_required ? "block" : repo.dirty_entries.length || !repo.has_head || repo.ahead ? "warn" : "ok";
return `<tr>
<td><strong>${escapeHtml(repo.spec.name)}</strong></td>
<td>${escapeHtml(repo.spec.category)} / ${escapeHtml(repo.spec.subtype)}</td>
<td>${escapeHtml(branch)}</td>
<td>${escapeHtml(version)}</td>
<td>${pill(git, severity)}</td>
<td>${tag}</td>
<td>${escapeHtml(notes)}</td>
</tr>`;
}
function gitStateText(repo) {
if (!repo.exists || !repo.is_git) return "missing";
const parts = [];
if (repo.has_head) parts.push(repo.head || "HEAD");
else parts.push("no HEAD");
if (repo.dirty_entries.length) parts.push(`${repo.dirty_entries.length} dirty/untracked`);
if (repo.ahead) parts.push(`${repo.ahead} unpushed`);
if (repo.behind) parts.push(`${repo.behind} behind`);
if (repo.has_head && repo.branch && !repo.upstream) parts.push("no upstream");
return parts.join(", ") || "clean";
}
function renderCatalog(catalog, targetVersion) {
elements.catalogStatus.textContent = catalog.catalog_exists ? "local file found" : "missing";
elements.catalog.innerHTML = [
kv("Channel", catalog.channel),
kv("Core release", `${catalog.core_release_version || "-"}${targetVersion && catalog.core_release_version !== targetVersion ? " (not target)" : ""}`),
kv("Modules", catalog.module_count),
kv("Signatures", catalog.signature_count),
kv("Keys", catalog.key_count),
kv("Generated", catalog.generated_at || "-"),
kv("Expires", catalog.expires_at || "-"),
kv("Public catalog", catalog.public_checked ? publicState(catalog.public_catalog_exists, catalog.catalog_matches_public) : "not checked"),
kv("Public keyring", catalog.public_checked ? publicState(catalog.public_keyring_exists, catalog.keyring_matches_public) : "not checked"),
kv("Public core", catalog.public_core_release_version || "-"),
kv("Catalog", catalog.catalog_path),
kv("Keyring", catalog.keyring_path),
kv("Public URL", catalog.public_catalog_url || "-"),
kv("Keyring URL", catalog.public_keyring_url || "-"),
].join("");
}
function renderPlan(plan) {
elements.planStatus.textContent = plan.status;
const units = plan.units || [];
const compatibility = plan.compatibility || [];
const steps = plan.dry_run_steps || [];
if (!units.length && !compatibility.length && !steps.length) {
elements.actions.innerHTML = `<div class="muted">No selective release candidates. Select repositories above to plan a release.</div>`;
return;
}
const unitHtml = units.map((unit) => {
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
const kind = unit.status === "blocked" ? "block" : unit.status === "attention" ? "warn" : "ok";
return `<div class="action">
<h3>${pill(unit.status, kind)} ${escapeHtml(unit.repo)} ${escapeHtml(unit.current_version || "-")} -> ${escapeHtml(unit.target_version)}</h3>
${blockers}${warnings}
</div>`;
}).join("");
const compatibilityHtml = compatibility.map((issue) => {
const kind = issue.severity === "blocker" ? "block" : issue.severity === "warning" ? "warn" : "ok";
return `<div class="action">
<h3>${pill(issue.severity, kind)} ${escapeHtml(issue.code)}</h3>
<p>${escapeHtml(issue.message)}</p>
</div>`;
}).join("");
const stepHtml = steps.map((step) => {
const command = step.command ? `<pre>${escapeHtml(step.command)}</pre>` : "";
const kind = step.status === "planned" ? "ok" : step.status.includes("blocked") ? "block" : "warn";
return `<div class="action">
<h3>${pill(step.status, kind)} ${escapeHtml(step.title)}</h3>
<p>${escapeHtml(step.detail)}</p>
${command}
</div>`;
}).join("");
elements.actions.innerHTML = `
${unitHtml}
${compatibilityHtml}
${stepHtml}
`;
}
function renderIdlePlan() {
elements.planStatus.textContent = "idle";
elements.actions.innerHTML = `<div class="muted">Select repositories above, adjust target versions, then build a plan.</div>`;
}
async function generateCandidate() {
elements.workflowStatus.textContent = "Generating...";
setBusy([elements.generateCandidate, elements.generateSelectedCandidate], true);
try {
const repoVersions = selectedRepoVersions();
if (!Object.keys(repoVersions).length) {
throw new Error("Select at least one repository first.");
}
const body = {
repo_versions: repoVersions,
channel: elements.channel.value.trim() || "stable",
signing_keys: elements.signingKey.value.trim() ? [elements.signingKey.value.trim()] : [],
check_public: elements.publicCatalog.checked,
};
const result = await postJson("/api/catalog-candidates", body);
elements.candidateDir.value = result.candidate_dir || "";
elements.workflowStatus.textContent = result.status;
renderWorkflowResult("Candidate generated", result);
await load();
} catch (error) {
elements.workflowStatus.textContent = "error";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Candidate generation failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.generateCandidate, elements.generateSelectedCandidate], false);
}
}
async function buildSelectedPlan() {
elements.planStatus.textContent = "Planning...";
setBusy([elements.buildSelectedPlan], true);
try {
if (!Object.keys(selectedRepoVersions()).length) {
elements.planStatus.textContent = "idle";
elements.actions.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more rows in the repository table first.</p></div>`;
return;
}
const plan = await api("/api/selective-plan");
renderPlan(plan);
elements.planStatus.textContent = plan.status;
} catch (error) {
elements.planStatus.textContent = "error";
elements.actions.innerHTML = `<div class="action"><h3>${pill("error", "block")} Plan failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.buildSelectedPlan], false);
}
}
function selectChangedUnits() {
if (!releaseState.dashboard) return;
let count = 0;
for (const repo of releaseState.dashboard.repositories) {
const current = primaryVersion(repo);
const changed = repo.dirty_entries.length || repo.ahead || !repo.has_head;
if (changed && repo.spec.name !== "govoplan") count += 1;
releaseState.rows[repo.spec.name] = {
...releaseState.rows[repo.spec.name],
selected: Boolean(changed),
target: releaseState.rows[repo.spec.name]?.target || current || "0.1.0",
};
}
renderReleaseUnits(releaseState.dashboard);
elements.statusLine.textContent = `Selected ${count} dirty/changed repositories. Dirty files still need a commit before they can be pushed.`;
}
function selectUnpushedUnits() {
if (!releaseState.dashboard) return;
let count = 0;
for (const repo of releaseState.dashboard.repositories) {
const current = primaryVersion(repo);
const unpushed = repo.has_head && repo.branch && (!repo.upstream || Number(repo.ahead || 0) > 0);
if (unpushed && repo.spec.name !== "govoplan") count += 1;
releaseState.rows[repo.spec.name] = {
...releaseState.rows[repo.spec.name],
selected: Boolean(unpushed),
target: releaseState.rows[repo.spec.name]?.target || current || "0.1.0",
};
}
renderReleaseUnits(releaseState.dashboard);
elements.statusLine.textContent = `Selected ${count} repositories with committed branch work to push.`;
}
function selectUnreleasedUnits() {
if (!releaseState.dashboard) return;
for (const repo of releaseState.dashboard.repositories) {
if (repo.spec.name === "govoplan") continue;
const unreleased = !primaryVersion(repo);
releaseState.rows[repo.spec.name] = {
...releaseState.rows[repo.spec.name],
selected: unreleased,
target: releaseState.rows[repo.spec.name]?.target || "0.1.0",
};
}
renderReleaseUnits(releaseState.dashboard);
}
function clearSelectedUnits() {
for (const repo of Object.keys(releaseState.rows)) {
releaseState.rows[repo] = { ...releaseState.rows[repo], selected: false };
}
if (releaseState.dashboard) renderReleaseUnits(releaseState.dashboard);
renderIdlePlan();
}
function selectedRepoNames() {
return Array.from(elements.releaseUnits.querySelectorAll("[data-release-check]:checked"))
.map((row) => row.dataset.repo)
.filter(Boolean);
}
function selectedRepoVersions() {
const result = {};
for (const row of elements.releaseUnits.querySelectorAll("[data-release-check]")) {
if (!row.checked) continue;
const repo = row.dataset.repo;
const target = elements.releaseUnits.querySelector(`[data-release-target][data-repo="${cssEscape(repo)}"]`)?.dataset.targetValue?.trim();
if (repo && target) result[repo] = target;
}
return result;
}
async function prepareSelectedRepos(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.prepareStatus.textContent = "idle";
elements.prepareOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.prepareStatus.textContent = options.apply ? "Committing..." : "Previewing...";
setBusy([elements.previewPrepare, elements.commitPrepare], true);
try {
const result = await postJson("/api/repositories/prepare", {
repos,
repo_versions: selectedRepoVersions(),
message: elements.prepareMessage.value.trim() || null,
apply: options.apply === true,
confirm: elements.prepareConfirmText.value.trim(),
});
elements.prepareStatus.textContent = result.status;
renderPrepareResult(result);
await load();
} catch (error) {
elements.prepareStatus.textContent = "error";
elements.prepareOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Prepare failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewPrepare, elements.commitPrepare], false);
}
}
function renderPrepareResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.prepareOutput.innerHTML = `<div class="muted">No prepare actions returned.</div>`;
return;
}
elements.prepareOutput.innerHTML = rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "committed" || row.status === "planned" ? "ok" : "warn";
const entries = Array.isArray(row.dirty_entries) ? row.dirty_entries : [];
const shown = entries.slice(0, 10).map((entry) => `<p>${escapeHtml(entry)}</p>`).join("");
const more = entries.length > 10 ? `<p>${escapeHtml(`... ${entries.length - 10} more`)}</p>` : "";
const after = row.after_head ? ` | new HEAD ${row.after_head} | ${row.after_ahead ?? 0} ahead | ${row.after_dirty_count ?? 0} dirty` : "";
const command = row.command && row.status !== "noop" ? `<pre>${escapeHtml(row.command)}</pre>` : "";
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
<p class="action-meta">${escapeHtml(`${row.branch || "-"} -> ${row.upstream || "-"} | ${row.dirty_count ?? 0} dirty${after}`)}</p>
${row.message ? `<p class="action-meta">${escapeHtml(row.message)}</p>` : ""}
${shown}${more}
${command}
${output}
</div>`;
}).join("");
}
async function syncSelectedRepos(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.pushStatus.textContent = "idle";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.pushStatus.textContent = options.apply ? "Syncing..." : "Previewing sync...";
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], true);
try {
const result = await postJson("/api/repositories/sync", {
repos,
remote: elements.pushRemote.value.trim() || "origin",
apply: options.apply === true,
confirm: elements.pushConfirmText.value.trim(),
});
elements.pushStatus.textContent = result.status;
renderSyncResult(result);
await load();
} catch (error) {
elements.pushStatus.textContent = "error";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Sync failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], false);
}
}
async function pushSelectedRepos(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.pushStatus.textContent = "idle";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.pushStatus.textContent = options.apply ? "Pushing..." : "Previewing...";
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], true);
try {
const result = await postJson("/api/repositories/push", {
repos,
remote: elements.pushRemote.value.trim() || "origin",
apply: options.apply === true,
confirm: elements.pushConfirmText.value.trim(),
});
elements.pushStatus.textContent = result.status;
renderPushResult(result);
await load();
} catch (error) {
elements.pushStatus.textContent = "error";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Push failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], false);
}
}
function renderSyncResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.pushOutput.innerHTML = `<div class="muted">No sync actions returned.</div>`;
return;
}
elements.pushOutput.innerHTML = rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "synced" || row.status === "planned" ? "ok" : "warn";
const before = `${row.ahead ?? 0} ahead / ${row.behind ?? 0} behind / ${row.dirty_count ?? 0} dirty`;
const after = row.after_ahead !== undefined ? ` -> ${row.after_ahead} ahead / ${row.after_behind ?? 0} behind / ${row.after_dirty_count ?? 0} dirty` : "";
const command = row.command ? `<pre>${escapeHtml(row.command)}</pre>` : "";
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
<p class="action-meta">${escapeHtml(`${row.branch || "-"} -> ${row.upstream || row.remote || "-"} | ${before}${after}`)}</p>
${command}
${output}
</div>`;
}).join("");
}
function renderPushResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.pushOutput.innerHTML = `<div class="muted">No push actions returned.</div>`;
return;
}
const summary = result.status === "noop"
? `<div class="action"><h3>${pill("noop", "warn")} Nothing pushed</h3><p>No selected repository had committed local commits ahead of its upstream. Dirty/untracked files are not included in Git pushes.</p></div>`
: "";
elements.pushOutput.innerHTML = summary + rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "planned" || row.status === "pushed" ? "ok" : "warn";
const meta = [
row.branch ? `branch ${row.branch}` : "",
row.upstream ? `upstream ${row.upstream}` : row.push_target ? `target ${row.push_target}` : "",
row.ahead !== undefined ? `${row.ahead} ahead` : "",
row.behind ? `${row.behind} behind` : "",
row.dirty_count ? `${row.dirty_count} dirty/untracked` : "clean worktree",
].filter(Boolean).join(" | ");
const command = row.command && row.status !== "noop" ? `<pre>${escapeHtml(row.command)}</pre>` : "";
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
${meta ? `<p class="action-meta">${escapeHtml(meta)}</p>` : ""}
${command}
${output}
</div>`;
}).join("");
}
async function publishCandidate(options) {
const candidateDir = elements.candidateDir.value.trim();
if (!candidateDir) {
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill("blocked", "block")} Candidate dir required</h3><p>Select or generate a candidate first.</p></div>`;
return;
}
elements.workflowStatus.textContent = "Running...";
setBusy([elements.previewPublish, elements.applyPublish, elements.pushPublish], true);
try {
const result = await postJson("/api/catalog-candidates/publish", {
candidate_dir: candidateDir,
channel: elements.channel.value.trim() || "stable",
confirm: elements.confirmText.value.trim(),
...options,
});
elements.workflowStatus.textContent = result.status;
renderWorkflowResult("Publish result", result);
await load();
} catch (error) {
elements.workflowStatus.textContent = "error";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Publish failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewPublish, elements.applyPublish, elements.pushPublish], false);
}
}
function renderWorkflowResult(title, result) {
const lines = [];
for (const key of ["status", "candidate_dir", "catalog_path", "keyring_path", "target_catalog_path", "tag_name", "validation_valid", "catalog_changed", "keyring_changed"]) {
if (result[key] !== undefined && result[key] !== null) lines.push(kv(key, result[key]));
}
const changes = Array.isArray(result.changes)
? result.changes.slice(0, 8).map((item) => `<p>${escapeHtml(`${item.repo}:${item.module_id || "-"} ${item.field} ${item.before || "-"} -> ${item.after || "-"}`)}</p>`).join("")
: "";
const steps = Array.isArray(result.steps)
? result.steps.map((step) => `<div class="action"><h3>${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.detail)}</p>${step.command ? `<pre>${escapeHtml(step.command)}</pre>` : ""}</div>`).join("")
: "";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${escapeHtml(title)}</h3>${lines.join("")}${changes}</div>${steps}`;
}
function setBusy(buttons, busy) {
for (const button of buttons) {
button.disabled = busy;
}
}
function publicState(exists, matches) {
if (!exists) return "missing";
if (matches === true) return "matches local";
if (matches === false) return "differs from local";
return "available";
}
function kv(key, value) {
return `<div class="kv"><span>${escapeHtml(key)}</span><code>${escapeHtml(String(value))}</code></div>`;
}
function pill(text, kind) {
return `<span class="pill ${kind}">${escapeHtml(String(text))}</span>`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value);
}
function cssEscape(value) {
if (window.CSS && CSS.escape) return CSS.escape(value);
return String(value).replaceAll('"', '\\"');
}
load();
</script>
</body>
</html>