-
Notifications
You must be signed in to change notification settings - Fork 7
feat(drivers): pick a version, and see when your own copy has fallen behind #672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "ftw": minor | ||
| --- | ||
|
|
||
| Pick which driver version runs, and see when your own copy has fallen behind. The signed channel keeps every version it has ever published, but rollback only stepped back one, so a specific older version was out of reach from the UI. Each managed driver now has a Versions list showing what is downloaded, what the channel offers, and which one is live. A driver you supply yourself is now also told when the channel has something newer — your copy keeps running until you decide otherwise. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // Choosing which driver version runs, and never being surprised by it. | ||
| // | ||
| // Two properties matter here and they pull in opposite directions: | ||
| // | ||
| // 1. An operator running their own copy must still be told when the channel | ||
| // has something newer. An override shadows the channel silently, so | ||
| // without this they never find out. | ||
| // | ||
| // 2. Being told must not mean being changed. Their copy keeps running until | ||
| // they act. The channel keeps every version it has ever signed, and | ||
| // reaching a specific one is their decision. | ||
|
|
||
| import { test } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { readFileSync } from "node:fs"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { dirname, join } from "node:path"; | ||
|
|
||
| const here = dirname(fileURLToPath(import.meta.url)); | ||
| const source = readFileSync(join(here, "settings/tabs/devices.js"), "utf8"); | ||
|
|
||
| // Just renderVersionPicker, so assertions about it cannot be satisfied or | ||
| // broken by unrelated code further down the file. | ||
| function renderVersionPickerBody() { | ||
| const start = source.indexOf("function renderVersionPicker"); | ||
| assert.notEqual(start, -1, "renderVersionPicker must exist"); | ||
| const next = source.indexOf("\n function ", start + 1); | ||
| return source.slice(start, next === -1 ? undefined : next); | ||
| } | ||
|
|
||
| test("a local override is told what the channel has", () => { | ||
| assert.match(source, /source === "local" && entry\.upstream_version/, | ||
| "an override shadows the channel, so it must surface the newer version " + | ||
| "rather than leaving the operator to find out some other way"); | ||
| assert.match(source, /channel has v/, | ||
| "the version itself has to be on screen, not just the fact of an update"); | ||
| }); | ||
|
|
||
| test("the override is not offered a button that would do nothing", () => { | ||
| // Installing a channel version while a local file is present changes | ||
| // nothing: the local file still wins. A button implying otherwise would be | ||
| // a lie the operator only discovers by debugging. | ||
| const localBranch = source.slice( | ||
| source.indexOf('source === "local" && entry.upstream_version'), | ||
| source.indexOf('if (source === "managed")')); | ||
| assert.ok(!/drv-module-update/.test(localBranch), | ||
| "the update button belongs to managed drivers, not overrides"); | ||
| assert.match(localBranch, /your own copy is used while it is present/, | ||
| "say why nothing is being offered"); | ||
| }); | ||
|
|
||
| test("the version picker is offered for managed drivers only", () => { | ||
| assert.match(source, /source !== "local"[\s\S]{0,200}drv-module-versions/, | ||
| "an override has no channel versions to switch between"); | ||
| }); | ||
|
|
||
| test("the picker distinguishes downloaded versions from ones still in the channel", () => { | ||
| const render = renderVersionPickerBody(); | ||
| assert.match(render, /body && body\.installed/); | ||
| assert.match(render, /body && body\.available/); | ||
|
|
||
| // Activating something already on disk needs no network; fetching does. | ||
| // The labels have to tell those apart or the operator cannot tell why one | ||
| // is instant and the other is not. | ||
| assert.match(render, /row\.local \? "Activate" : "Fetch and activate"/); | ||
| assert.match(render, /row\.local[\s\S]{0,120}"\/activate"[\s\S]{0,40}"\/install"/, | ||
| "an installed version activates; one that is not installed installs"); | ||
| }); | ||
|
|
||
| test("the running version is marked and cannot be re-activated", () => { | ||
| const render = renderVersionPickerBody(); | ||
| assert.match(render, /row\.active \? " · running" : ""/, | ||
| "an operator must be able to see which one is live"); | ||
| assert.match(render, /if \(!row\.active\) \{/, | ||
| "no button on the version that is already running"); | ||
| }); | ||
|
|
||
| test("the picker builds DOM instead of assembling HTML", () => { | ||
| const render = renderVersionPickerBody(); | ||
| assert.ok(!/innerHTML/.test(render), | ||
| "version strings come from a signed manifest, but the manifest is still " + | ||
| "remote input and must not be able to inject markup"); | ||
| assert.match(render, /createElement\("button"\)/); | ||
| assert.match(render, /textContent = "v" \+ row\.version/); | ||
| }); | ||
|
|
||
| test("an empty history says so rather than rendering nothing", () => { | ||
| const render = renderVersionPickerBody(); | ||
| assert.match(render, /No versions found for this driver/, | ||
| "a silently empty panel looks like a broken request"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,90 @@ | |
| return true; | ||
| } | ||
|
|
||
| // Every version the channel has signed for this driver, and which one is | ||
| // running. Rollback steps back exactly one; this is how an operator reaches | ||
| // a specific older version, which is the whole reason the channel keeps a | ||
| // history in the first place. | ||
| function renderVersionPicker(panel, driverID, body) { | ||
| var installed = (body && body.installed) || []; | ||
| var available = (body && body.available) || []; | ||
| panel.textContent = ""; | ||
|
|
||
| if (installed.length === 0 && available.length === 0) { | ||
| panel.textContent = "No versions found for this driver."; | ||
| return; | ||
| } | ||
|
|
||
| // Downloaded already: activating one is instant and needs no network. | ||
| var installedVersions = {}; | ||
| installed.forEach(function (v) { if (v && v.version) installedVersions[v.version] = true; }); | ||
|
|
||
| var rows = []; | ||
| installed.forEach(function (v) { | ||
| rows.push({version: v.version, sha256: v.sha256, active: !!v.active, local: true}); | ||
| }); | ||
| available.forEach(function (v) { | ||
| var version = typeof v === "string" ? v : (v && v.version); | ||
| if (!version || installedVersions[version]) return; | ||
| rows.push({version: version, active: false, local: false}); | ||
| }); | ||
|
|
||
| rows.forEach(function (row) { | ||
| var line = document.createElement("div"); | ||
| line.style.display = "flex"; | ||
| line.style.alignItems = "center"; | ||
| line.style.gap = "8px"; | ||
| line.style.marginTop = "4px"; | ||
|
|
||
| var label = document.createElement("span"); | ||
| label.className = "creds-badge"; | ||
| label.textContent = "v" + row.version + (row.active ? " · running" : ""); | ||
| line.appendChild(label); | ||
|
|
||
| if (!row.active) { | ||
| var action = document.createElement("button"); | ||
| action.type = "button"; | ||
| action.className = "btn-add"; | ||
| // Already downloaded, so this only switches which one runs. A version | ||
| // that is not downloaded has to be fetched from the channel first. | ||
| action.textContent = row.local ? "Activate" : "Fetch and activate"; | ||
| action.addEventListener("click", function () { | ||
| action.disabled = true; | ||
| var status = line.querySelector(".drv-version-status"); | ||
| var endpoint = row.local ? "/activate" : "/install"; | ||
| var payload = row.local | ||
| ? {version: row.version, sha256: row.sha256 || ""} | ||
| : {version: row.version}; | ||
|
Comment on lines
+96
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the operator selects a version that is not downloaded, this payload contains only Useful? React with 👍 / 👎. |
||
| if (status) status.textContent = row.local ? "Activating…" : "Fetching…"; | ||
| apiFetch("/api/device_repository/drivers/" + encodeURIComponent(driverID) + endpoint, { | ||
| method: "POST", | ||
| headers: {"Content-Type": "application/json"}, | ||
| body: JSON.stringify(payload) | ||
| }).then(function (r) { | ||
| return r.json().then(function (b) { | ||
| if (!r.ok) throw new Error(b.error || "could not switch version"); | ||
| return b; | ||
| }); | ||
| }).then(function () { | ||
| if (status) status.textContent = "v" + row.version + " is now running."; | ||
| }).catch(function (err) { | ||
| if (status) status.textContent = err.message; | ||
| action.disabled = false; | ||
| }); | ||
| }); | ||
| line.appendChild(action); | ||
| } | ||
|
|
||
| var status = document.createElement("span"); | ||
| status.className = "drv-version-status"; | ||
| status.style.color = "var(--text-dim)"; | ||
| status.style.fontSize = "0.75rem"; | ||
| line.appendChild(status); | ||
|
|
||
| panel.appendChild(line); | ||
| }); | ||
| } | ||
|
|
||
| function configProfilesFor(entry) { | ||
| if (!entry || entry.id !== "goodwe" || !versionAtLeast(entry.version, "1.0.2")) return []; | ||
| return DRIVER_CONFIG_PROFILES.goodwe; | ||
|
|
@@ -448,11 +532,27 @@ | |
| '" data-repository-id="' + escHtml(entry.repository_id) + '" data-version="' + escHtml(entry.upstream_version) + '">Update to v' + | ||
| escHtml(entry.upstream_version) + '</button>'; | ||
| } | ||
| // An override shadows the channel, so installing a newer version | ||
| // would not change what runs. Say that, rather than offering a | ||
| // button that appears to do nothing. Their copy stays active until | ||
| // they remove it themselves. | ||
| if (source === "local" && entry.upstream_version) { | ||
| html += ' <span class="creds-badge">channel has v' + escHtml(entry.upstream_version) + '</span>'; | ||
| html += ' <span style="color:var(--text-dim);font-size:0.75rem">' + | ||
| 'your own copy is used while it is present</span>'; | ||
| } | ||
| if (source === "managed") { | ||
| html += ' <button class="btn-add drv-module-rollback" type="button" data-driver-id="' + escHtml(entry.id) + | ||
| '" data-logical-path="' + escHtml(entry.path) + '">Rollback</button>'; | ||
| } | ||
| // Rollback only steps back one version. The channel keeps every | ||
| // version it has ever signed, so let an operator pick from them. | ||
| if (source !== "local") { | ||
| html += ' <button class="btn-add drv-module-versions" type="button" data-driver-id="' + | ||
| escHtml(entry.id) + '">Versions…</button>'; | ||
| } | ||
| html += ' <span class="drv-module-action"></span>'; | ||
| html += '<div class="drv-module-versions-panel" style="display:none;margin-top:6px"></div>'; | ||
| slot.innerHTML = html; | ||
| }); | ||
| bodyEl.querySelectorAll(".drv-module-update").forEach(function (btn) { | ||
|
|
@@ -481,6 +581,25 @@ | |
| .catch(function (err) { if (status) status.textContent = " " + err.message; btn.disabled = false; }); | ||
| }); | ||
| }); | ||
| bodyEl.querySelectorAll(".drv-module-versions").forEach(function (btn) { | ||
| btn.addEventListener("click", function () { | ||
| var panel = btn.parentElement.querySelector(".drv-module-versions-panel"); | ||
| if (!panel) return; | ||
| if (panel.style.display !== "none") { panel.style.display = "none"; return; } | ||
| panel.style.display = "block"; | ||
| panel.textContent = "Loading versions…"; | ||
| var id = btn.dataset.driverId; | ||
| apiFetch("/api/device_repository/drivers/" + encodeURIComponent(id) + "/versions") | ||
| .then(function (r) { | ||
| return r.json().then(function (body) { | ||
| if (!r.ok) throw new Error(body.error || "could not list versions"); | ||
| return body; | ||
| }); | ||
| }) | ||
| .then(function (body) { renderVersionPicker(panel, id, body); }) | ||
| .catch(function (err) { panel.textContent = err.message; }); | ||
| }); | ||
| }); | ||
| bodyEl.querySelectorAll(".drv-profile-slot").forEach(function (slot) { | ||
| var dIdx = parseInt(slot.getAttribute("data-driver-idx"), 10); | ||
| var d = config.drivers[dIdx]; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a version exists only in the signed repository, the versions endpoint returns each
availableitem as aVersionCandidateshaped like{repository_id, driver: {version, ...}}, not as an object with a top-levelversion(the existingupdate-badge.jsconsumer follows this shape). Consequentlyv.versionis undefined and every channel-only candidate is skipped, leaving bundled drivers with a blank panel and preventing the advertised selection of older remote versions.Useful? React with 👍 / 👎.