diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 580fb866..2fb22bfa 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -47,6 +47,7 @@ NODE_FILES = ( "tests/test_reboot_health_refresh.cjs", "tests/test_host_update_controls.cjs", "tests/test_reboot_recovery.cjs", + "tests/test_proxmenux_version_row.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/AppImage/components/host-update-status.tsx b/AppImage/components/host-update-status.tsx index 6b92db4a..d74f6748 100644 --- a/AppImage/components/host-update-status.tsx +++ b/AppImage/components/host-update-status.tsx @@ -5,6 +5,7 @@ import * as Confirm from "@radix-ui/react-alert-dialog" import { Button } from "./ui/button" import { getAuthToken } from "@/lib/api-config" import { useT } from "@/lib/i18n/provider" +import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react" function headers() { const token = getAuthToken() @@ -145,14 +146,32 @@ export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) { return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) } }, [getApiUrl]) const state = value?.state ?? "unknown" - return
-

{t("healthStatus.versions.title")}

- {t(`healthStatus.versions.${state}`)}
-
+ const versions =
{t("healthStatus.versions.installed")}
{value?.installed ?? t("healthStatus.versions.unverified")}
{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}
{value?.available ?? t("healthStatus.versions.unverified")}
+ const metadata = <> {value &&

{t(`healthStatus.versions.${value.channel}`)}

} {value?.checked_at &&

{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}

} + + if (state !== "available") return
+ + {state === "current" ? +
+ {state === "unknown" && versions} + {metadata} +
+
+ return
+

{t("healthStatus.versions.title")}

+
+ {versions} + {metadata}
} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index d61972ef..ac9d283b 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5326,6 +5326,7 @@ "lastKnown": "Last known available version", "available": "Update available", "current": "ProxMenux is up to date", + "currentVersion": "Version {version} is up to date", "unknown": "Could not verify the available version", "unverified": "Unverified", "stable": "Stable channel", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 290fdbd5..2d82db87 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5198,6 +5198,7 @@ "lastKnown": "Posledná známa dostupná verzia", "available": "Dostupná aktualizácia", "current": "ProxMenux je aktuálny", + "currentVersion": "Verzia {version} je aktuálna", "unknown": "Dostupnú verziu sa nepodarilo overiť", "unverified": "Neoverené", "stable": "Stabilný kanál", diff --git a/tests/test_proxmenux_version_row.cjs b/tests/test_proxmenux_version_row.cjs new file mode 100644 index 00000000..dcedb123 --- /dev/null +++ b/tests/test_proxmenux_version_row.cjs @@ -0,0 +1,43 @@ +// Render the shipped component with an inert snapshot; no network or power. +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm') +const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript')) +const react=require(path.join(app,'node_modules/react')) +const {renderToStaticMarkup}=require(path.join(app,'node_modules/react-dom/server')) +const code=ts.transpileModule(fs.readFileSync(path.join(app,'components/host-update-status.tsx'),'utf8'),{ + compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,esModuleInterop:true}}).outputText +for(const locale of ['en','sk']){ + const catalog=JSON.parse(fs.readFileSync(path.join(app,'messages',locale,'common.json'),'utf8')) + const t=(key,params={})=>{const text=key.split('.').reduce((x,k)=>x?.[k],catalog);assert.equal(typeof text,'string',key);return text.replace(/\{(\w+)\}/g,(_,k)=>params[k]??'{'+k+'}')} + const versions=catalog.healthStatus.versions + let value, effects=0 + const exports={} + vm.runInNewContext(code,{exports,require:name=>{ + if(name==='react')return {...react,useState:()=>[value,()=>{}],useEffect:()=>{},useRef:()=>({current:null})} + if(name==='react/jsx-runtime')return require(path.join(app,'node_modules/react/jsx-runtime')) + if(name==='@radix-ui/react-alert-dialog'||name==='./ui/button')return {} + if(name==='@/lib/i18n/provider')return {useT:()=>t} + if(name==='@/lib/api-config')return {getAuthToken:()=>null} + if(name==='lucide-react')return require(path.join(app,'node_modules/lucide-react')) + throw Error('Unexpected boundary '+name) + },fetch:()=>{effects++;throw Error('Forbidden request')},console}) + const snapshot={installed:'1.2.7',available:'1.2.7',state:'current',channel:'stable',checked_at:'2026-10-28T08:00:00Z'} + const render=data=>{value=data;return renderToStaticMarkup(react.createElement(exports.ProxMenuxVersionCard,{getApiUrl:p=>p}))} + const current=render(snapshot) + assert.ok(current.includes(''),'up-to-date version has no expanded update card') + assert.ok(current.includes('1.2.7')&¤t.includes('text-green-500')) + assert.ok(current.includes(versions.stable)&¤t.includes(versions.checked.split('{')[0])) + const available=render({...snapshot,state:'available',available:'1.2.8'}) + assert.ok(available.includes('