1782 lines
70 KiB
HTML
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">↶</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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
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>
|