Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog — ChannelGate

- Keep sidebar update messages inside the rail, wrapping long details and showing a short commit
revision with the full hash on hover.

- Add System health to the admin UI with real CPU/RAM/load and storage metrics, historical
charts and peaks, storage warnings and capacity estimates, and refreshed hardware inventory.

Expand Down
3 changes: 3 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2491,6 +2491,9 @@ are retired, bullet by bullet; everything else stands.
`refused`, or `failed`. Slack/MCP thread markers are transaction-bound and survive intermediate
boots; only the matching terminal result is posted, with a deterministic Slack message id, then
the marker is removed. → TEST-PLAN: Transactional self-update.
- **Sidebar update text stays inside the rail**: completion, rollback, errors, progress and
sign-in links wrap as normal text, including long unbroken paths. Result revisions show seven
characters with the full hash on hover. → TEST-PLAN: Sidebar update layout (engine-independent).
- **npm advisory policy**: candidate production dependencies are audited after `npm ci`.
Critical/high findings block and roll back the update; moderate findings are reported and
reviewed in the same development cycle. Weekly Dependabot discovery, exception policy, and the
Expand Down
16 changes: 16 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4948,6 +4948,22 @@ Manual checks for the daemon-level behavior:
connected Airtable account. The cases above are ready to transfer using the operator's personal
connection; this is not a claim of live engine acceptance or stable-release readiness.

### Sidebar update layout (engine-independent)

- [x] Chromium acceptance: `CG_BROWSER_MODULE=/absolute/path/to/playwright/index.mjs node --test test/update-ui.test.js`.
Uses the shipped sidebar markup, stylesheet and update renderer in a disposable browser;
no daemon state changes or engine calls. At 1440, 800 and 390 px widths, render updated,
unchanged, rolled-back, refused, failed, image-warning, running preflight and sign-in states.
Fixtures use a 40-character revision and a `/tmp/` error path containing twelve repeated
`long-path-segment` strings. Pass: every rendered text/element rectangle stays within the
sidebar, dots remain 7 px wide, and the sign-in link remains visible. The revision unit case
requires a seven-character label and the complete hash in its tooltip.
- Manual reproduction: open the Admin UI after a completed update, resize to desktop and mobile,
and hover the revision. Pass: no update text crosses into page content; the full revision
remains available on hover. Claude/Codex share this browser-only presentation; neither
engine participates in layout. Automated fixture passed; a real updater run is unnecessary
for this presentation-only change.

### Transactional self-update

- [x] Unit: exclusive reservation, live-owner refusal, dead/abandoned-owner recovery, ownership
Expand Down
4 changes: 3 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3741,7 +3741,9 @@ function updateResultHtml(transaction) {
const cause = transaction.candidateError ? ` Candidate error: ${transaction.candidateError}` : "";
return `<span class="statuschip"><span class="dot warn"></span>${escapeHtml(transaction.reason + cause)}</span>`;
}
const revision = transaction.runningRevision ? ` <code>${escapeHtml(transaction.runningRevision)}</code>` : "";
const revision = transaction.runningRevision
? ` <code title="${escapeHtml(transaction.runningRevision)}">${escapeHtml(transaction.runningRevision.slice(0, 7))}</code>`
: "";
if (transaction.result === "updated" && transaction.imageWarning) {
return `<span class="statuschip"><span class="dot warn"></span>container image needs attention — ${escapeHtml(transaction.imageWarning)}</span>`;
}
Expand Down
3 changes: 2 additions & 1 deletion public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,8 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba(

/* Gateway version chip + one-click update (rail footer) */
#update { margin-top: 4px; display: flex; flex-direction: column; gap: 4px; }
#update .statuschip { padding: 3px 10px; }
#update .statuschip { display: block; overflow-wrap: anywhere; padding: 3px 10px; }
#update .statuschip .dot { display: inline-block; margin-right: 8px; vertical-align: middle; }
.update-btn { font-size: 11.5px; padding: 6px 10px; margin: 2px 10px 0; }

/* Per-model Codex rates table (Settings → Behavior) */
Expand Down
60 changes: 60 additions & 0 deletions test/update-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,63 @@ test("session loss after restart asks for login without leaking or inventing upd
assert.match(el.innerHTML, /completion is not yet verified/);
assert.equal(timers.length, 0);
});

test("completed update shows a short revision with the full hash available on hover", () => {
const { context } = fixture();
const revision = "79ee4e6de60c3ea7be98699deea56922a6f36170";
for (const transaction of [{ result: "updated" }, { result: "updated", changed: false }, { result: "rolled_back" }]) {
const html = context.updateResultHtml({ ...transaction, runningRevision: revision });
assert.ok(html.includes(`<code title="${revision}">79ee4e6</code>`));
}
});

// Use the real sidebar markup, CSS and update renderer: DOM-only tests cannot detect overflow.
test("sidebar update results, progress and login links stay within the rail", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => {
const { chromium } = await import(process.env.CG_BROWSER_MODULE);
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
t.after(() => browser.close());
const page = await browser.newPage();
const index = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
const sidebar = index.match(/<aside class="sidebar">[\s\S]*?<\/aside>/)[0];
const css = readFileSync(new URL("../public/styles.css", import.meta.url), "utf8");
const revision = "79ee4e6de60c3ea7be98699deea56922a6f36170";
const longDetail = `Fixture failure: /tmp/${"long-path-segment".repeat(12)}`;
const states = [
{ status: "terminal", result: "updated", runningRevision: revision },
{ status: "terminal", result: "updated", changed: false, runningRevision: revision },
{ status: "terminal", result: "rolled_back", runningRevision: revision, candidateError: longDetail },
{ status: "terminal", result: "refused", reason: longDetail },
{ status: "terminal", result: "failed", rollbackError: longDetail },
{ status: "terminal", result: "updated", imageWarning: longDetail },
{ id: "running", status: "running", phase: "preflight", requiredDiskBytes: 8 * 1024 ** 3, availableDiskBytes: 12 * 1024 ** 3 },
];
const markup = [];
for (const transaction of states) {
const { context, el } = fixture({ transaction });
await context.loadUpdateStatus();
markup.push(el.innerHTML);
}
const login = fixture();
login.context.fetch = async () => ({ ok: true, json: async () => ({}) });
await login.context.monitorGatewayUpdate("tx", login.el);
markup.push(login.el.innerHTML);
for (const width of [1440, 800, 390]) {
await page.setViewportSize({ width, height: 1000 });
await page.setContent(`<style>${css}</style><div class="app">${sidebar}<main class="content"></main></div>`);
for (const html of markup) {
await page.locator("#update").evaluate((el, value) => { el.innerHTML = value; }, html);
const overflow = await page.locator("#update").evaluate((el) => {
const rail = el.closest(".sidebar").getBoundingClientRect();
const range = globalThis.document.createRange();
range.selectNodeContents(el);
return [...range.getClientRects()].some((rect) => rect.left < rail.left || rect.right > rail.right + 1);
});
assert.equal(overflow, false, `${width}px: ${html}`);
for (const dot of await page.locator("#update .dot").all()) {
const box = await dot.boundingBox();
assert.equal(box.width, 7, "status dot remains visible at its normal size");
}
}
assert.equal(await page.locator('#update a[href="/login"]').isVisible(), true);
}
});
Loading