From 2a9d95d3d9c3ea88eaab833215fc7a50281c7563 Mon Sep 17 00:00:00 2001 From: mfwadejr <125498560+mfwadejr@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:53 +0000 Subject: [PATCH] Show running build version in the UI so stale deploys are obvious MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · " 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. --- .github/workflows/publish-container.yml | 3 +++ Dockerfile | 5 +++++ public/app.js | 2 ++ public/index.html | 2 +- public/style.css | 1 + server.mjs | 6 +++--- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml index a1a0856..4debf7b 100644 --- a/.github/workflows/publish-container.yml +++ b/.github/workflows/publish-container.yml @@ -53,3 +53,6 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_SHA=${{ github.sha }} + BUILD_DATE=${{ github.event.repository.updated_at || github.run_id }} diff --git a/Dockerfile b/Dockerfile index 6d20d52..63312fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ 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.revision="$GIT_SHA" LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker" WORKDIR /app COPY package.json server.mjs ./ @@ -9,6 +12,8 @@ RUN apk add --no-cache su-exec \ && chmod +x /usr/local/bin/vboxstock-entrypoint \ && mkdir -p /data 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 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 diff --git a/public/app.js b/public/app.js index 5bf706f..10d589c 100644 --- a/public/app.js +++ b/public/app.js @@ -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}); 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(); +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(); diff --git a/public/index.html b/public/index.html index 638dbd2..111c5f1 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ vBoxStock -
vBoxStock
vBoxStock
+
vBoxStock
vBoxStock

Inventory Overview

Available products0Ready to sell
Sold this month0No sales recorded
Inventory value$0Based on purchase cost
diff --git a/public/style.css b/public/style.css index 4547d01..643945e 100644 --- a/public/style.css +++ b/public/style.css @@ -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}} +.build-tag{color:#9aa5b8;font-size:11px;font-weight:600;letter-spacing:.02em} \ No newline at end of file diff --git a/server.mjs b/server.mjs index eb2dc74..0ae2008 100644 --- a/server.mjs +++ b/server.mjs @@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path"; import { backup, DatabaseSync } from "node:sqlite"; 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 SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map(); 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){ 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/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})} @@ -150,7 +150,7 @@ async function adminApi(req,res,url,session){ } 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/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled} const user=session.user;