Compare commits
16 Commits
v0.1.3-dev
...
v0.1.3-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87b90bf9f | ||
|
|
8557140193 | ||
|
|
86438b11f3 | ||
|
|
3a785832b1 | ||
|
|
a94cd17186 | ||
|
|
b01bfcd38e | ||
|
|
831a98c5a1 | ||
|
|
daea783d38 | ||
|
|
90d3e5676a | ||
|
|
8a054c5d85 | ||
|
|
009ac8cdd0 | ||
|
|
7a9ffb710a | ||
|
|
15da438625 | ||
|
|
50ddc3e211 | ||
|
|
e7a79246b8 | ||
|
|
bb2fb2dcf2 |
15
README.md
15
README.md
@@ -10,10 +10,23 @@ Lightweight dashboard for DietPi-based Pi-Kit images.
|
|||||||
- Frontend: `cd pikit-web && npm install` (once), `npm run dev` for live reload, `npm test` for Playwright, `npm run build` for production `dist/`.
|
- Frontend: `cd pikit-web && npm install` (once), `npm run dev` for live reload, `npm test` for Playwright, `npm run build` for production `dist/`.
|
||||||
- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device.
|
- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device.
|
||||||
|
|
||||||
|
## Build, package, and publish a release
|
||||||
|
1) Bump `pikit-web/data/version.json` to the target version (e.g., `0.1.3-dev1`), then `cd pikit-web && npm run build`.
|
||||||
|
2) Package: `./tools/release/make-release.sh <version> https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v<version>`
|
||||||
|
Outputs in `out/releases/`: `pikit-<version>.tar.gz` and `manifest.json` (with SHA256 + changelog URL).
|
||||||
|
3) Create a Gitea release/tag `v<version>` and upload:
|
||||||
|
- `pikit-<version>.tar.gz`
|
||||||
|
- `manifest.json`
|
||||||
|
- `CHANGELOG-<version>.txt` (add a short changelog in `out/releases/`)
|
||||||
|
4) Update repo manifests (public raw defaults used by devices): edit `manifests/manifest-stable.json` and `manifests/manifest-dev.json` with `version`, `bundle`, `changelog`, `_release_date`, `sha256` from the new bundle, then commit/push.
|
||||||
|
- Default OTA URLs (no token needed):
|
||||||
|
- Stable: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json`
|
||||||
|
- Dev: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json`
|
||||||
|
|
||||||
## Deploy to a Pi-Kit box
|
## Deploy to a Pi-Kit box
|
||||||
1) Copy `pikit-api.py` **and** the `pikit_api/` directory to the device (e.g., `/usr/local/bin/`) and restart `pikit-api.service`.
|
1) Copy `pikit-api.py` **and** the `pikit_api/` directory to the device (e.g., `/usr/local/bin/`) and restart `pikit-api.service`.
|
||||||
2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`.
|
2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`.
|
||||||
3) Using release bundles: `./tools/release/make-release.sh <version> <base_url>`, upload the tarball + manifest, and point `PIKIT_MANIFEST_URL` (systemd drop-in) to that manifest URL for OTA.
|
3) OTA defaults (no per-device token required): `PIKIT_MANIFEST_URL` points to the stable manifest above; enabling “Allow dev builds” in the UI makes the updater consult the dev manifest URL. Override via systemd drop-in if you need to pin to a specific manifest.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Service paths are normalized (leading slash) and URLs include optional subpaths.
|
- Service paths are normalized (leading slash) and URLs include optional subpaths.
|
||||||
|
|||||||
9
manifests/manifest-dev.json
Normal file
9
manifests/manifest-dev.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.3-dev3",
|
||||||
|
"_release_date": "2025-12-14T23:50:00Z",
|
||||||
|
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev3/pikit-0.1.3-dev3.tar.gz",
|
||||||
|
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev3/CHANGELOG-0.1.3-dev3.txt",
|
||||||
|
"files": [
|
||||||
|
{ "path": "bundle.tar.gz", "sha256": "1352f6a90d91e21871684e9a2c175c8cb48c53aa0b121fcdc94a3004c3d4d3f2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
manifests/manifest-stable.json
Normal file
9
manifests/manifest-stable.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.2",
|
||||||
|
"_release_date": "2025-12-10T00:00:00Z",
|
||||||
|
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/pikit-0.1.2.tar.gz",
|
||||||
|
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/CHANGELOG-0.1.2.txt",
|
||||||
|
"files": [
|
||||||
|
{ "path": "bundle.tar.gz", "sha256": "8d2e0f8b260063cab0d52e862cb42f10472a643123f984af0248592479dd613d" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pikit_api import HOST, PORT
|
from pikit_api import HOST, PORT
|
||||||
from pikit_api.releases import apply_update, check_for_update, rollback_update
|
from pikit_api.releases import apply_update, check_for_update
|
||||||
from pikit_api.server import run_server
|
from pikit_api.server import run_server
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ def parse_args():
|
|||||||
parser = argparse.ArgumentParser(description="Pi-Kit API / updater")
|
parser = argparse.ArgumentParser(description="Pi-Kit API / updater")
|
||||||
parser.add_argument("--apply-update", action="store_true", help="Apply latest release (non-HTTP mode)")
|
parser.add_argument("--apply-update", action="store_true", help="Apply latest release (non-HTTP mode)")
|
||||||
parser.add_argument("--check-update", action="store_true", help="Check for latest release (non-HTTP mode)")
|
parser.add_argument("--check-update", action="store_true", help="Check for latest release (non-HTTP mode)")
|
||||||
parser.add_argument("--rollback-update", action="store_true", help="Rollback to last backup (non-HTTP mode)")
|
|
||||||
parser.add_argument("--host", default=HOST, help="Bind host (default 127.0.0.1)")
|
parser.add_argument("--host", default=HOST, help="Bind host (default 127.0.0.1)")
|
||||||
parser.add_argument("--port", type=int, default=PORT, help="Bind port (default 4000)")
|
parser.add_argument("--port", type=int, default=PORT, help="Bind port (default 4000)")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
@@ -36,9 +35,6 @@ def main():
|
|||||||
if args.check_update:
|
if args.check_update:
|
||||||
check_for_update()
|
check_for_update()
|
||||||
return
|
return
|
||||||
if args.rollback_update:
|
|
||||||
rollback_update()
|
|
||||||
return
|
|
||||||
run_server(host=args.host, port=args.port)
|
run_server(host=args.host, port=args.port)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,12 @@ export const applyRelease = () =>
|
|||||||
api("/api/update/apply", {
|
api("/api/update/apply", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
export const rollbackRelease = () =>
|
export const applyReleaseVersion = (version) =>
|
||||||
api("/api/update/rollback", {
|
api("/api/update/apply_version", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: JSON.stringify({ version }),
|
||||||
});
|
});
|
||||||
|
export const listReleases = () => api("/api/update/releases");
|
||||||
export const setReleaseAutoCheck = (enable) =>
|
export const setReleaseAutoCheck = (enable) =>
|
||||||
api("/api/update/auto", {
|
api("/api/update/auto", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -94,6 +94,60 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-status-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-advanced {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-advanced-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card input[type="radio"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-card .status-msg {
|
.modal-card .status-msg {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
getReleaseStatus,
|
getReleaseStatus,
|
||||||
checkRelease,
|
checkRelease,
|
||||||
applyRelease,
|
applyRelease,
|
||||||
rollbackRelease,
|
applyReleaseVersion,
|
||||||
|
listReleases,
|
||||||
setReleaseAutoCheck,
|
setReleaseAutoCheck,
|
||||||
setReleaseChannel,
|
setReleaseChannel,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -24,8 +25,14 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
const releaseProgress = document.getElementById("releaseProgress");
|
const releaseProgress = document.getElementById("releaseProgress");
|
||||||
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
||||||
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
||||||
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
|
const releaseAdvancedToggle = document.getElementById("releaseAdvancedToggle");
|
||||||
|
const releaseAdvanced = document.getElementById("releaseAdvanced");
|
||||||
|
const releaseList = document.getElementById("releaseList");
|
||||||
|
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
|
||||||
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
||||||
|
const releaseStatusChip = document.getElementById("releaseStatusChip");
|
||||||
|
const releaseChannelChip = document.getElementById("releaseChannelChip");
|
||||||
|
const releaseLastCheckChip = document.getElementById("releaseLastCheckChip");
|
||||||
const releaseLog = document.getElementById("releaseLog");
|
const releaseLog = document.getElementById("releaseLog");
|
||||||
const releaseLogStatus = document.getElementById("releaseLogStatus");
|
const releaseLogStatus = document.getElementById("releaseLogStatus");
|
||||||
const releaseLogCopy = document.getElementById("releaseLogCopy");
|
const releaseLogCopy = document.getElementById("releaseLogCopy");
|
||||||
@@ -46,6 +53,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
let changelogCache = { version: null, text: "" };
|
let changelogCache = { version: null, text: "" };
|
||||||
let lastChangelogUrl = null;
|
let lastChangelogUrl = null;
|
||||||
let releaseChannel = "dev";
|
let releaseChannel = "dev";
|
||||||
|
let releaseOptions = [];
|
||||||
const logger = createReleaseLogger(logUi);
|
const logger = createReleaseLogger(logUi);
|
||||||
logger.attach(releaseLog);
|
logger.attach(releaseLog);
|
||||||
|
|
||||||
@@ -64,6 +72,71 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function loadReleaseList() {
|
||||||
|
if (!releaseList) return;
|
||||||
|
try {
|
||||||
|
const data = await listReleases();
|
||||||
|
releaseOptions = data.releases || [];
|
||||||
|
renderReleaseList();
|
||||||
|
} catch (e) {
|
||||||
|
renderReleaseList(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReleaseList(error = false) {
|
||||||
|
if (!releaseList) return;
|
||||||
|
releaseList.innerHTML = "";
|
||||||
|
if (error) {
|
||||||
|
releaseList.textContent = "Failed to load releases.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!releaseOptions.length) {
|
||||||
|
releaseList.textContent = "No releases found.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseOptions.forEach((r, idx) => {
|
||||||
|
const card = document.createElement("label");
|
||||||
|
card.className = "release-card";
|
||||||
|
card.setAttribute("role", "option");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "radio";
|
||||||
|
input.name = "releaseVersion";
|
||||||
|
input.value = r.version;
|
||||||
|
if (idx === 0) input.checked = true;
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "release-card-meta";
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "release-card-title";
|
||||||
|
title.textContent = r.version;
|
||||||
|
const tags = document.createElement("div");
|
||||||
|
tags.className = "release-card-tags";
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "status-chip ghost";
|
||||||
|
chip.textContent = r.prerelease ? "Dev" : "Stable";
|
||||||
|
tags.appendChild(chip);
|
||||||
|
if (r.published_at) {
|
||||||
|
const date = document.createElement("span");
|
||||||
|
date.className = "hint quiet";
|
||||||
|
date.textContent = fmtDate(r.published_at);
|
||||||
|
tags.appendChild(date);
|
||||||
|
}
|
||||||
|
meta.appendChild(title);
|
||||||
|
meta.appendChild(tags);
|
||||||
|
if (r.changelog_url) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = r.changelog_url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.className = "hint";
|
||||||
|
link.textContent = "Changelog";
|
||||||
|
meta.appendChild(link);
|
||||||
|
}
|
||||||
|
card.appendChild(input);
|
||||||
|
card.appendChild(meta);
|
||||||
|
releaseList.appendChild(card);
|
||||||
|
});
|
||||||
|
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
function setReleaseChip(state) {
|
function setReleaseChip(state) {
|
||||||
if (!releaseFlagTop) return;
|
if (!releaseFlagTop) return;
|
||||||
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
||||||
@@ -155,6 +228,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
current_release_date = null,
|
current_release_date = null,
|
||||||
latest_release_date = null,
|
latest_release_date = null,
|
||||||
changelog_url = null,
|
changelog_url = null,
|
||||||
|
last_check = null,
|
||||||
} = data || {};
|
} = data || {};
|
||||||
releaseChannel = channel || "dev";
|
releaseChannel = channel || "dev";
|
||||||
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
|
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
|
||||||
@@ -176,6 +250,13 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
if (releaseLatest) releaseLatest.textContent = latest_version;
|
if (releaseLatest) releaseLatest.textContent = latest_version;
|
||||||
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
|
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
|
||||||
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
|
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
|
||||||
|
if (releaseStatusChip) {
|
||||||
|
releaseStatusChip.textContent = `Status: ${status.replaceAll("_", " ")}`;
|
||||||
|
releaseStatusChip.classList.toggle("chip-warm", status === "update_available");
|
||||||
|
releaseStatusChip.classList.toggle("chip-off", status === "error");
|
||||||
|
}
|
||||||
|
if (releaseChannelChip) releaseChannelChip.textContent = `Channel: ${releaseChannel}`;
|
||||||
|
if (releaseLastCheckChip) releaseLastCheckChip.textContent = `Last check: ${last_check ? fmtDate(last_check) : "—"}`;
|
||||||
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
||||||
if (releaseProgress) releaseProgress.textContent = "";
|
if (releaseProgress) releaseProgress.textContent = "";
|
||||||
if (status === "in_progress" && progress) {
|
if (status === "in_progress" && progress) {
|
||||||
@@ -237,6 +318,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
releaseBtn?.addEventListener("click", () => {
|
releaseBtn?.addEventListener("click", () => {
|
||||||
releaseModal?.classList.remove("hidden");
|
releaseModal?.classList.remove("hidden");
|
||||||
loadReleaseStatus(true);
|
loadReleaseStatus(true);
|
||||||
|
loadReleaseList();
|
||||||
});
|
});
|
||||||
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
||||||
// Do not allow dismiss by clicking backdrop (consistency with other modals)
|
// Do not allow dismiss by clicking backdrop (consistency with other modals)
|
||||||
@@ -299,19 +381,32 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
releaseRollbackBtn?.addEventListener("click", async () => {
|
releaseAdvancedToggle?.addEventListener("click", async () => {
|
||||||
|
releaseAdvanced?.classList.toggle("hidden");
|
||||||
|
if (!releaseAdvanced?.classList.contains("hidden")) {
|
||||||
|
await loadReleaseList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseApplyVersionBtn?.addEventListener("click", async () => {
|
||||||
|
const selected = releaseList?.querySelector("input[name='releaseVersion']:checked");
|
||||||
|
if (!selected) {
|
||||||
|
showToast("Select a version first", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
lastReleaseToastKey = null;
|
lastReleaseToastKey = null;
|
||||||
logUi("Rollback requested");
|
const ver = selected.value;
|
||||||
|
logUi(`Install version ${ver} requested`);
|
||||||
releaseBusyActive = true;
|
releaseBusyActive = true;
|
||||||
showBusy("Rolling back…", "Restoring previous backup.");
|
showBusy(`Installing ${ver}…`, "Applying selected release. This can take up to a minute.");
|
||||||
logRelease("Starting rollback…");
|
logRelease(`Installing ${ver}…`);
|
||||||
await rollbackRelease();
|
await applyReleaseVersion(ver);
|
||||||
pollReleaseStatus();
|
pollReleaseStatus();
|
||||||
showToast("Rollback started", "success");
|
showToast(`Installing ${ver}`, "success");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.error || "Rollback failed", "error");
|
showToast(e.error || "Install failed", "error");
|
||||||
logRelease(`Error: ${e.error || "Rollback failed"}`);
|
logRelease(`Error: ${e.error || "Install failed"}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (releaseProgress) releaseProgress.textContent = "";
|
if (releaseProgress) releaseProgress.textContent = "";
|
||||||
}
|
}
|
||||||
@@ -334,6 +429,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
|||||||
releaseChannel = chan;
|
releaseChannel = chan;
|
||||||
logRelease(`Channel set to ${chan}`);
|
logRelease(`Channel set to ${chan}`);
|
||||||
await loadReleaseStatus(true);
|
await loadReleaseStatus(true);
|
||||||
|
await loadReleaseList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.error || "Failed to save channel", "error");
|
showToast(e.error || "Failed to save channel", "error");
|
||||||
releaseChannelToggle.checked = releaseChannel === "dev";
|
releaseChannelToggle.checked = releaseChannel === "dev";
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "0.1.3-dev1"
|
"version": "0.1.3-dev3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls column">
|
<div class="controls column">
|
||||||
|
<div class="release-status-bar">
|
||||||
|
<span id="releaseStatusChip" class="status-chip quiet">Status: n/a</span>
|
||||||
|
<span id="releaseChannelChip" class="status-chip quiet">Channel: n/a</span>
|
||||||
|
<span id="releaseLastCheckChip" class="status-chip quiet">Last check: —</span>
|
||||||
|
</div>
|
||||||
<div class="control-card release-versions">
|
<div class="control-card release-versions">
|
||||||
<div>
|
<div>
|
||||||
<p class="hint quiet">Current version</p>
|
<p class="hint quiet">Current version</p>
|
||||||
@@ -165,8 +170,8 @@
|
|||||||
<button id="releaseApplyBtn" title="Download and install the latest release">
|
<button id="releaseApplyBtn" title="Download and install the latest release">
|
||||||
Upgrade
|
Upgrade
|
||||||
</button>
|
</button>
|
||||||
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
|
<button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
|
||||||
Rollback
|
Manual selection
|
||||||
</button>
|
</button>
|
||||||
<label class="checkbox-row inline">
|
<label class="checkbox-row inline">
|
||||||
<input type="checkbox" id="releaseAutoCheck" />
|
<input type="checkbox" id="releaseAutoCheck" />
|
||||||
@@ -177,6 +182,18 @@
|
|||||||
<span>Allow dev builds</span>
|
<span>Allow dev builds</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="releaseAdvanced" class="release-advanced hidden">
|
||||||
|
<div class="release-advanced-head">
|
||||||
|
<div>
|
||||||
|
<p class="hint quiet">Choose a specific release</p>
|
||||||
|
<span class="hint">Dev builds only appear when “Allow dev builds” is on.</span>
|
||||||
|
</div>
|
||||||
|
<button id="releaseApplyVersionBtn" class="ghost" title="Install selected release">
|
||||||
|
Install selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="releaseList" class="release-list" role="listbox" aria-label="Available releases"></div>
|
||||||
|
</div>
|
||||||
<div id="releaseProgress" class="hint status-msg"></div>
|
<div id="releaseProgress" class="hint status-msg"></div>
|
||||||
<div class="log-card">
|
<div class="log-card">
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ modules while keeping the on-device entry point compatible.
|
|||||||
# Re-export commonly used helpers for convenience
|
# Re-export commonly used helpers for convenience
|
||||||
from .constants import HOST, PORT # noqa: F401
|
from .constants import HOST, PORT # noqa: F401
|
||||||
from .server import run_server # noqa: F401
|
from .server import run_server # noqa: F401
|
||||||
from .releases import apply_update, check_for_update, rollback_update # noqa: F401
|
from .releases import apply_update, check_for_update # noqa: F401
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ ALL_PATTERNS = [
|
|||||||
# Release updater
|
# Release updater
|
||||||
DEFAULT_MANIFEST_URL = os.environ.get(
|
DEFAULT_MANIFEST_URL = os.environ.get(
|
||||||
"PIKIT_MANIFEST_URL",
|
"PIKIT_MANIFEST_URL",
|
||||||
"https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json",
|
# Stable manifest (raw in repo, public)
|
||||||
|
"https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json",
|
||||||
|
)
|
||||||
|
DEFAULT_DEV_MANIFEST_URL = os.environ.get(
|
||||||
|
"PIKIT_DEV_MANIFEST_URL",
|
||||||
|
# Dev manifest (raw in repo, public)
|
||||||
|
"https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json",
|
||||||
)
|
)
|
||||||
AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN")
|
AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN")
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from .releases import (
|
|||||||
read_current_version,
|
read_current_version,
|
||||||
save_update_state,
|
save_update_state,
|
||||||
start_background_task,
|
start_background_task,
|
||||||
|
list_available_releases,
|
||||||
|
apply_update_version,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
FirewallToolMissing,
|
FirewallToolMissing,
|
||||||
@@ -82,6 +84,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
state = _load_diag_state()
|
state = _load_diag_state()
|
||||||
return self._send(200, {"entries": entries, "state": state})
|
return self._send(200, {"entries": entries, "state": state})
|
||||||
|
|
||||||
|
if self.path.startswith("/api/update/releases"):
|
||||||
|
state = load_update_state()
|
||||||
|
channel = state.get("channel") or "stable"
|
||||||
|
return self._send(200, {"releases": list_available_releases(channel)})
|
||||||
|
|
||||||
return self._send(404, {"error": "not found"})
|
return self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
# POST endpoints
|
# POST endpoints
|
||||||
@@ -122,6 +129,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
state = check_for_update()
|
state = check_for_update()
|
||||||
return self._send(200, state)
|
return self._send(200, state)
|
||||||
|
|
||||||
|
if self.path.startswith("/api/update/apply_version"):
|
||||||
|
version = payload.get("version")
|
||||||
|
if not version:
|
||||||
|
return self._send(400, {"error": "version required"})
|
||||||
|
state = load_update_state()
|
||||||
|
chan = payload.get("channel") or state.get("channel") or "stable"
|
||||||
|
result = apply_update_version(version, chan)
|
||||||
|
return self._send(200, result)
|
||||||
|
|
||||||
if self.path.startswith("/api/update/apply"):
|
if self.path.startswith("/api/update/apply"):
|
||||||
start_background_task("apply")
|
start_background_task("apply")
|
||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
@@ -130,14 +146,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
return self._send(202, state)
|
return self._send(202, state)
|
||||||
|
|
||||||
if self.path.startswith("/api/update/rollback"):
|
|
||||||
start_background_task("rollback")
|
|
||||||
state = load_update_state()
|
|
||||||
state["status"] = "in_progress"
|
|
||||||
state["message"] = "Starting rollback"
|
|
||||||
save_update_state(state)
|
|
||||||
return self._send(202, state)
|
|
||||||
|
|
||||||
if self.path.startswith("/api/update/auto"):
|
if self.path.startswith("/api/update/auto"):
|
||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
state["auto_check"] = bool(payload.get("enable"))
|
state["auto_check"] = bool(payload.get("enable"))
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from .constants import (
|
|||||||
API_PACKAGE_DIR,
|
API_PACKAGE_DIR,
|
||||||
API_PATH,
|
API_PATH,
|
||||||
AUTH_TOKEN,
|
AUTH_TOKEN,
|
||||||
BACKUP_ROOT,
|
|
||||||
DEFAULT_MANIFEST_URL,
|
DEFAULT_MANIFEST_URL,
|
||||||
|
DEFAULT_DEV_MANIFEST_URL,
|
||||||
TMP_ROOT,
|
TMP_ROOT,
|
||||||
UPDATE_LOCK,
|
UPDATE_LOCK,
|
||||||
UPDATE_STATE,
|
UPDATE_STATE,
|
||||||
@@ -43,29 +43,62 @@ def read_current_version() -> str:
|
|||||||
|
|
||||||
def load_update_state() -> Dict[str, Any]:
|
def load_update_state() -> Dict[str, Any]:
|
||||||
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
def _reset_if_stale(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
If state thinks an update is running but the lock holder is gone,
|
||||||
|
clear it so the UI can recover instead of getting stuck forever.
|
||||||
|
"""
|
||||||
|
lock_alive = False
|
||||||
|
if UPDATE_LOCK.exists():
|
||||||
|
try:
|
||||||
|
pid = int(UPDATE_LOCK.read_text().strip() or "0")
|
||||||
|
if pid > 0:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
lock_alive = True
|
||||||
|
else:
|
||||||
|
UPDATE_LOCK.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
UPDATE_LOCK.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
UPDATE_LOCK.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
if state.get("in_progress") and not lock_alive:
|
||||||
|
state["in_progress"] = False
|
||||||
|
state["progress"] = None
|
||||||
|
if state.get("status") == "in_progress":
|
||||||
|
state["status"] = "up_to_date"
|
||||||
|
state["message"] = state.get("message") or "Recovered from interrupted update"
|
||||||
|
try:
|
||||||
|
save_update_state(state)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return state
|
||||||
|
|
||||||
if UPDATE_STATE.exists():
|
if UPDATE_STATE.exists():
|
||||||
try:
|
try:
|
||||||
state = json.loads(UPDATE_STATE.read_text())
|
state = json.loads(UPDATE_STATE.read_text())
|
||||||
state.setdefault("changelog_url", None)
|
state.setdefault("changelog_url", None)
|
||||||
state.setdefault("latest_release_date", None)
|
state.setdefault("latest_release_date", None)
|
||||||
state.setdefault("current_release_date", None)
|
state.setdefault("current_release_date", None)
|
||||||
return state
|
return _reset_if_stale(state)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {
|
return _reset_if_stale(
|
||||||
"current_version": read_current_version(),
|
{
|
||||||
"latest_version": None,
|
"current_version": read_current_version(),
|
||||||
"last_check": None,
|
"latest_version": None,
|
||||||
"status": "unknown",
|
"last_check": None,
|
||||||
"message": "",
|
"status": "unknown",
|
||||||
"auto_check": False,
|
"message": "",
|
||||||
"in_progress": False,
|
"auto_check": False,
|
||||||
"progress": None,
|
"in_progress": False,
|
||||||
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
|
"progress": None,
|
||||||
"changelog_url": None,
|
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
|
||||||
"latest_release_date": None,
|
"changelog_url": None,
|
||||||
"current_release_date": None,
|
"latest_release_date": None,
|
||||||
}
|
"current_release_date": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_update_state(state: Dict[str, Any]) -> None:
|
def save_update_state(state: Dict[str, Any]) -> None:
|
||||||
@@ -119,6 +152,17 @@ def fetch_manifest(url: str | None = None):
|
|||||||
data = resp.read()
|
data = resp.read()
|
||||||
return json.loads(data.decode())
|
return json.loads(data.decode())
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
|
# If raw URL is protected, retry with access_token query param
|
||||||
|
if e.code == 404 and token and "access_token=" not in target:
|
||||||
|
try:
|
||||||
|
sep = "&" if "?" in target else "?"
|
||||||
|
retry_url = f"{target}{sep}access_token={token}"
|
||||||
|
req = urllib.request.Request(retry_url)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
data = resp.read()
|
||||||
|
return json.loads(data.decode())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if e.code == 404:
|
if e.code == 404:
|
||||||
alt = _gitea_latest_manifest(target)
|
alt = _gitea_latest_manifest(target)
|
||||||
if alt:
|
if alt:
|
||||||
@@ -135,6 +179,31 @@ def _try_fetch(url: Optional[str]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_releases_api_url(manifest_url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Best-effort: derive Gitea releases API endpoint from a manifest URL.
|
||||||
|
Supports raw URLs (/owner/repo/raw/...) or release asset URLs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(manifest_url)
|
||||||
|
parts = parsed.path.strip("/").split("/")
|
||||||
|
owner = repo = None
|
||||||
|
if "releases" in parts:
|
||||||
|
if len(parts) >= 2:
|
||||||
|
owner, repo = parts[0], parts[1]
|
||||||
|
elif "raw" in parts:
|
||||||
|
if len(parts) >= 2:
|
||||||
|
owner, repo = parts[0], parts[1]
|
||||||
|
elif len(parts) >= 2:
|
||||||
|
owner, repo = parts[0], parts[1]
|
||||||
|
if owner and repo:
|
||||||
|
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return f"{base}/api/v1/repos/{owner}/{repo}/releases"
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
||||||
"""
|
"""
|
||||||
For stable: use normal manifest (latest non-prerelease).
|
For stable: use normal manifest (latest non-prerelease).
|
||||||
@@ -143,10 +212,14 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
|||||||
"""
|
"""
|
||||||
channel = channel or "dev"
|
channel = channel or "dev"
|
||||||
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||||
dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL")
|
dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL") or DEFAULT_DEV_MANIFEST_URL
|
||||||
stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or base_manifest_url
|
stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||||
manifest = None
|
manifest = None
|
||||||
|
manual_dev_manifest = None
|
||||||
version_dates: Dict[str, Optional[str]] = {}
|
version_dates: Dict[str, Optional[str]] = {}
|
||||||
|
# Explicit dev manifest (raw file) – only used for dev channel
|
||||||
|
if channel == "dev":
|
||||||
|
manual_dev_manifest = _try_fetch(dev_manifest_url)
|
||||||
try:
|
try:
|
||||||
manifest = fetch_manifest(stable_manifest_url)
|
manifest = fetch_manifest(stable_manifest_url)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -194,21 +267,22 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
|||||||
try:
|
try:
|
||||||
parts = base_manifest_url.split("/")
|
parts = base_manifest_url.split("/")
|
||||||
if "releases" not in parts:
|
if "releases" not in parts:
|
||||||
if manifest:
|
# No releases API for this URL; keep any fetched manifest and skip API discovery.
|
||||||
return (manifest, {"version_dates": version_dates}) if with_meta else manifest
|
releases = []
|
||||||
mf = fetch_manifest(base_manifest_url)
|
if not manifest:
|
||||||
return (mf, {"version_dates": version_dates}) if with_meta else mf
|
manifest = fetch_manifest(base_manifest_url)
|
||||||
idx = parts.index("releases")
|
else:
|
||||||
owner = parts[idx - 2]
|
idx = parts.index("releases")
|
||||||
repo = parts[idx - 1]
|
owner = parts[idx - 2]
|
||||||
base = "/".join(parts[:3])
|
repo = parts[idx - 1]
|
||||||
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases"
|
base = "/".join(parts[:3])
|
||||||
req = urllib.request.Request(api_url)
|
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases"
|
||||||
token = _auth_token()
|
req = urllib.request.Request(api_url)
|
||||||
if token:
|
token = _auth_token()
|
||||||
req.add_header("Authorization", f"token {token}")
|
if token:
|
||||||
resp = urllib.request.urlopen(req, timeout=10)
|
req.add_header("Authorization", f"token {token}")
|
||||||
releases = json.loads(resp.read().decode())
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
releases = json.loads(resp.read().decode())
|
||||||
|
|
||||||
# Map release versions to published dates so we can surface them later
|
# Map release versions to published dates so we can surface them later
|
||||||
for rel in releases:
|
for rel in releases:
|
||||||
@@ -252,8 +326,8 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
|||||||
manifest["_release_date"] = version_dates[mver]
|
manifest["_release_date"] = version_dates[mver]
|
||||||
|
|
||||||
if channel == "dev":
|
if channel == "dev":
|
||||||
# Choose the newest by version comparison across stable/dev/base candidates
|
# Choose the newest by version comparison across stable/dev/base/manual-dev candidates
|
||||||
candidates = [c for c in (latest_dev, latest_stable, manifest) if c]
|
candidates = [c for c in (latest_dev, manual_dev_manifest, latest_stable, manifest) if c]
|
||||||
best = None
|
best = None
|
||||||
best_ver = None
|
best_ver = None
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
@@ -286,6 +360,56 @@ def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
|||||||
raise RuntimeError("No manifest found for channel")
|
raise RuntimeError("No manifest found for channel")
|
||||||
|
|
||||||
|
|
||||||
|
def list_available_releases(channel: str = "stable", limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Return a list of releases with manifest URLs. Respects channel:
|
||||||
|
- stable: non-prerelease only
|
||||||
|
- dev: includes prereleases
|
||||||
|
"""
|
||||||
|
channel = channel or "stable"
|
||||||
|
api_url = os.environ.get("PIKIT_RELEASES_API") or _derive_releases_api_url(DEFAULT_MANIFEST_URL)
|
||||||
|
if not api_url:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(api_url)
|
||||||
|
token = _auth_token()
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
releases = json.loads(resp.read().decode())
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for rel in releases:
|
||||||
|
prerelease = bool(rel.get("prerelease"))
|
||||||
|
if channel == "stable" and prerelease:
|
||||||
|
continue
|
||||||
|
version = rel.get("tag_name") or rel.get("name")
|
||||||
|
if not version:
|
||||||
|
continue
|
||||||
|
version = str(version).lstrip("vV")
|
||||||
|
manifest_asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||||
|
if not manifest_asset or not manifest_asset.get("browser_download_url"):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"version": version,
|
||||||
|
"prerelease": prerelease,
|
||||||
|
"published_at": rel.get("published_at") or rel.get("created_at"),
|
||||||
|
"manifest_url": manifest_asset["browser_download_url"],
|
||||||
|
"changelog_url": next(
|
||||||
|
(a.get("browser_download_url") for a in rel.get("assets", []) if a.get("name", "").startswith("CHANGELOG-")),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort newest first by published_at if present
|
||||||
|
items.sort(key=lambda x: x.get("published_at") or "", reverse=True)
|
||||||
|
return items[:limit]
|
||||||
|
|
||||||
|
|
||||||
def download_file(url: str, dest: pathlib.Path):
|
def download_file(url: str, dest: pathlib.Path):
|
||||||
ensure_dir(dest.parent)
|
ensure_dir(dest.parent)
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
@@ -309,6 +433,19 @@ def fetch_text_with_auth(url: str):
|
|||||||
def acquire_lock():
|
def acquire_lock():
|
||||||
try:
|
try:
|
||||||
ensure_dir(UPDATE_LOCK.parent)
|
ensure_dir(UPDATE_LOCK.parent)
|
||||||
|
# Clear stale lock if the recorded PID is not running
|
||||||
|
if UPDATE_LOCK.exists():
|
||||||
|
try:
|
||||||
|
pid = int(UPDATE_LOCK.read_text().strip() or "0")
|
||||||
|
if pid > 0:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
else:
|
||||||
|
UPDATE_LOCK.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
# Process not running
|
||||||
|
UPDATE_LOCK.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
UPDATE_LOCK.unlink(missing_ok=True)
|
||||||
lockfile = UPDATE_LOCK.open("w")
|
lockfile = UPDATE_LOCK.open("w")
|
||||||
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
lockfile.write(str(os.getpid()))
|
lockfile.write(str(os.getpid()))
|
||||||
@@ -327,75 +464,6 @@ def release_lock(lockfile):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def list_backups():
|
|
||||||
"""Return backups sorted by mtime (newest first)."""
|
|
||||||
ensure_dir(BACKUP_ROOT)
|
|
||||||
backups = [p for p in BACKUP_ROOT.iterdir() if p.is_dir()]
|
|
||||||
backups.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
||||||
return backups
|
|
||||||
|
|
||||||
|
|
||||||
def get_backup_version(path: pathlib.Path):
|
|
||||||
vf = path / "version.txt"
|
|
||||||
if not vf.exists():
|
|
||||||
web_version = path / "pikit-web" / "data" / "version.json"
|
|
||||||
if not web_version.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(web_version.read_text()).get("version")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return vf.read_text().strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def choose_rollback_backup():
|
|
||||||
"""
|
|
||||||
Pick the most recent backup whose version differs from the currently
|
|
||||||
installed version. If none differ, fall back to the newest backup.
|
|
||||||
"""
|
|
||||||
backups = list_backups()
|
|
||||||
if not backups:
|
|
||||||
return None
|
|
||||||
current = read_current_version()
|
|
||||||
for b in backups:
|
|
||||||
ver = get_backup_version(b)
|
|
||||||
if ver and ver != current:
|
|
||||||
return b
|
|
||||||
return backups[0]
|
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(target: pathlib.Path):
|
|
||||||
if (target / "pikit-web").exists():
|
|
||||||
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
|
||||||
shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True)
|
|
||||||
if (target / "pikit-api.py").exists():
|
|
||||||
shutil.copy2(target / "pikit-api.py", API_PATH)
|
|
||||||
os.chmod(API_PATH, 0o755)
|
|
||||||
if (target / "pikit_api").exists():
|
|
||||||
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
|
|
||||||
shutil.copytree(target / "pikit_api", API_PACKAGE_DIR, dirs_exist_ok=True)
|
|
||||||
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if (target / "version.txt").exists():
|
|
||||||
shutil.copy2(target / "version.txt", VERSION_FILE)
|
|
||||||
else:
|
|
||||||
ver = get_backup_version(target)
|
|
||||||
if ver:
|
|
||||||
VERSION_FILE.write_text(str(ver))
|
|
||||||
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
|
|
||||||
subprocess.run(["systemctl", "restart", svc], check=False)
|
|
||||||
|
|
||||||
|
|
||||||
def prune_backups(keep: int = 2):
|
|
||||||
if keep < 1:
|
|
||||||
keep = 1
|
|
||||||
backups = list_backups()
|
|
||||||
for old in backups[keep:]:
|
|
||||||
shutil.rmtree(old, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def check_for_update():
|
def check_for_update():
|
||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
lock = acquire_lock()
|
lock = acquire_lock()
|
||||||
@@ -455,6 +523,71 @@ def check_for_update():
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _install_manifest(manifest: Dict[str, Any], meta: Optional[Dict[str, Any]], state: Dict[str, Any]):
|
||||||
|
latest = manifest.get("version") or manifest.get("latest_version")
|
||||||
|
if not latest:
|
||||||
|
raise RuntimeError("Manifest missing version")
|
||||||
|
|
||||||
|
bundle_url = manifest.get("bundle") or manifest.get("url")
|
||||||
|
if not bundle_url:
|
||||||
|
raise RuntimeError("Manifest missing bundle url")
|
||||||
|
stage_dir = TMP_ROOT / str(latest)
|
||||||
|
bundle_path = stage_dir / "bundle.tar.gz"
|
||||||
|
ensure_dir(stage_dir)
|
||||||
|
|
||||||
|
state["progress"] = "Downloading release…"
|
||||||
|
save_update_state(state)
|
||||||
|
download_file(bundle_url, bundle_path)
|
||||||
|
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
|
||||||
|
|
||||||
|
expected_hash = None
|
||||||
|
for f in manifest.get("files", []):
|
||||||
|
if f.get("path") == "bundle.tar.gz" and f.get("sha256"):
|
||||||
|
expected_hash = f["sha256"]
|
||||||
|
break
|
||||||
|
if expected_hash:
|
||||||
|
got = sha256_file(bundle_path)
|
||||||
|
if got.lower() != expected_hash.lower():
|
||||||
|
raise RuntimeError("Bundle hash mismatch")
|
||||||
|
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
|
||||||
|
|
||||||
|
state["progress"] = "Staging files…"
|
||||||
|
save_update_state(state)
|
||||||
|
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||||
|
tar.extractall(stage_dir)
|
||||||
|
|
||||||
|
staged_web = stage_dir / "pikit-web"
|
||||||
|
if staged_web.exists():
|
||||||
|
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
||||||
|
shutil.copytree(staged_web, WEB_ROOT)
|
||||||
|
staged_api = stage_dir / "pikit-api.py"
|
||||||
|
if staged_api.exists():
|
||||||
|
shutil.copy2(staged_api, API_PATH)
|
||||||
|
os.chmod(API_PATH, 0o755)
|
||||||
|
staged_pkg = stage_dir / "pikit_api"
|
||||||
|
if staged_pkg.exists():
|
||||||
|
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
|
||||||
|
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
# Restart frontend to pick up new assets; avoid restarting this API process
|
||||||
|
# mid-apply to prevent leaving state in_progress.
|
||||||
|
subprocess.run(["systemctl", "restart", "dietpi-dashboard-frontend.service"], check=False)
|
||||||
|
|
||||||
|
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
VERSION_FILE.write_text(str(latest))
|
||||||
|
|
||||||
|
state["current_version"] = str(latest)
|
||||||
|
state["latest_version"] = str(latest)
|
||||||
|
state["changelog_url"] = manifest.get("changelog")
|
||||||
|
state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest))
|
||||||
|
state["current_release_date"] = state.get("latest_release_date")
|
||||||
|
state["status"] = "up_to_date"
|
||||||
|
state["message"] = "Update installed"
|
||||||
|
state["progress"] = None
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("info", "Update applied", {"version": str(latest)})
|
||||||
|
|
||||||
|
|
||||||
def _stage_backup() -> pathlib.Path:
|
def _stage_backup() -> pathlib.Path:
|
||||||
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||||
backup_dir = BACKUP_ROOT / ts
|
backup_dir = BACKUP_ROOT / ts
|
||||||
@@ -494,70 +627,7 @@ def apply_update():
|
|||||||
try:
|
try:
|
||||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||||
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
|
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
|
||||||
latest = manifest.get("version") or manifest.get("latest_version")
|
_install_manifest(manifest, meta, state)
|
||||||
if not latest:
|
|
||||||
raise RuntimeError("Manifest missing version")
|
|
||||||
|
|
||||||
backup_dir = _stage_backup()
|
|
||||||
prune_backups(keep=1)
|
|
||||||
|
|
||||||
bundle_url = manifest.get("bundle") or manifest.get("url")
|
|
||||||
if not bundle_url:
|
|
||||||
raise RuntimeError("Manifest missing bundle url")
|
|
||||||
stage_dir = TMP_ROOT / str(latest)
|
|
||||||
bundle_path = stage_dir / "bundle.tar.gz"
|
|
||||||
ensure_dir(stage_dir)
|
|
||||||
|
|
||||||
state["progress"] = "Downloading release…"
|
|
||||||
save_update_state(state)
|
|
||||||
download_file(bundle_url, bundle_path)
|
|
||||||
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
|
|
||||||
|
|
||||||
expected_hash = None
|
|
||||||
for f in manifest.get("files", []):
|
|
||||||
if f.get("path") == "bundle.tar.gz" and f.get("sha256"):
|
|
||||||
expected_hash = f["sha256"]
|
|
||||||
break
|
|
||||||
if expected_hash:
|
|
||||||
got = sha256_file(bundle_path)
|
|
||||||
if got.lower() != expected_hash.lower():
|
|
||||||
raise RuntimeError("Bundle hash mismatch")
|
|
||||||
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
|
|
||||||
|
|
||||||
state["progress"] = "Staging files…"
|
|
||||||
save_update_state(state)
|
|
||||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
|
||||||
tar.extractall(stage_dir)
|
|
||||||
|
|
||||||
staged_web = stage_dir / "pikit-web"
|
|
||||||
if staged_web.exists():
|
|
||||||
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
|
||||||
shutil.copytree(staged_web, WEB_ROOT)
|
|
||||||
staged_api = stage_dir / "pikit-api.py"
|
|
||||||
if staged_api.exists():
|
|
||||||
shutil.copy2(staged_api, API_PATH)
|
|
||||||
os.chmod(API_PATH, 0o755)
|
|
||||||
staged_pkg = stage_dir / "pikit_api"
|
|
||||||
if staged_pkg.exists():
|
|
||||||
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
|
|
||||||
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
|
|
||||||
|
|
||||||
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
|
|
||||||
subprocess.run(["systemctl", "restart", svc], check=False)
|
|
||||||
|
|
||||||
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
VERSION_FILE.write_text(str(latest))
|
|
||||||
|
|
||||||
state["current_version"] = str(latest)
|
|
||||||
state["latest_version"] = str(latest)
|
|
||||||
state["changelog_url"] = manifest.get("changelog")
|
|
||||||
state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest))
|
|
||||||
state["current_release_date"] = state.get("latest_release_date")
|
|
||||||
state["status"] = "up_to_date"
|
|
||||||
state["message"] = "Update installed"
|
|
||||||
state["progress"] = None
|
|
||||||
save_update_state(state)
|
|
||||||
diag_log("info", "Update applied", {"version": str(latest)})
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = f"No release available ({e.code})"
|
state["message"] = f"No release available ({e.code})"
|
||||||
@@ -570,18 +640,6 @@ def apply_update():
|
|||||||
state["latest_release_date"] = None
|
state["latest_release_date"] = None
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
diag_log("error", "Update apply failed", {"error": str(e)})
|
diag_log("error", "Update apply failed", {"error": str(e)})
|
||||||
backup = choose_rollback_backup()
|
|
||||||
if backup:
|
|
||||||
try:
|
|
||||||
restore_backup(backup)
|
|
||||||
state["current_version"] = read_current_version()
|
|
||||||
state["message"] += f" (rolled back to backup {backup.name})"
|
|
||||||
save_update_state(state)
|
|
||||||
diag_log("info", "Rollback after failed update", {"backup": backup.name})
|
|
||||||
except Exception as re:
|
|
||||||
state["message"] += f" (rollback failed: {re})"
|
|
||||||
save_update_state(state)
|
|
||||||
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
|
|
||||||
finally:
|
finally:
|
||||||
state["in_progress"] = False
|
state["in_progress"] = False
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
@@ -591,55 +649,64 @@ def apply_update():
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
def rollback_update():
|
def apply_update_version(version: str, channel: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Install a specific version chosen by the user. Uses the releases list to
|
||||||
|
resolve the manifest URL.
|
||||||
|
"""
|
||||||
|
version = str(version).lstrip("vV")
|
||||||
state = load_update_state()
|
state = load_update_state()
|
||||||
|
channel = channel or state.get("channel") or "stable"
|
||||||
|
if state.get("in_progress"):
|
||||||
|
state["message"] = "Update already in progress"
|
||||||
|
save_update_state(state)
|
||||||
|
return state
|
||||||
|
|
||||||
lock = acquire_lock()
|
lock = acquire_lock()
|
||||||
if lock is None:
|
if lock is None:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = "Another update is running"
|
state["message"] = "Another update is running"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
state["in_progress"] = True
|
state["in_progress"] = True
|
||||||
state["status"] = "in_progress"
|
state["status"] = "in_progress"
|
||||||
state["progress"] = "Rolling back…"
|
state["progress"] = f"Preparing {version}…"
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
diag_log("info", "Rollback started")
|
diag_log("info", "Manual update started", {"version": version, "channel": channel})
|
||||||
backup = choose_rollback_backup()
|
|
||||||
if not backup:
|
try:
|
||||||
|
releases = list_available_releases("dev" if channel == "dev" else "stable", limit=40)
|
||||||
|
entry = next((r for r in releases if str(r.get("version")) == version), None)
|
||||||
|
if not entry:
|
||||||
|
raise RuntimeError(f"Version {version} not found")
|
||||||
|
manifest_url = entry.get("manifest_url")
|
||||||
|
manifest = fetch_manifest(manifest_url)
|
||||||
|
meta = {"version_dates": {version: entry.get("published_at")}}
|
||||||
|
_install_manifest(manifest, meta, state)
|
||||||
|
except Exception as e:
|
||||||
state["status"] = "error"
|
state["status"] = "error"
|
||||||
state["message"] = "No backup available to rollback."
|
state["message"] = f"Update failed: {e}"
|
||||||
|
state["progress"] = None
|
||||||
|
state["latest_release_date"] = None
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("error", "Manual update failed", {"error": str(e), "version": version})
|
||||||
|
finally:
|
||||||
state["in_progress"] = False
|
state["in_progress"] = False
|
||||||
state["progress"] = None
|
state["progress"] = None
|
||||||
save_update_state(state)
|
save_update_state(state)
|
||||||
release_lock(lock)
|
if lock:
|
||||||
return state
|
release_lock(lock)
|
||||||
try:
|
|
||||||
restore_backup(backup)
|
|
||||||
state["status"] = "up_to_date"
|
|
||||||
state["current_version"] = read_current_version()
|
|
||||||
state["latest_version"] = state.get("latest_version") or state["current_version"]
|
|
||||||
ver = get_backup_version(backup)
|
|
||||||
suffix = f" (version {ver})" if ver else ""
|
|
||||||
state["message"] = f"Rolled back to backup {backup.name}{suffix}"
|
|
||||||
diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver})
|
|
||||||
except Exception as e:
|
|
||||||
state["status"] = "error"
|
|
||||||
state["message"] = f"Rollback failed: {e}"
|
|
||||||
diag_log("error", "Rollback failed", {"error": str(e)})
|
|
||||||
state["in_progress"] = False
|
|
||||||
state["progress"] = None
|
|
||||||
save_update_state(state)
|
|
||||||
release_lock(lock)
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
def start_background_task(mode: str):
|
def start_background_task(mode: str):
|
||||||
"""
|
"""
|
||||||
Kick off a background update/rollback via systemd-run so nginx/API restarts
|
Kick off a background update via systemd-run so nginx/API restarts
|
||||||
do not break the caller connection.
|
do not break the caller connection.
|
||||||
mode: "apply" or "rollback"
|
mode: "apply"
|
||||||
"""
|
"""
|
||||||
assert mode in ("apply", "rollback"), "invalid mode"
|
assert mode in ("apply",), "invalid mode"
|
||||||
unit = f"pikit-update-{mode}"
|
unit = f"pikit-update-{mode}"
|
||||||
cmd = ["systemd-run", "--unit", unit, "--quiet"]
|
cmd = ["systemd-run", "--unit", unit, "--quiet"]
|
||||||
if DEFAULT_MANIFEST_URL:
|
if DEFAULT_MANIFEST_URL:
|
||||||
@@ -653,4 +720,3 @@ def start_background_task(mode: str):
|
|||||||
|
|
||||||
# Backwards compat aliases
|
# Backwards compat aliases
|
||||||
apply_update_stub = apply_update
|
apply_update_stub = apply_update
|
||||||
rollback_update_stub = rollback_update
|
|
||||||
|
|||||||
Reference in New Issue
Block a user