Show running build version in the UI so stale deploys are obvious
Publish container / publish (push) Successful in 24s

Bakes the git commit SHA into the image at build time (Dockerfile
GIT_SHA/BUILD_DATE build-args, set by the CI workflow) and surfaces it
as a small "vX.Y.Z · <sha>" tag on the sign-in screen and in the header
user-menu, sourced from the existing unauthenticated /api/health
endpoint (now also returns appVersion/buildSha/buildDate). Also adds
buildSha/buildDate to the admin diagnostics payload. This makes it
possible to tell at a glance whether a running container matches the
latest published image without digging through registry timestamps.
This commit is contained in:
mfwadejr
2026-09-21 22:42:53 +00:00
parent afa8da647f
commit 2a9d95d3d9
6 changed files with 15 additions and 4 deletions
+3
View File
@@ -53,3 +53,6 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
BUILD_DATE=${{ github.event.repository.updated_at || github.run_id }}
+5
View File
@@ -1,5 +1,8 @@
FROM node:22-alpine FROM node:22-alpine
ARG GIT_SHA=dev
ARG BUILD_DATE
LABEL org.opencontainers.image.source="https://git.us2plus2.com/marvin/vboxstock" LABEL org.opencontainers.image.source="https://git.us2plus2.com/marvin/vboxstock"
LABEL org.opencontainers.image.revision="$GIT_SHA"
LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker" LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker"
WORKDIR /app WORKDIR /app
COPY package.json server.mjs ./ COPY package.json server.mjs ./
@@ -9,6 +12,8 @@ RUN apk add --no-cache su-exec \
&& chmod +x /usr/local/bin/vboxstock-entrypoint \ && chmod +x /usr/local/bin/vboxstock-entrypoint \
&& mkdir -p /data && mkdir -p /data
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production PUID=99 PGID=100 ENV PORT=3000 DATA_DIR=/data NODE_ENV=production PUID=99 PGID=100
ENV GIT_SHA=$GIT_SHA
ENV BUILD_DATE=$BUILD_DATE
EXPOSE 3000 EXPOSE 3000
VOLUME ["/data"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1
+2
View File
@@ -188,4 +188,6 @@ const targetedOcrObserver=new MutationObserver(()=>{const input=$("#receiveForm
const autoAddScanObserver=new MutationObserver(()=>{const form=$("#receiveForm"),help=form?.querySelector(".scan-help"),marker=form?.querySelector("[data-scan-added]");if(form&&marker&&!form.elements.uid.value&&!form.elements.sn.value&&!form.elements.mac.value)marker.remove();if(form&&help?.textContent.includes("read")&&form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value&&!marker){const added=document.createElement("i");added.dataset.scanAdded="true";added.hidden=true;form.append(added);form.querySelector(".add-device").click()}});autoAddScanObserver.observe($("#modalBody"),{childList:true,subtree:true,characterData:true}); const autoAddScanObserver=new MutationObserver(()=>{const form=$("#receiveForm"),help=form?.querySelector(".scan-help"),marker=form?.querySelector("[data-scan-added]");if(form&&marker&&!form.elements.uid.value&&!form.elements.sn.value&&!form.elements.mac.value)marker.remove();if(form&&help?.textContent.includes("read")&&form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value&&!marker){const added=document.createElement("i");added.dataset.scanAdded="true";added.hidden=true;form.append(added);form.querySelector(".add-device").click()}});autoAddScanObserver.observe($("#modalBody"),{childList:true,subtree:true,characterData:true});
if(!("BarcodeDetector" in window))document.addEventListener("click",e=>{const button=e.target.closest(".scan-camera");if(button){e.stopPropagation();scanLabelWithLibrary(button.closest("form"),button.closest("form").querySelector(".scan-help"))}},true); if(!("BarcodeDetector" in window))document.addEventListener("click",e=>{const button=e.target.closest(".scan-camera");if(button){e.stopPropagation();scanLabelWithLibrary(button.closest("form"),button.closest("form").querySelector(".scan-help"))}},true);
applyTheme(); applyTheme();
async function loadBuildInfo(){try{const response=await fetch("/api/health"),data=await response.json(),label=`v${data.appVersion}${data.buildSha&&data.buildSha!=="dev"?" · "+data.buildSha:""}`;document.querySelectorAll(".build-tag").forEach(el=>el.textContent=label)}catch{}}
loadBuildInfo();
initialize(); initialize();
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="header-inner"><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></div></header> <body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><small class="build-tag" id="buildTagAuth"></small><div id="authBody"></div></div></section><div id="appShell"><header><div class="header-inner"><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><small class="build-tag" id="buildTagHeader"></small><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></div></header>
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section> <main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div id="filterBar" class="filter-bar"></div><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main> <section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div id="filterBar" class="filter-bar"></div><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
<dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html> <dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html>
+1
View File
@@ -1 +1,2 @@
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#172033;background:#f5f7fb;--control-h:44px;--control-h-sm:36px;--control-radius:9px;--control-radius-sm:8px;--control-px:14px;--control-px-sm:10px;--control-font:14px;--control-font-sm:13px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer}header{height:76px;background:white;border-bottom:1px solid #e3e7ef;position:sticky;top:0;z-index:2}.header-inner{width:min(1400px,92vw);margin:0 auto;height:100%;display:flex;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700}.brand b{display:grid;place-items:center;background:#2563eb;color:white;border-radius:9px;width:36px;height:36px}.brand+input{flex:1;max-width:620px;border:1px solid #d8deea;background:#f8fafc;height:var(--control-h);padding:0 var(--control-px);border-radius:var(--control-radius);box-sizing:border-box;font-size:var(--control-font)}.actions{margin-left:auto;display:flex;gap:10px}button{border:0;border-radius:var(--control-radius);height:var(--control-h);padding:0 var(--control-px);font-weight:650;font-size:var(--control-font);display:inline-flex;align-items:center;justify-content:center}.primary{background:#2563eb;color:#fff}.secondary{background:#fff;border:1px solid #d7deea;color:#27344a}main{width:min(1400px,92vw);margin:34px auto}.intro{display:flex;justify-content:space-between;align-items:center}.intro h1{font-size:28px}.intro>span{background:#fff;border:1px solid #e0e5ed;padding:9px 12px;border-radius:9px;color:#64748b}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:20px 0}.stats article{background:#fff;border:1px solid #e3e7ef;border-radius:14px;padding:22px;display:grid;gap:6px}.stats span,.stats small{color:#68758b}.stats strong{font-size:30px}.records{background:#fff;border:1px solid #e3e7ef;border-radius:14px;overflow:hidden}.records nav{height:64px;border-bottom:1px solid #e3e7ef;display:flex;align-items:center;padding:0 20px;gap:12px}.records nav .tab{background:transparent;color:#667085;border-radius:0;height:64px}.records nav .tab.active{color:#2563eb;border-bottom:2px solid #2563eb}.tab i{font-style:normal;background:#edf2f8;padding:2px 7px;border-radius:12px}.records nav>span{margin-left:auto;color:#6b7280;font-size:13px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;min-width:950px}th,td{text-align:left;padding:15px 18px;border-bottom:1px solid #edf0f5;vertical-align:middle}th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#758197;background:#fbfcfe}.product strong,.customer strong{display:block}.muted,td small{display:block;color:#7b8799;margin-top:4px}.ids{display:grid;gap:3px}.ids code{font-family:ui-monospace,monospace;font-size:12px}.pill{display:inline-block;color:#087443;background:#dcfce7;border-radius:99px;padding:4px 9px;font-size:12px}.row-actions{display:flex;gap:6px}.row-actions button{height:var(--control-h-sm);padding:0 var(--control-px-sm);background:#edf3ff;color:#1d4ed8;white-space:nowrap;font-size:var(--control-font-sm)}.row-actions .danger{background:#fff0f0;color:#c62828}#empty{padding:60px;text-align:center;color:#758197}dialog{width:min(620px,94vw);border:0;border-radius:16px;padding:26px;box-shadow:0 24px 80px #17203340}dialog::backdrop{background:#17203380}.close{position:absolute;right:15px;top:13px;background:transparent;font-size:25px;color:#728096}dialog h2{margin:4px 0}dialog>div>p{color:#68758b;margin-top:5px}.scan{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}label{display:grid;gap:6px;margin:13px 0;font-weight:600;font-size:13px}label input,label select,label textarea{width:100%;height:var(--control-h);padding:0 var(--control-px);border:1px solid #d6dce7;border-radius:var(--control-radius);background:white;box-sizing:border-box;font-size:var(--control-font)}label textarea{height:auto;min-height:var(--control-h);padding:11px var(--control-px)}.form-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:20px}#toast{position:fixed;right:24px;bottom:24px;background:#172033;color:#fff;padding:13px 17px;border-radius:10px;z-index:4}@media(max-width:760px){header{height:auto}.header-inner{width:100%;padding:14px;flex-wrap:wrap;gap:10px;box-sizing:border-box}.brand+input{order:3;flex-basis:100%;max-width:none}.actions{margin-left:auto}.actions button{font-size:0}.actions button::first-letter{font-size:18px}.stats{grid-template-columns:1fr}.scan,.form-row{grid-template-columns:1fr}.records nav>span{display:none}main{margin-top:18px}} :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#172033;background:#f5f7fb;--control-h:44px;--control-h-sm:36px;--control-radius:9px;--control-radius-sm:8px;--control-px:14px;--control-px-sm:10px;--control-font:14px;--control-font-sm:13px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer}header{height:76px;background:white;border-bottom:1px solid #e3e7ef;position:sticky;top:0;z-index:2}.header-inner{width:min(1400px,92vw);margin:0 auto;height:100%;display:flex;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700}.brand b{display:grid;place-items:center;background:#2563eb;color:white;border-radius:9px;width:36px;height:36px}.brand+input{flex:1;max-width:620px;border:1px solid #d8deea;background:#f8fafc;height:var(--control-h);padding:0 var(--control-px);border-radius:var(--control-radius);box-sizing:border-box;font-size:var(--control-font)}.actions{margin-left:auto;display:flex;gap:10px}button{border:0;border-radius:var(--control-radius);height:var(--control-h);padding:0 var(--control-px);font-weight:650;font-size:var(--control-font);display:inline-flex;align-items:center;justify-content:center}.primary{background:#2563eb;color:#fff}.secondary{background:#fff;border:1px solid #d7deea;color:#27344a}main{width:min(1400px,92vw);margin:34px auto}.intro{display:flex;justify-content:space-between;align-items:center}.intro h1{font-size:28px}.intro>span{background:#fff;border:1px solid #e0e5ed;padding:9px 12px;border-radius:9px;color:#64748b}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:20px 0}.stats article{background:#fff;border:1px solid #e3e7ef;border-radius:14px;padding:22px;display:grid;gap:6px}.stats span,.stats small{color:#68758b}.stats strong{font-size:30px}.records{background:#fff;border:1px solid #e3e7ef;border-radius:14px;overflow:hidden}.records nav{height:64px;border-bottom:1px solid #e3e7ef;display:flex;align-items:center;padding:0 20px;gap:12px}.records nav .tab{background:transparent;color:#667085;border-radius:0;height:64px}.records nav .tab.active{color:#2563eb;border-bottom:2px solid #2563eb}.tab i{font-style:normal;background:#edf2f8;padding:2px 7px;border-radius:12px}.records nav>span{margin-left:auto;color:#6b7280;font-size:13px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;min-width:950px}th,td{text-align:left;padding:15px 18px;border-bottom:1px solid #edf0f5;vertical-align:middle}th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#758197;background:#fbfcfe}.product strong,.customer strong{display:block}.muted,td small{display:block;color:#7b8799;margin-top:4px}.ids{display:grid;gap:3px}.ids code{font-family:ui-monospace,monospace;font-size:12px}.pill{display:inline-block;color:#087443;background:#dcfce7;border-radius:99px;padding:4px 9px;font-size:12px}.row-actions{display:flex;gap:6px}.row-actions button{height:var(--control-h-sm);padding:0 var(--control-px-sm);background:#edf3ff;color:#1d4ed8;white-space:nowrap;font-size:var(--control-font-sm)}.row-actions .danger{background:#fff0f0;color:#c62828}#empty{padding:60px;text-align:center;color:#758197}dialog{width:min(620px,94vw);border:0;border-radius:16px;padding:26px;box-shadow:0 24px 80px #17203340}dialog::backdrop{background:#17203380}.close{position:absolute;right:15px;top:13px;background:transparent;font-size:25px;color:#728096}dialog h2{margin:4px 0}dialog>div>p{color:#68758b;margin-top:5px}.scan{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}label{display:grid;gap:6px;margin:13px 0;font-weight:600;font-size:13px}label input,label select,label textarea{width:100%;height:var(--control-h);padding:0 var(--control-px);border:1px solid #d6dce7;border-radius:var(--control-radius);background:white;box-sizing:border-box;font-size:var(--control-font)}label textarea{height:auto;min-height:var(--control-h);padding:11px var(--control-px)}.form-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:20px}#toast{position:fixed;right:24px;bottom:24px;background:#172033;color:#fff;padding:13px 17px;border-radius:10px;z-index:4}@media(max-width:760px){header{height:auto}.header-inner{width:100%;padding:14px;flex-wrap:wrap;gap:10px;box-sizing:border-box}.brand+input{order:3;flex-basis:100%;max-width:none}.actions{margin-left:auto}.actions button{font-size:0}.actions button::first-letter{font-size:18px}.stats{grid-template-columns:1fr}.scan,.form-row{grid-template-columns:1fr}.records nav>span{display:none}main{margin-top:18px}}
.build-tag{color:#9aa5b8;font-size:11px;font-weight:600;letter-spacing:.02em}
+3 -3
View File
@@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path";
import { backup, DatabaseSync } from "node:sqlite"; import { backup, DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
const APP_VERSION="3.5.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data"; const APP_VERSION="3.5.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data",GIT_SHA=process.env.GIT_SHA||"dev",BUILD_SHORT_SHA=GIT_SHA==="dev"?"dev":GIT_SHA.slice(0,7),BUILD_DATE=process.env.BUILD_DATE||"";
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public"); const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map(); const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
@@ -123,7 +123,7 @@ async function authApi(req,res,url,session){
async function adminApi(req,res,url,session){ async function adminApi(req,res,url,session){
const user=session.user; const user=session.user;
if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",frequency:setting("backup_frequency")==="weekly"?"weekly":"daily",weekday:Number(setting("backup_weekday"))||0,hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null,nextScheduledBackup:nextAutomaticBackup()}})} if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,buildSha:BUILD_SHORT_SHA,buildDate:BUILD_DATE,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",frequency:setting("backup_frequency")==="weekly"?"weekly":"daily",weekday:Number(setting("backup_weekday"))||0,hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null,nextScheduledBackup:nextAutomaticBackup()}})}
if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),frequency=String(v.frequency||"daily"),weekday=Number(v.weekday),hour=Number(v.hour),retention=Number(v.retention);if(!new Set(["daily","weekly"]).has(frequency)||!Number.isInteger(weekday)||weekday<0||weekday>6||!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Choose a valid frequency, weekday, backup hour, and retention period.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_frequency",frequency);saveSetting("backup_weekday",weekday);saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${frequency}${frequency==="weekly"?` day ${weekday}`:""}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true,nextScheduledBackup:nextAutomaticBackup()})} if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),frequency=String(v.frequency||"daily"),weekday=Number(v.weekday),hour=Number(v.hour),retention=Number(v.retention);if(!new Set(["daily","weekly"]).has(frequency)||!Number.isInteger(weekday)||weekday<0||weekday>6||!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Choose a valid frequency, weekday, backup hour, and retention period.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_frequency",frequency);saveSetting("backup_weekday",weekday);saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${frequency}${frequency==="weekly"?` day ${weekday}`:""}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true,nextScheduledBackup:nextAutomaticBackup()})}
if(url.pathname==="/api/admin/warranties"&&req.method==="GET")return json(res,200,allWarranties()); if(url.pathname==="/api/admin/warranties"&&req.method==="GET")return json(res,200,allWarranties());
if(url.pathname==="/api/admin/warranties"&&req.method==="POST"){const v=await body(req),name=cleanWarrantyName(v.name),durationValue=Number(v.durationValue),durationUnit=String(v.durationUnit||"days");if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");const id=crypto.randomUUID();db.prepare("INSERT INTO warranty_presets (id,name,duration_value,duration_unit) VALUES (?,?,?,?)").run(id,name,durationValue,durationUnit);audit(req,user,"warranty_created",name,`${durationValue} ${durationUnit}`);return json(res,201,{id,name,durationValue,durationUnit,active:true,isDefault:false,usageCount:0})} if(url.pathname==="/api/admin/warranties"&&req.method==="POST"){const v=await body(req),name=cleanWarrantyName(v.name),durationValue=Number(v.durationValue),durationUnit=String(v.durationUnit||"days");if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");const id=crypto.randomUUID();db.prepare("INSERT INTO warranty_presets (id,name,duration_value,duration_unit) VALUES (?,?,?,?)").run(id,name,durationValue,durationUnit);audit(req,user,"warranty_created",name,`${durationValue} ${durationUnit}`);return json(res,201,{id,name,durationValue,durationUnit,active:true,isDefault:false,usageCount:0})}
@@ -150,7 +150,7 @@ async function adminApi(req,res,url,session){
} }
async function api(req,res,url){ async function api(req,res,url){
requireOrigin(req);const session=authorize(req,url);if(url.pathname==="/api/health")return json(res,200,{ok:true}); requireOrigin(req);const session=authorize(req,url);if(url.pathname==="/api/health")return json(res,200,{ok:true,appVersion:APP_VERSION,buildSha:BUILD_SHORT_SHA,buildDate:BUILD_DATE});
if(url.pathname.startsWith("/api/auth/")){const handled=await authApi(req,res,url,session);if(handled!==false)return handled} if(url.pathname.startsWith("/api/auth/")){const handled=await authApi(req,res,url,session);if(handled!==false)return handled}
if(url.pathname.startsWith("/api/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled} if(url.pathname.startsWith("/api/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled}
const user=session.user; const user=session.user;