Add system updates page to admin section
Add a new API endpoint and UI section for checking system updates, including internationalized locale strings and necessary dependency updates. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 1e6de894-7c78-4557-b01a-a2c1c01f4155 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
"pino-http": "^10",
|
"pino-http": "^10",
|
||||||
"playwright-core": "^1.59.1",
|
"playwright-core": "^1.59.1",
|
||||||
"sanitize-html": "^2.17.3",
|
"sanitize-html": "^2.17.3",
|
||||||
|
"semver": "^7.6.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"esbuild-plugin-pino": "^2.3.3",
|
"esbuild-plugin-pino": "^2.3.3",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import rolesRouter from "./roles";
|
|||||||
import auditRouter from "./audit";
|
import auditRouter from "./audit";
|
||||||
import executiveMeetingsRouter from "./executive-meetings";
|
import executiveMeetingsRouter from "./executive-meetings";
|
||||||
import pushRouter from "./push";
|
import pushRouter from "./push";
|
||||||
|
import systemRouter from "./system";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -38,5 +39,6 @@ router.use(rolesRouter);
|
|||||||
router.use(auditRouter);
|
router.use(auditRouter);
|
||||||
router.use(executiveMeetingsRouter);
|
router.use(executiveMeetingsRouter);
|
||||||
router.use(pushRouter);
|
router.use(pushRouter);
|
||||||
|
router.use(systemRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Router, type IRouter } from "express";
|
||||||
|
import semver from "semver";
|
||||||
|
import { requireAdmin } from "../middlewares/auth";
|
||||||
|
import versionFile from "../../../../version.json" with { type: "json" };
|
||||||
|
import { GetSystemVersionResponse } from "@workspace/api-zod";
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
const CURRENT_VERSION: string = versionFile.version;
|
||||||
|
const CURRENT_BUILD: string = versionFile.build;
|
||||||
|
|
||||||
|
const LATEST_URL = process.env.LATEST_VERSION_URL ?? "";
|
||||||
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
type LatestPayload = {
|
||||||
|
version?: unknown;
|
||||||
|
build?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get("/system/version", requireAdmin, async (_req, res): Promise<void> => {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (!LATEST_URL) {
|
||||||
|
res.json(
|
||||||
|
GetSystemVersionResponse.parse({
|
||||||
|
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||||
|
latest: null,
|
||||||
|
status: "not-configured",
|
||||||
|
errorMessage: null,
|
||||||
|
checkedAt,
|
||||||
|
configured: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(LATEST_URL, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
res.json(
|
||||||
|
GetSystemVersionResponse.parse({
|
||||||
|
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||||
|
latest: null,
|
||||||
|
status: "error",
|
||||||
|
errorMessage: `HTTP ${response.status}`,
|
||||||
|
checkedAt,
|
||||||
|
configured: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as LatestPayload;
|
||||||
|
const rawVersion = typeof body.version === "string" ? body.version : "";
|
||||||
|
const rawBuild = typeof body.build === "string" ? body.build : "";
|
||||||
|
const cleaned = semver.valid(semver.coerce(rawVersion));
|
||||||
|
const localCleaned = semver.valid(semver.coerce(CURRENT_VERSION));
|
||||||
|
if (!cleaned || !localCleaned) {
|
||||||
|
res.json(
|
||||||
|
GetSystemVersionResponse.parse({
|
||||||
|
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||||
|
latest: { version: rawVersion, build: rawBuild },
|
||||||
|
status: "error",
|
||||||
|
errorMessage: "Invalid version format",
|
||||||
|
checkedAt,
|
||||||
|
configured: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// semver.gt: strictly greater → update available. Equal or older →
|
||||||
|
// up to date (we never tell the user to "downgrade" — the desktop
|
||||||
|
// build is the source of truth for that scenario).
|
||||||
|
const status = semver.gt(cleaned, localCleaned)
|
||||||
|
? "update-available"
|
||||||
|
: "up-to-date";
|
||||||
|
res.json(
|
||||||
|
GetSystemVersionResponse.parse({
|
||||||
|
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||||
|
latest: { version: rawVersion, build: rawBuild },
|
||||||
|
status,
|
||||||
|
errorMessage: null,
|
||||||
|
checkedAt,
|
||||||
|
configured: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.name === "AbortError"
|
||||||
|
? "Request timed out"
|
||||||
|
: err.message
|
||||||
|
: "Unknown error";
|
||||||
|
res.json(
|
||||||
|
GetSystemVersionResponse.parse({
|
||||||
|
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||||
|
latest: null,
|
||||||
|
status: "error",
|
||||||
|
errorMessage: message,
|
||||||
|
checkedAt,
|
||||||
|
configured: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -528,8 +528,27 @@
|
|||||||
"auditLog": "سجل التدقيق",
|
"auditLog": "سجل التدقيق",
|
||||||
"userManagement": "إدارة المستخدمين",
|
"userManagement": "إدارة المستخدمين",
|
||||||
"settings": "الإعدادات",
|
"settings": "الإعدادات",
|
||||||
|
"systemUpdates": "تحديثات النظام",
|
||||||
"menu": "القائمة"
|
"menu": "القائمة"
|
||||||
},
|
},
|
||||||
|
"systemUpdates": {
|
||||||
|
"title": "تحديثات النظام",
|
||||||
|
"subtitle": "تحقّق فقط من توفر إصدار أحدث — لا يقوم النظام بتثبيت أي تحديث.",
|
||||||
|
"currentVersion": "الإصدار الحالي",
|
||||||
|
"buildNumber": "رقم البناء",
|
||||||
|
"latestVersion": "أحدث إصدار",
|
||||||
|
"updateStatus": "حالة التحديث",
|
||||||
|
"statusUpToDate": "النظام محدّث",
|
||||||
|
"statusUpdateAvailable": "يتوفّر تحديث جديد",
|
||||||
|
"statusNotConfigured": "لم يتم تهيئة مصدر التحديثات",
|
||||||
|
"statusError": "تعذّر التحقق من التحديثات",
|
||||||
|
"notConfiguredHint": "اطلب من المسؤول ضبط متغيّر البيئة LATEST_VERSION_URL على رابط ملف latest.json الخارجي لتفعيل التحقق.",
|
||||||
|
"checkNow": "تحقّق الآن",
|
||||||
|
"loading": "جاري التحقق من الإصدار…",
|
||||||
|
"lastChecked": "آخر تحقق",
|
||||||
|
"toastErrorTitle": "تعذّر التحقق",
|
||||||
|
"toastErrorDesc": "لم نتمكن من الوصول إلى مصدر التحديثات. حاول مرة أخرى لاحقاً."
|
||||||
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"counts": {
|
"counts": {
|
||||||
"groups": "{{count}} مجموعة",
|
"groups": "{{count}} مجموعة",
|
||||||
|
|||||||
@@ -504,8 +504,27 @@
|
|||||||
"auditLog": "Audit log",
|
"auditLog": "Audit log",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"systemUpdates": "System Updates",
|
||||||
"menu": "Menu"
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
|
"systemUpdates": {
|
||||||
|
"title": "System Updates",
|
||||||
|
"subtitle": "Check whether a newer version is available — this page never installs an update.",
|
||||||
|
"currentVersion": "Current Version",
|
||||||
|
"buildNumber": "Build Number",
|
||||||
|
"latestVersion": "Latest Version",
|
||||||
|
"updateStatus": "Update Status",
|
||||||
|
"statusUpToDate": "System is up to date",
|
||||||
|
"statusUpdateAvailable": "New update available",
|
||||||
|
"statusNotConfigured": "Update source not configured",
|
||||||
|
"statusError": "Unable to check for updates",
|
||||||
|
"notConfiguredHint": "Ask the administrator to set the LATEST_VERSION_URL environment variable to the external latest.json URL to enable checking.",
|
||||||
|
"checkNow": "Check Now",
|
||||||
|
"loading": "Checking version…",
|
||||||
|
"lastChecked": "Last checked",
|
||||||
|
"toastErrorTitle": "Check failed",
|
||||||
|
"toastErrorDesc": "Could not reach the update source. Please try again later."
|
||||||
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"counts": {
|
"counts": {
|
||||||
"groups": "{{count}} groups",
|
"groups": "{{count}} groups",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
useUpdateAppSettings,
|
useUpdateAppSettings,
|
||||||
useGetAdminStats,
|
useGetAdminStats,
|
||||||
getGetAdminStatsQueryKey,
|
getGetAdminStatsQueryKey,
|
||||||
|
useGetSystemVersion,
|
||||||
|
getGetSystemVersionQueryKey,
|
||||||
useGetAdminAppOpensByApp,
|
useGetAdminAppOpensByApp,
|
||||||
getGetAdminAppOpensByAppQueryKey,
|
getGetAdminAppOpensByAppQueryKey,
|
||||||
getAdminAppOpensByApp,
|
getAdminAppOpensByApp,
|
||||||
@@ -124,6 +126,7 @@ import type {
|
|||||||
ServiceDependentOrdersPage,
|
ServiceDependentOrdersPage,
|
||||||
UserDependentNotesPage,
|
UserDependentNotesPage,
|
||||||
UserDependentOrdersPage,
|
UserDependentOrdersPage,
|
||||||
|
SystemVersionResponse,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
@@ -131,7 +134,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||||
import { isBuiltinAppSlug } from "@workspace/db/built-in-apps";
|
import { isBuiltinAppSlug } from "@workspace/db/built-in-apps";
|
||||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, type LucideIcon } from "lucide-react";
|
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, Package, RefreshCw, CheckCircle2, AlertCircle, type LucideIcon } from "lucide-react";
|
||||||
import * as LucideIcons from "lucide-react";
|
import * as LucideIcons from "lucide-react";
|
||||||
|
|
||||||
function resolveAppIcon(name: string): LucideIcon | null {
|
function resolveAppIcon(name: string): LucideIcon | null {
|
||||||
@@ -184,7 +187,7 @@ async function fetchAdminApps(): Promise<App[]> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings";
|
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings" | "system-updates";
|
||||||
|
|
||||||
function LeaderboardAvatar({
|
function LeaderboardAvatar({
|
||||||
src,
|
src,
|
||||||
@@ -1081,6 +1084,11 @@ export default function AdminPage() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
|
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
|
||||||
|
// #575: Read-only "System Updates" page — checks the running build
|
||||||
|
// against a remote latest.json (configured via LATEST_VERSION_URL on
|
||||||
|
// the server) and shows whether a newer version is available. It
|
||||||
|
// never performs an update — pure status surface.
|
||||||
|
{ key: "system-updates", label: t("admin.nav.systemUpdates"), Icon: Package },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sync section to URL hash so deep links work
|
// Sync section to URL hash so deep links work
|
||||||
@@ -1090,7 +1098,7 @@ export default function AdminPage() {
|
|||||||
const sectionMatch = hash.match(/section=([a-z-]+)/);
|
const sectionMatch = hash.match(/section=([a-z-]+)/);
|
||||||
if (sectionMatch) {
|
if (sectionMatch) {
|
||||||
const s = sectionMatch[1] as AdminSection;
|
const s = sectionMatch[1] as AdminSection;
|
||||||
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) {
|
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings", "system-updates"].includes(s)) {
|
||||||
setSection(s);
|
setSection(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2211,6 +2219,8 @@ export default function AdminPage() {
|
|||||||
<SiteSettingsPanel />
|
<SiteSettingsPanel />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === "system-updates" && <SystemUpdatesPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2331,6 +2341,188 @@ export default function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #575: System Updates panel — read-only version checker. Reads the
|
||||||
|
// local build (server returns version.json embedded at build time)
|
||||||
|
// and, if the server has LATEST_VERSION_URL configured, compares it
|
||||||
|
// against the remote latest.json via semver on the server side. We
|
||||||
|
// never perform an update from this screen — pure status surface.
|
||||||
|
// The bell icon and other admin features stay untouched.
|
||||||
|
function SystemUpdatesPanel() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = getGetSystemVersionQueryKey();
|
||||||
|
const { data, isLoading, isFetching, refetch } = useGetSystemVersion({
|
||||||
|
query: { queryKey, refetchOnWindowFocus: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCheckNow = async () => {
|
||||||
|
// Invalidate first so React Query treats the next fetch as fresh
|
||||||
|
// (no stale cache reuse) — this is the user explicitly asking for
|
||||||
|
// a re-check, so we want a real network hit.
|
||||||
|
await queryClient.invalidateQueries({ queryKey });
|
||||||
|
const result = await refetch();
|
||||||
|
if (result.error) {
|
||||||
|
toast({
|
||||||
|
title: t("admin.systemUpdates.toastErrorTitle"),
|
||||||
|
description: t("admin.systemUpdates.toastErrorDesc"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatChecked = (iso: string | null | undefined) => {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return new Intl.DateTimeFormat(i18n.language === "ar" ? "ar" : "en", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatusBadge = (resp: SystemVersionResponse | undefined) => {
|
||||||
|
if (!resp) return null;
|
||||||
|
const map: Record<
|
||||||
|
SystemVersionResponse["status"],
|
||||||
|
{ label: string; cls: string; Icon: typeof CheckCircle2 }
|
||||||
|
> = {
|
||||||
|
"up-to-date": {
|
||||||
|
label: t("admin.systemUpdates.statusUpToDate"),
|
||||||
|
cls: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
|
Icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
label: t("admin.systemUpdates.statusUpdateAvailable"),
|
||||||
|
cls: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
|
Icon: AlertCircle,
|
||||||
|
},
|
||||||
|
"not-configured": {
|
||||||
|
label: t("admin.systemUpdates.statusNotConfigured"),
|
||||||
|
cls: "bg-slate-50 text-slate-700 border-slate-200",
|
||||||
|
Icon: HelpCircle,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: t("admin.systemUpdates.statusError"),
|
||||||
|
cls: "bg-rose-50 text-rose-700 border-rose-200",
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const cfg = map[resp.status];
|
||||||
|
const Icon = cfg.Icon;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border",
|
||||||
|
cfg.cls,
|
||||||
|
)}
|
||||||
|
data-testid="system-updates-status-badge"
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="system-updates-panel" dir={i18n.language === "ar" ? "rtl" : "ltr"}>
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{t("admin.systemUpdates.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t("admin.systemUpdates.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCheckNow}
|
||||||
|
disabled={isFetching}
|
||||||
|
data-testid="system-updates-check-now"
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader2 size={14} className="animate-spin me-2" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={14} className="me-2" />
|
||||||
|
)}
|
||||||
|
{t("admin.systemUpdates.checkNow")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-6">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
{t("admin.systemUpdates.loading")}
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("admin.systemUpdates.currentVersion")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xl font-semibold mt-1 tabular-nums"
|
||||||
|
data-testid="system-updates-current-version"
|
||||||
|
>
|
||||||
|
{data.current.version}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("admin.systemUpdates.buildNumber")}:{" "}
|
||||||
|
<span className="tabular-nums" data-testid="system-updates-current-build">
|
||||||
|
{data.current.build}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("admin.systemUpdates.latestVersion")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xl font-semibold mt-1 tabular-nums"
|
||||||
|
data-testid="system-updates-latest-version"
|
||||||
|
>
|
||||||
|
{data.latest?.version ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("admin.systemUpdates.buildNumber")}:{" "}
|
||||||
|
<span className="tabular-nums" data-testid="system-updates-latest-build">
|
||||||
|
{data.latest?.build ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 rounded-xl border border-slate-200 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
|
{t("admin.systemUpdates.updateStatus")}
|
||||||
|
</div>
|
||||||
|
{renderStatusBadge(data)}
|
||||||
|
{data.status === "error" && data.errorMessage && (
|
||||||
|
<div className="text-xs text-rose-700 mt-2" data-testid="system-updates-error-message">
|
||||||
|
{data.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.status === "not-configured" && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
{t("admin.systemUpdates.notConfiguredHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.checkedAt && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-3">
|
||||||
|
{t("admin.systemUpdates.lastChecked")}: {formatChecked(data.checkedAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SiteSettingsPanel() {
|
function SiteSettingsPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|||||||
@@ -9,6 +9,32 @@ export interface HealthStatus {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemVersionInfo {
|
||||||
|
version: string;
|
||||||
|
build: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemVersionResponseStatus =
|
||||||
|
(typeof SystemVersionResponseStatus)[keyof typeof SystemVersionResponseStatus];
|
||||||
|
|
||||||
|
export const SystemVersionResponseStatus = {
|
||||||
|
"up-to-date": "up-to-date",
|
||||||
|
"update-available": "update-available",
|
||||||
|
"not-configured": "not-configured",
|
||||||
|
error: "error",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface SystemVersionResponse {
|
||||||
|
current: SystemVersionInfo;
|
||||||
|
latest: SystemVersionInfo | null;
|
||||||
|
status: SystemVersionResponseStatus;
|
||||||
|
/** @nullable */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
checkedAt: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ import type {
|
|||||||
ServiceOrder,
|
ServiceOrder,
|
||||||
SubscribePushBody,
|
SubscribePushBody,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
|
SystemVersionResponse,
|
||||||
UnsubscribePushBody,
|
UnsubscribePushBody,
|
||||||
UpdateAppBody,
|
UpdateAppBody,
|
||||||
UpdateAppSettingsBody,
|
UpdateAppSettingsBody,
|
||||||
@@ -217,6 +218,86 @@ export function useHealthCheck<
|
|||||||
return { ...query, queryKey: queryOptions.queryKey };
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the local build version (read from version.json embedded at
|
||||||
|
build time) and, if `LATEST_VERSION_URL` is configured on the
|
||||||
|
server, the latest published version fetched from that URL. Admin
|
||||||
|
only. Never performs an update — pure read.
|
||||||
|
|
||||||
|
* @summary Get current and latest system version
|
||||||
|
*/
|
||||||
|
export const getGetSystemVersionUrl = () => {
|
||||||
|
return `/api/system/version`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemVersion = async (
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<SystemVersionResponse> => {
|
||||||
|
return customFetch<SystemVersionResponse>(getGetSystemVersionUrl(), {
|
||||||
|
...options,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetSystemVersionQueryKey = () => {
|
||||||
|
return [`/api/system/version`] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGetSystemVersionQueryOptions = <
|
||||||
|
TData = Awaited<ReturnType<typeof getSystemVersion>>,
|
||||||
|
TError = ErrorType<unknown>,
|
||||||
|
>(options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getSystemVersion>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}) => {
|
||||||
|
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetSystemVersionQueryKey();
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<ReturnType<typeof getSystemVersion>>
|
||||||
|
> = ({ signal }) => getSystemVersion({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getSystemVersion>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: QueryKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSystemVersionQueryResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getSystemVersion>>
|
||||||
|
>;
|
||||||
|
export type GetSystemVersionQueryError = ErrorType<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current and latest system version
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetSystemVersion<
|
||||||
|
TData = Awaited<ReturnType<typeof getSystemVersion>>,
|
||||||
|
TError = ErrorType<unknown>,
|
||||||
|
>(options?: {
|
||||||
|
query?: UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof getSystemVersion>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
const queryOptions = getGetSystemVersionQueryOptions(options);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Register a new user
|
* @summary Register a new user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ tags:
|
|||||||
description: Object storage upload and serving endpoints
|
description: Object storage upload and serving endpoints
|
||||||
- name: audit
|
- name: audit
|
||||||
description: Admin audit log
|
description: Admin audit log
|
||||||
|
- name: system
|
||||||
|
description: System metadata (version, update availability)
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/healthz:
|
/healthz:
|
||||||
@@ -45,6 +47,24 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/HealthStatus"
|
$ref: "#/components/schemas/HealthStatus"
|
||||||
|
|
||||||
|
/system/version:
|
||||||
|
get:
|
||||||
|
operationId: getSystemVersion
|
||||||
|
tags: [system]
|
||||||
|
summary: Get current and latest system version
|
||||||
|
description: |
|
||||||
|
Returns the local build version (read from version.json embedded at
|
||||||
|
build time) and, if `LATEST_VERSION_URL` is configured on the
|
||||||
|
server, the latest published version fetched from that URL. Admin
|
||||||
|
only. Never performs an update — pure read.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: System version info
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SystemVersionResponse"
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
/auth/register:
|
/auth/register:
|
||||||
post:
|
post:
|
||||||
@@ -2927,6 +2947,44 @@ components:
|
|||||||
required:
|
required:
|
||||||
- status
|
- status
|
||||||
|
|
||||||
|
SystemVersionInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
build:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- version
|
||||||
|
- build
|
||||||
|
|
||||||
|
SystemVersionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
current:
|
||||||
|
$ref: "#/components/schemas/SystemVersionInfo"
|
||||||
|
latest:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/SystemVersionInfo"
|
||||||
|
- type: "null"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [up-to-date, update-available, not-configured, error]
|
||||||
|
errorMessage:
|
||||||
|
type: ["string", "null"]
|
||||||
|
checkedAt:
|
||||||
|
type: ["string", "null"]
|
||||||
|
format: date-time
|
||||||
|
configured:
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- current
|
||||||
|
- latest
|
||||||
|
- status
|
||||||
|
- errorMessage
|
||||||
|
- checkedAt
|
||||||
|
- configured
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -14,6 +14,37 @@ export const HealthCheckResponse = zod.object({
|
|||||||
status: zod.string(),
|
status: zod.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the local build version (read from version.json embedded at
|
||||||
|
build time) and, if `LATEST_VERSION_URL` is configured on the
|
||||||
|
server, the latest published version fetched from that URL. Admin
|
||||||
|
only. Never performs an update — pure read.
|
||||||
|
|
||||||
|
* @summary Get current and latest system version
|
||||||
|
*/
|
||||||
|
export const GetSystemVersionResponse = zod.object({
|
||||||
|
current: zod.object({
|
||||||
|
version: zod.string(),
|
||||||
|
build: zod.string(),
|
||||||
|
}),
|
||||||
|
latest: zod.union([
|
||||||
|
zod.object({
|
||||||
|
version: zod.string(),
|
||||||
|
build: zod.string(),
|
||||||
|
}),
|
||||||
|
zod.null(),
|
||||||
|
]),
|
||||||
|
status: zod.enum([
|
||||||
|
"up-to-date",
|
||||||
|
"update-available",
|
||||||
|
"not-configured",
|
||||||
|
"error",
|
||||||
|
]),
|
||||||
|
errorMessage: zod.string().nullable(),
|
||||||
|
checkedAt: zod.coerce.date().nullable(),
|
||||||
|
configured: zod.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Register a new user
|
* @summary Register a new user
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+18
@@ -135,6 +135,9 @@ importers:
|
|||||||
sanitize-html:
|
sanitize-html:
|
||||||
specifier: ^2.17.3
|
specifier: ^2.17.3
|
||||||
version: 2.17.3
|
version: 2.17.3
|
||||||
|
semver:
|
||||||
|
specifier: ^7.6.3
|
||||||
|
version: 7.8.0
|
||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.3
|
specifier: ^4.8.3
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
@@ -175,6 +178,9 @@ importers:
|
|||||||
'@types/sanitize-html':
|
'@types/sanitize-html':
|
||||||
specifier: ^2.16.1
|
specifier: ^2.16.1
|
||||||
version: 2.16.1
|
version: 2.16.1
|
||||||
|
'@types/semver':
|
||||||
|
specifier: ^7.5.8
|
||||||
|
version: 7.7.1
|
||||||
'@types/web-push':
|
'@types/web-push':
|
||||||
specifier: ^3.6.4
|
specifier: ^3.6.4
|
||||||
version: 3.6.4
|
version: 3.6.4
|
||||||
@@ -2996,6 +3002,9 @@ packages:
|
|||||||
'@types/sanitize-html@2.16.1':
|
'@types/sanitize-html@2.16.1':
|
||||||
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
|
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
|
||||||
|
|
||||||
|
'@types/semver@7.7.1':
|
||||||
|
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||||
|
|
||||||
@@ -4668,6 +4677,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.8.0:
|
||||||
|
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
send@1.2.1:
|
send@1.2.1:
|
||||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -7540,6 +7554,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
htmlparser2: 10.1.0
|
htmlparser2: 10.1.0
|
||||||
|
|
||||||
|
'@types/semver@7.7.1': {}
|
||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.3.5
|
'@types/node': 25.3.5
|
||||||
@@ -9221,6 +9237,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
semver@7.8.0: {}
|
||||||
|
|
||||||
send@1.2.1:
|
send@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.0-dev",
|
||||||
|
"build": "20260517"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user