Compare commits
18 Commits
v0.1.2
...
v0.1.3-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87b90bf9f | ||
|
|
8557140193 | ||
|
|
86438b11f3 | ||
|
|
3a785832b1 | ||
|
|
a94cd17186 | ||
|
|
b01bfcd38e | ||
|
|
831a98c5a1 | ||
|
|
daea783d38 | ||
|
|
90d3e5676a | ||
|
|
8a054c5d85 | ||
|
|
009ac8cdd0 | ||
|
|
7a9ffb710a | ||
|
|
15da438625 | ||
|
|
50ddc3e211 | ||
|
|
e7a79246b8 | ||
|
|
bb2fb2dcf2 | ||
|
|
222f6f9e77 | ||
|
|
250ea2e00d |
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/`.
|
||||
- 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
|
||||
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`.
|
||||
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
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def parse_args():
|
||||
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("--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("--port", type=int, default=PORT, help="Bind port (default 4000)")
|
||||
return parser.parse_args()
|
||||
@@ -36,9 +35,6 @@ def main():
|
||||
if args.check_update:
|
||||
check_for_update()
|
||||
return
|
||||
if args.rollback_update:
|
||||
rollback_update()
|
||||
return
|
||||
run_server(host=args.host, port=args.port)
|
||||
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ export const applyRelease = () =>
|
||||
api("/api/update/apply", {
|
||||
method: "POST",
|
||||
});
|
||||
export const rollbackRelease = () =>
|
||||
api("/api/update/rollback", {
|
||||
export const applyReleaseVersion = (version) =>
|
||||
api("/api/update/apply_version", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ version }),
|
||||
});
|
||||
export const listReleases = () => api("/api/update/releases");
|
||||
export const setReleaseAutoCheck = (enable) =>
|
||||
api("/api/update/auto", {
|
||||
method: "POST",
|
||||
|
||||
@@ -94,6 +94,60 @@
|
||||
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 {
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 6px;
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getReleaseStatus,
|
||||
checkRelease,
|
||||
applyRelease,
|
||||
rollbackRelease,
|
||||
applyReleaseVersion,
|
||||
listReleases,
|
||||
setReleaseAutoCheck,
|
||||
setReleaseChannel,
|
||||
} from "./api.js";
|
||||
@@ -18,12 +19,20 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
const releaseClose = document.getElementById("releaseClose");
|
||||
const releaseCurrent = document.getElementById("releaseCurrent");
|
||||
const releaseLatest = document.getElementById("releaseLatest");
|
||||
const releaseCurrentDate = document.getElementById("releaseCurrentDate");
|
||||
const releaseLatestDate = document.getElementById("releaseLatestDate");
|
||||
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
||||
const releaseProgress = document.getElementById("releaseProgress");
|
||||
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
||||
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 releaseStatusChip = document.getElementById("releaseStatusChip");
|
||||
const releaseChannelChip = document.getElementById("releaseChannelChip");
|
||||
const releaseLastCheckChip = document.getElementById("releaseLastCheckChip");
|
||||
const releaseLog = document.getElementById("releaseLog");
|
||||
const releaseLogStatus = document.getElementById("releaseLogStatus");
|
||||
const releaseLogCopy = document.getElementById("releaseLogCopy");
|
||||
@@ -44,9 +53,90 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
let changelogCache = { version: null, text: "" };
|
||||
let lastChangelogUrl = null;
|
||||
let releaseChannel = "dev";
|
||||
let releaseOptions = [];
|
||||
const logger = createReleaseLogger(logUi);
|
||||
logger.attach(releaseLog);
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (e) {
|
||||
return "—";
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!releaseFlagTop) return;
|
||||
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
||||
@@ -69,7 +159,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
const msg = shorten(message, 80) || "";
|
||||
releaseFlagTop.title = msg || "Pi-Kit release status";
|
||||
if (releaseStatusMsg) {
|
||||
releaseStatusMsg.textContent = status === "update_available" ? msg || "Update available" : "";
|
||||
const isUrlMsg = msg && /^https?:/i.test(msg);
|
||||
const safeMsg = isUrlMsg ? "Update available" : msg;
|
||||
releaseStatusMsg.textContent = status === "update_available" ? safeMsg || "Update available" : "";
|
||||
releaseStatusMsg.classList.remove("error");
|
||||
}
|
||||
if (releaseLogStatus) {
|
||||
@@ -133,6 +225,10 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
auto_check = false,
|
||||
progress = null,
|
||||
channel = "dev",
|
||||
current_release_date = null,
|
||||
latest_release_date = null,
|
||||
changelog_url = null,
|
||||
last_check = null,
|
||||
} = data || {};
|
||||
releaseChannel = channel || "dev";
|
||||
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
|
||||
@@ -143,14 +239,24 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
lastReleaseLogKey = key;
|
||||
}
|
||||
releaseLastFetched = now;
|
||||
if (status === "update_available" && message && message.startsWith("http")) {
|
||||
lastChangelogUrl = changelog_url || null;
|
||||
if (!lastChangelogUrl && status === "update_available" && message && message.startsWith("http")) {
|
||||
lastChangelogUrl = message;
|
||||
} else if (latest_version) {
|
||||
} else if (!lastChangelogUrl && latest_version) {
|
||||
lastChangelogUrl = `https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v${latest_version}/CHANGELOG-${latest_version}.txt`;
|
||||
}
|
||||
setReleaseChip(data);
|
||||
if (releaseCurrent) releaseCurrent.textContent = current_version;
|
||||
if (releaseLatest) releaseLatest.textContent = latest_version;
|
||||
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_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 (releaseProgress) releaseProgress.textContent = "";
|
||||
if (status === "in_progress" && progress) {
|
||||
@@ -212,6 +318,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
releaseBtn?.addEventListener("click", () => {
|
||||
releaseModal?.classList.remove("hidden");
|
||||
loadReleaseStatus(true);
|
||||
loadReleaseList();
|
||||
});
|
||||
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
||||
// Do not allow dismiss by clicking backdrop (consistency with other modals)
|
||||
@@ -274,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 {
|
||||
lastReleaseToastKey = null;
|
||||
logUi("Rollback requested");
|
||||
const ver = selected.value;
|
||||
logUi(`Install version ${ver} requested`);
|
||||
releaseBusyActive = true;
|
||||
showBusy("Rolling back…", "Restoring previous backup.");
|
||||
logRelease("Starting rollback…");
|
||||
await rollbackRelease();
|
||||
showBusy(`Installing ${ver}…`, "Applying selected release. This can take up to a minute.");
|
||||
logRelease(`Installing ${ver}…`);
|
||||
await applyReleaseVersion(ver);
|
||||
pollReleaseStatus();
|
||||
showToast("Rollback started", "success");
|
||||
showToast(`Installing ${ver}`, "success");
|
||||
} catch (e) {
|
||||
showToast(e.error || "Rollback failed", "error");
|
||||
logRelease(`Error: ${e.error || "Rollback failed"}`);
|
||||
showToast(e.error || "Install failed", "error");
|
||||
logRelease(`Error: ${e.error || "Install failed"}`);
|
||||
} finally {
|
||||
if (releaseProgress) releaseProgress.textContent = "";
|
||||
}
|
||||
@@ -309,6 +429,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
releaseChannel = chan;
|
||||
logRelease(`Channel set to ${chan}`);
|
||||
await loadReleaseStatus(true);
|
||||
await loadReleaseList();
|
||||
} catch (e) {
|
||||
showToast(e.error || "Failed to save channel", "error");
|
||||
releaseChannelToggle.checked = releaseChannel === "dev";
|
||||
@@ -317,8 +438,11 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
|
||||
|
||||
releaseChangelogBtn?.addEventListener("click", async () => {
|
||||
const state = window.__lastReleaseState || {};
|
||||
const { latest_version, message } = state;
|
||||
const url = (message && message.startsWith("http") ? message : null) || lastChangelogUrl;
|
||||
const { latest_version, message, changelog_url } = state;
|
||||
const url =
|
||||
changelog_url ||
|
||||
(message && message.startsWith("http") ? message : null) ||
|
||||
lastChangelogUrl;
|
||||
if (!url) {
|
||||
showToast("No changelog URL available", "error");
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"last_check": "2025-12-10T22:00:00Z",
|
||||
"status": "update_available",
|
||||
"message": "New UI polish and bug fixes.",
|
||||
"changelog_url": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.1-mock/CHANGELOG-0.1.1-mock.txt",
|
||||
"latest_release_date": "2025-12-09T18:00:00Z",
|
||||
"current_release_date": "2025-12-01T17:00:00Z",
|
||||
"auto_check": true,
|
||||
"in_progress": false,
|
||||
"progress": null
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.1.2"
|
||||
"version": "0.1.3-dev3"
|
||||
}
|
||||
|
||||
@@ -144,14 +144,21 @@
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<p class="hint quiet">Current version</p>
|
||||
<h3 id="releaseCurrent">n/a</h3>
|
||||
<p class="hint quiet" id="releaseCurrentDate">—</p>
|
||||
</div>
|
||||
<div class="align-right">
|
||||
<p class="hint quiet">Latest available</p>
|
||||
<h3 id="releaseLatest">—</h3>
|
||||
<p class="hint quiet" id="releaseLatestDate">—</p>
|
||||
<p id="releaseStatusMsg" class="hint status-msg"></p>
|
||||
<button id="releaseChangelogBtn" class="ghost small" title="View changelog">Changelog</button>
|
||||
</div>
|
||||
@@ -163,8 +170,8 @@
|
||||
<button id="releaseApplyBtn" title="Download and install the latest release">
|
||||
Upgrade
|
||||
</button>
|
||||
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
|
||||
Rollback
|
||||
<button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
|
||||
Manual selection
|
||||
</button>
|
||||
<label class="checkbox-row inline">
|
||||
<input type="checkbox" id="releaseAutoCheck" />
|
||||
@@ -175,6 +182,18 @@
|
||||
<span>Allow dev builds</span>
|
||||
</label>
|
||||
</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 class="log-card">
|
||||
<div class="log-header">
|
||||
|
||||
@@ -8,4 +8,4 @@ modules while keeping the on-device entry point compatible.
|
||||
# Re-export commonly used helpers for convenience
|
||||
from .constants import HOST, PORT # 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
|
||||
DEFAULT_MANIFEST_URL = os.environ.get(
|
||||
"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")
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ from .releases import (
|
||||
read_current_version,
|
||||
save_update_state,
|
||||
start_background_task,
|
||||
list_available_releases,
|
||||
apply_update_version,
|
||||
)
|
||||
from .services import (
|
||||
FirewallToolMissing,
|
||||
@@ -82,6 +84,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
state = _load_diag_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"})
|
||||
|
||||
# POST endpoints
|
||||
@@ -122,6 +129,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
state = check_for_update()
|
||||
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"):
|
||||
start_background_task("apply")
|
||||
state = load_update_state()
|
||||
@@ -130,14 +146,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
save_update_state(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"):
|
||||
state = load_update_state()
|
||||
state["auto_check"] = bool(payload.get("enable"))
|
||||
|
||||
@@ -16,8 +16,8 @@ from .constants import (
|
||||
API_PACKAGE_DIR,
|
||||
API_PATH,
|
||||
AUTH_TOKEN,
|
||||
BACKUP_ROOT,
|
||||
DEFAULT_MANIFEST_URL,
|
||||
DEFAULT_DEV_MANIFEST_URL,
|
||||
TMP_ROOT,
|
||||
UPDATE_LOCK,
|
||||
UPDATE_STATE,
|
||||
@@ -43,12 +43,48 @@ def read_current_version() -> str:
|
||||
|
||||
def load_update_state() -> Dict[str, Any]:
|
||||
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if UPDATE_STATE.exists():
|
||||
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:
|
||||
return json.loads(UPDATE_STATE.read_text())
|
||||
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 {
|
||||
return state
|
||||
|
||||
if UPDATE_STATE.exists():
|
||||
try:
|
||||
state = json.loads(UPDATE_STATE.read_text())
|
||||
state.setdefault("changelog_url", None)
|
||||
state.setdefault("latest_release_date", None)
|
||||
state.setdefault("current_release_date", None)
|
||||
return _reset_if_stale(state)
|
||||
except Exception:
|
||||
pass
|
||||
return _reset_if_stale(
|
||||
{
|
||||
"current_version": read_current_version(),
|
||||
"latest_version": None,
|
||||
"last_check": None,
|
||||
@@ -58,7 +94,11 @@ def load_update_state() -> Dict[str, Any]:
|
||||
"in_progress": False,
|
||||
"progress": None,
|
||||
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
|
||||
"changelog_url": None,
|
||||
"latest_release_date": None,
|
||||
"current_release_date": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def save_update_state(state: Dict[str, Any]) -> None:
|
||||
@@ -112,6 +152,17 @@ def fetch_manifest(url: str | None = None):
|
||||
data = resp.read()
|
||||
return json.loads(data.decode())
|
||||
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:
|
||||
alt = _gitea_latest_manifest(target)
|
||||
if alt:
|
||||
@@ -119,32 +170,108 @@ def fetch_manifest(url: str | None = None):
|
||||
raise
|
||||
|
||||
|
||||
def fetch_manifest_for_channel(channel: str):
|
||||
def _try_fetch(url: Optional[str]):
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
return fetch_manifest(url)
|
||||
except Exception:
|
||||
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):
|
||||
"""
|
||||
For stable: use normal manifest (latest non-prerelease).
|
||||
For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API.
|
||||
If a stable build is newer than the latest dev build, prefer the newer stable even on dev channel.
|
||||
"""
|
||||
channel = channel or "dev"
|
||||
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_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 DEFAULT_MANIFEST_URL
|
||||
manifest = None
|
||||
manual_dev_manifest = None
|
||||
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:
|
||||
manifest = fetch_manifest(base_manifest_url)
|
||||
manifest = fetch_manifest(stable_manifest_url)
|
||||
except Exception:
|
||||
manifest = None
|
||||
|
||||
if manifest and channel == "stable":
|
||||
return manifest
|
||||
if manifest:
|
||||
version = manifest.get("version") or manifest.get("latest_version")
|
||||
if channel == "dev" and version and "dev" in str(version):
|
||||
return manifest
|
||||
def _norm_ver(ver):
|
||||
if ver is None:
|
||||
return None
|
||||
s = str(ver).strip()
|
||||
if s.lower().startswith("v"):
|
||||
s = s[1:]
|
||||
return s
|
||||
|
||||
def _newer(a, b):
|
||||
try:
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
return LooseVersion(a) > LooseVersion(b)
|
||||
except Exception:
|
||||
return a > b
|
||||
|
||||
def _release_version(rel: Dict[str, Any]):
|
||||
for key in ("tag_name", "name"):
|
||||
val = rel.get(key)
|
||||
if val:
|
||||
v = _norm_ver(val)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
def _manifest_from_release(rel: Dict[str, Any]):
|
||||
asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||
if not asset or not asset.get("browser_download_url"):
|
||||
return None
|
||||
mf = fetch_manifest(asset["browser_download_url"])
|
||||
if mf:
|
||||
dt = rel.get("published_at") or rel.get("created_at")
|
||||
if dt:
|
||||
mf["_release_date"] = dt
|
||||
tag = rel.get("tag_name")
|
||||
if tag:
|
||||
mf["_release_tag"] = tag
|
||||
return mf
|
||||
|
||||
try:
|
||||
parts = base_manifest_url.split("/")
|
||||
if "releases" not in parts:
|
||||
if manifest:
|
||||
return manifest
|
||||
return fetch_manifest(base_manifest_url)
|
||||
# No releases API for this URL; keep any fetched manifest and skip API discovery.
|
||||
releases = []
|
||||
if not manifest:
|
||||
manifest = fetch_manifest(base_manifest_url)
|
||||
else:
|
||||
idx = parts.index("releases")
|
||||
owner = parts[idx - 2]
|
||||
repo = parts[idx - 1]
|
||||
@@ -157,29 +284,132 @@ def fetch_manifest_for_channel(channel: str):
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
releases = json.loads(resp.read().decode())
|
||||
|
||||
def pick(predicate):
|
||||
for r in releases:
|
||||
if predicate(r):
|
||||
asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||
if asset and asset.get("browser_download_url"):
|
||||
return fetch_manifest(asset["browser_download_url"])
|
||||
return None
|
||||
# Map release versions to published dates so we can surface them later
|
||||
for rel in releases:
|
||||
v = _release_version(rel)
|
||||
if v and v not in version_dates:
|
||||
version_dates[v] = rel.get("published_at") or rel.get("created_at")
|
||||
|
||||
dev_rel = None
|
||||
stable_rel = None
|
||||
dev_ver = None
|
||||
stable_ver = None
|
||||
for rel in releases:
|
||||
ver_str = _release_version(rel)
|
||||
parsed = _norm_ver(ver_str) if ver_str else None
|
||||
if parsed is None:
|
||||
continue
|
||||
if rel.get("prerelease") is True:
|
||||
if dev_ver is None or _newer(parsed.replace("-", "."), dev_ver):
|
||||
dev_rel = rel
|
||||
dev_ver = parsed.replace("-", ".")
|
||||
elif rel.get("prerelease") is False:
|
||||
if stable_ver is None or _newer(parsed.replace("-", "."), stable_ver):
|
||||
stable_rel = rel
|
||||
stable_ver = parsed.replace("-", ".")
|
||||
|
||||
latest_dev = _manifest_from_release(dev_rel) if dev_rel else None
|
||||
latest_stable = _manifest_from_release(stable_rel) if stable_rel else None
|
||||
|
||||
# If API didn't give us a dev manifest, try explicitly configured dev URL
|
||||
if dev_manifest_url and latest_dev is None:
|
||||
latest_dev = _try_fetch(dev_manifest_url)
|
||||
if latest_dev and "_release_date" not in latest_dev:
|
||||
latest_dev["_release_date"] = version_dates.get(
|
||||
_norm_ver(latest_dev.get("version") or latest_dev.get("latest_version")), None
|
||||
)
|
||||
|
||||
# Attach publish date to the base manifest when possible
|
||||
if manifest:
|
||||
mver = _norm_ver(manifest.get("version") or manifest.get("latest_version"))
|
||||
if mver and mver in version_dates and "_release_date" not in manifest:
|
||||
manifest["_release_date"] = version_dates[mver]
|
||||
|
||||
if channel == "dev":
|
||||
m = pick(lambda r: r.get("prerelease") is True)
|
||||
if m:
|
||||
return m
|
||||
m = pick(lambda r: r.get("prerelease") is False)
|
||||
if m:
|
||||
return m
|
||||
# Choose the newest by version comparison across stable/dev/base/manual-dev candidates
|
||||
candidates = [c for c in (latest_dev, manual_dev_manifest, latest_stable, manifest) if c]
|
||||
best = None
|
||||
best_ver = None
|
||||
for c in candidates:
|
||||
ver = _norm_ver(c.get("version") or c.get("latest_version"))
|
||||
if not ver:
|
||||
continue
|
||||
ver_cmp = ver.replace("-", ".")
|
||||
if best_ver is None or _newer(ver_cmp, best_ver):
|
||||
best = c
|
||||
best_ver = ver_cmp
|
||||
manifest = best
|
||||
else:
|
||||
# stable channel
|
||||
manifest = latest_stable or manifest
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# As a last resort for dev channel, consider explicitly configured dev manifest even without API data
|
||||
if channel == "dev" and manifest is None and dev_manifest_url:
|
||||
manifest = _try_fetch(dev_manifest_url)
|
||||
|
||||
# If still nothing and stable manifest URL is set, try that once more
|
||||
if manifest is None and stable_manifest_url and stable_manifest_url != base_manifest_url:
|
||||
manifest = _try_fetch(stable_manifest_url)
|
||||
|
||||
if manifest:
|
||||
if with_meta:
|
||||
return manifest, {"version_dates": version_dates}
|
||||
return manifest
|
||||
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):
|
||||
ensure_dir(dest.parent)
|
||||
req = urllib.request.Request(url)
|
||||
@@ -203,6 +433,19 @@ def fetch_text_with_auth(url: str):
|
||||
def acquire_lock():
|
||||
try:
|
||||
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")
|
||||
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
lockfile.write(str(os.getpid()))
|
||||
@@ -221,75 +464,6 @@ def release_lock(lockfile):
|
||||
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():
|
||||
state = load_update_state()
|
||||
lock = acquire_lock()
|
||||
@@ -303,10 +477,25 @@ def check_for_update():
|
||||
state["progress"] = "Checking for updates…"
|
||||
save_update_state(state)
|
||||
try:
|
||||
manifest = fetch_manifest_for_channel(state.get("channel") or "dev")
|
||||
manifest, meta = fetch_manifest_for_channel(state.get("channel") or "dev", with_meta=True)
|
||||
latest = manifest.get("version") or manifest.get("latest_version")
|
||||
state["latest_version"] = latest
|
||||
state["changelog_url"] = manifest.get("changelog")
|
||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
version_dates = (meta or {}).get("version_dates") or {}
|
||||
if manifest.get("_release_date"):
|
||||
state["latest_release_date"] = manifest.get("_release_date")
|
||||
elif latest and latest in version_dates:
|
||||
state["latest_release_date"] = version_dates.get(str(latest))
|
||||
else:
|
||||
state["latest_release_date"] = None
|
||||
state["current_release_date"] = None
|
||||
current_ver = state.get("current_version")
|
||||
if current_ver and current_ver in version_dates:
|
||||
state["current_release_date"] = version_dates.get(str(current_ver))
|
||||
elif current_ver and current_ver == latest and state["latest_release_date"]:
|
||||
# If current matches latest and we have a date for latest, reuse it
|
||||
state["current_release_date"] = state["latest_release_date"]
|
||||
channel = state.get("channel") or "dev"
|
||||
if channel == "stable" and latest and "dev" in str(latest):
|
||||
state["status"] = "up_to_date"
|
||||
@@ -314,7 +503,7 @@ def check_for_update():
|
||||
else:
|
||||
if latest and latest != state.get("current_version"):
|
||||
state["status"] = "update_available"
|
||||
state["message"] = manifest.get("changelog", "Update available")
|
||||
state["message"] = manifest.get("notes") or manifest.get("message") or "Update available"
|
||||
else:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Up to date"
|
||||
@@ -323,6 +512,7 @@ def check_for_update():
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = f"Could not reach update server: {e}"
|
||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
state["latest_release_date"] = None
|
||||
diag_log("error", "Update check failed", {"error": str(e)})
|
||||
finally:
|
||||
state["in_progress"] = False
|
||||
@@ -333,52 +523,11 @@ def check_for_update():
|
||||
return state
|
||||
|
||||
|
||||
def _stage_backup() -> pathlib.Path:
|
||||
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
backup_dir = BACKUP_ROOT / ts
|
||||
ensure_dir(backup_dir)
|
||||
if WEB_ROOT.exists():
|
||||
ensure_dir(backup_dir / "pikit-web")
|
||||
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True)
|
||||
if API_PATH.exists():
|
||||
shutil.copy2(API_PATH, backup_dir / "pikit-api.py")
|
||||
if API_PACKAGE_DIR.exists():
|
||||
shutil.copytree(API_PACKAGE_DIR, backup_dir / "pikit_api", dirs_exist_ok=True)
|
||||
if VERSION_FILE.exists():
|
||||
shutil.copy2(VERSION_FILE, backup_dir / "version.txt")
|
||||
return backup_dir
|
||||
|
||||
|
||||
def apply_update():
|
||||
state = load_update_state()
|
||||
if state.get("in_progress"):
|
||||
state["message"] = "Update already in progress"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
lock = acquire_lock()
|
||||
if lock is None:
|
||||
state["status"] = "error"
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
state["in_progress"] = True
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Starting update…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
|
||||
|
||||
try:
|
||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||
manifest = fetch_manifest_for_channel(channel)
|
||||
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")
|
||||
|
||||
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")
|
||||
@@ -420,41 +569,77 @@ def apply_update():
|
||||
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)
|
||||
# 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:
|
||||
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
backup_dir = BACKUP_ROOT / ts
|
||||
ensure_dir(backup_dir)
|
||||
if WEB_ROOT.exists():
|
||||
ensure_dir(backup_dir / "pikit-web")
|
||||
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True)
|
||||
if API_PATH.exists():
|
||||
shutil.copy2(API_PATH, backup_dir / "pikit-api.py")
|
||||
if API_PACKAGE_DIR.exists():
|
||||
shutil.copytree(API_PACKAGE_DIR, backup_dir / "pikit_api", dirs_exist_ok=True)
|
||||
if VERSION_FILE.exists():
|
||||
shutil.copy2(VERSION_FILE, backup_dir / "version.txt")
|
||||
return backup_dir
|
||||
|
||||
|
||||
def apply_update():
|
||||
state = load_update_state()
|
||||
if state.get("in_progress"):
|
||||
state["message"] = "Update already in progress"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
lock = acquire_lock()
|
||||
if lock is None:
|
||||
state["status"] = "error"
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
state["in_progress"] = True
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Starting update…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
|
||||
|
||||
try:
|
||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
|
||||
_install_manifest(manifest, meta, state)
|
||||
except urllib.error.HTTPError as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"No release available ({e.code})"
|
||||
state["latest_release_date"] = None
|
||||
diag_log("error", "Update apply HTTP error", {"code": e.code})
|
||||
except Exception as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"Update failed: {e}"
|
||||
state["progress"] = None
|
||||
state["latest_release_date"] = None
|
||||
save_update_state(state)
|
||||
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:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
@@ -464,55 +649,64 @@ def apply_update():
|
||||
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()
|
||||
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()
|
||||
if lock is None:
|
||||
state["status"] = "error"
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
state["in_progress"] = True
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Rolling back…"
|
||||
state["progress"] = f"Preparing {version}…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Rollback started")
|
||||
backup = choose_rollback_backup()
|
||||
if not backup:
|
||||
state["status"] = "error"
|
||||
state["message"] = "No backup available to rollback."
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
release_lock(lock)
|
||||
return state
|
||||
diag_log("info", "Manual update started", {"version": version, "channel": channel})
|
||||
|
||||
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})
|
||||
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["message"] = f"Rollback failed: {e}"
|
||||
diag_log("error", "Rollback failed", {"error": str(e)})
|
||||
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["progress"] = None
|
||||
save_update_state(state)
|
||||
if lock:
|
||||
release_lock(lock)
|
||||
return state
|
||||
|
||||
|
||||
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.
|
||||
mode: "apply" or "rollback"
|
||||
mode: "apply"
|
||||
"""
|
||||
assert mode in ("apply", "rollback"), "invalid mode"
|
||||
assert mode in ("apply",), "invalid mode"
|
||||
unit = f"pikit-update-{mode}"
|
||||
cmd = ["systemd-run", "--unit", unit, "--quiet"]
|
||||
if DEFAULT_MANIFEST_URL:
|
||||
@@ -526,4 +720,3 @@ def start_background_task(mode: str):
|
||||
|
||||
# Backwards compat aliases
|
||||
apply_update_stub = apply_update
|
||||
rollback_update_stub = rollback_update
|
||||
|
||||
Reference in New Issue
Block a user