feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令

This commit is contained in:
Lemon-miaow committed 2026-09-25 21:50:49 +08:00
1 parent 24373823cc
commit 7f160e2feb
28 files changed
+994 -10

No files matched your search

+25
View File
@@ -1011,6 +1011,31 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
});
return true;
}
case "GET updates/report": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
// Last night's timer run: one update waiting, one feed unreachable, one
// install unreadable, so the card shows every state it has.
sendJSON(ctx.res, 200, {
report: {
checked_at: new Date(Date.now() - 7 * 3600 * 1000).toISOString(),
felis: "v0.4.0",
components: [
{ name: "felis-api", current: "v0.4.0", latest: "v0.5.1", state: "available", selector: "panel" },
{ name: "velocity", current: "3.4.0-SNAPSHOT+b528", state: "current", selector: "velocity" },
{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" },
{ name: "cloudflared", current: "2026.6.1", latest: "2026.9.0", state: "available", selector: "cloudflared" },
{ name: "jre", current: "21.0.8+9", state: "unknown", selector: "jre", error: "adoptium: GET https://api.adoptium.net/v3/assets/latest/21/hotspot: context deadline exceeded" },
{ name: "postgresql", current: "13.22", state: "current", selector: "postgres", note: "PostgreSQL 13 reached its end of life on 2025-11-13; plan a major upgrade (docs/operations.md §4)" },
],
},
stale: false,
max_age_seconds: 26 * 3600,
});
return true;
}
case "GET updates/window":
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
+25 -1
View File
@@ -90,7 +90,7 @@
"reviewed_at": "Reviewed At",
"reject_reason": "Rejection Reason",
"updates_title": "Maintenance & Backups",
"updates_subtitle": "Check that the control-plane database backup is fresh, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.",
"updates_subtitle": "Check that the control-plane database backup is fresh, see which components have a newer release, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.",
"updates_window_advisory": "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.",
"updates_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.",
"updates_start_label": "Start Time",
@@ -132,6 +132,30 @@
"dbbackup_fix_hint": "If the host failed now, accounts, server ownership and the archive index could not be recovered. Take a backup on the host now, then read the timer's log to find out why it did not run:",
"dbbackup_copy": "Copy command",
"dbbackup_offsite_note": "Backups are kept on this host only and are lost with its disk. Copy the backup directory to another machine regularly; restore and disaster-recovery steps are in the troubleshooting guide, §16.",
"versions_title": "Component versions",
"versions_subtitle": "felis-update-check.timer compares what this host runs with the newest upstream releases every day. Felis applies nothing on its own.",
"versions_status_available_one": "{{count}} update available",
"versions_status_available_other": "{{count}} updates available",
"versions_status_current": "Up to date",
"versions_status_stale": "Check overdue",
"versions_status_never": "Never checked",
"versions_refresh": "Refresh",
"versions_loading": "Reading the latest version check…",
"versions_checked": "Checked {{when}} by felis {{felis}}",
"versions_none": "The check tracked no components.",
"versions_col_component": "Component",
"versions_col_installed": "Installed",
"versions_col_latest": "Newer release",
"versions_col_status": "Status",
"versions_state_available": "Update available",
"versions_state_current": "Up to date",
"versions_state_unknown": "Feed unreachable",
"versions_state_unreadable": "Version unreadable",
"versions_state_pinned": "Pinned",
"versions_apply_hint": "To apply, run this on the host, inside the maintenance window below if you set one. It prints the exact command for each component and warns when run outside the window:",
"versions_never_title": "No version check has been recorded yet",
"versions_stale_title": "The newest version check is more than {{hours}} hours old",
"versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:",
"build_import_submission_label": "Import parameters from submission",
"build_import_submission_placeholder": "Select a user submission...",
"build_import_submission_none": "No matching submissions found or not loaded",
+24 -1
View File
@@ -90,7 +90,7 @@
"reviewed_at": "审核时间",
"reject_reason": "驳回理由",
"updates_title": "维护与备份",
"updates_subtitle": "查看控制面数据库备份是否新鲜,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。",
"updates_subtitle": "查看控制面数据库备份是否新鲜、哪些组件有新版本,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。",
"updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。",
"updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。",
"updates_start_label": "开始时间",
@@ -132,6 +132,29 @@
"dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:",
"dbbackup_copy": "复制命令",
"dbbackup_offsite_note": "备份只保存在这台主机上,硬盘损坏或主机丢失时会一起丢失。请定期把备份目录复制到另一台机器;恢复与灾备步骤见故障排查文档 §16。",
"versions_title": "组件版本",
"versions_subtitle": "felis-update-check.timer 每天把本机运行的版本与上游最新发行版比对。Felis 不会自行应用任何更新。",
"versions_status_available_other": "{{count}} 个可用更新",
"versions_status_current": "已是最新",
"versions_status_stale": "检查已逾期",
"versions_status_never": "从未检查",
"versions_refresh": "刷新",
"versions_loading": "正在读取最近一次版本检查…",
"versions_checked": "{{when}}由 felis {{felis}} 检查",
"versions_none": "本次检查没有跟踪任何组件。",
"versions_col_component": "组件",
"versions_col_installed": "已安装",
"versions_col_latest": "更新版本",
"versions_col_status": "状态",
"versions_state_available": "有可用更新",
"versions_state_current": "已是最新",
"versions_state_unknown": "无法访问发行源",
"versions_state_unreadable": "读不到版本",
"versions_state_pinned": "已固定",
"versions_apply_hint": "要应用更新,请在主机上运行下面的命令;如果设置了维护窗口,请在窗口内运行。它会列出每个组件的确切命令,并在窗口外运行时发出警告:",
"versions_never_title": "还没有记录过版本检查",
"versions_stale_title": "最近一次版本检查已超过 {{hours}} 小时",
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
"build_import_submission_label": "从已有的审核提交导入参数",
"build_import_submission_placeholder": "选择一个用户提交...",
"build_import_submission_none": "无匹配的提交或暂未加载",
+13
View File
@@ -699,6 +699,19 @@ describe("image whitelist and builds wire shapes", () => {
});
});
describe("component version check", () => {
it("getUpdateReport GETs /updates/report and keeps a null report", async () => {
const report = { report: null, stale: true, max_age_seconds: 93600 };
const fetchSpy = fakeFetch(report);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.getUpdateReport();
expect(res).toEqual({ report: null, stale: true, max_age_seconds: 93600 });
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/updates/report");
expect((opts as RequestInit).method).toBe("GET");
});
});
describe("backup now and server jobs wire shapes", () => {
it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
+2
View File
@@ -33,6 +33,7 @@ import type {
SubmissionPage,
UpdateWindow,
DBBackupStatus,
UpdateReport,
} from "./types";
import { loadConfig } from "./config";
import i18next from "i18next";
@@ -755,6 +756,7 @@ export const api = rejectingSync({
// Freshness of the host's control-plane database backup (felis-db-backup.timer).
getDBBackup: () => request<DBBackupStatus>("GET", "/platform/db-backup"),
getUpdateReport: () => request<UpdateReport>("GET", "/updates/report"),
// ---- User admin (admin-tier, spec §7 user admin) ----
+76
View File
@@ -1027,6 +1027,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/updates/report": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* The newest recorded version check of every tracked component (admin).
* @description What `felis update --record` last stored in platform_settings; the installer's felis-update-check.timer runs it daily on the host, where the installed versions are readable. report is null before the first check; stale is true then, and whenever the check is older than max_age_seconds. Read-only: Felis applies no update on its own.
*/
get: operations["getUpdateReport"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/fleet": {
parameters: {
query?: never;
@@ -2193,6 +2213,40 @@ export interface components {
*/
max_age_seconds: number;
};
/** @description The newest version check the host recorded (internal/api/handlers_updates.go updateReportView; the record is internal/updates StatusReport, written by `felis update --record`, which felis-update-check.timer runs daily). */
UpdateReport: {
/** @description Null until the first check has been recorded (internal/updates StatusReport). */
report: {
/** Format: date-time */
checked_at: string;
/** @description Version of the felis binary that ran the check. */
felis: string;
components: components["schemas"]["UpdateComponent"][];
} | null;
/** @description True when there is no record or it is older than max_age_seconds. */
stale: boolean;
/**
* Format: int64
* @description The freshness limit (26h).
*/
max_age_seconds: number;
};
/** @description One component's line. available has a newer stable release (latest); unknown means the release feed could not be read and unreadable that the installed version could not, both with error; pinned never changes by policy. */
UpdateComponent: {
name: string;
/** @description Installed version; omitted when unreadable. */
current?: string;
/** @description The newer stable release; present only when state is available. */
latest?: string;
/** @enum {string} */
state: "current" | "available" | "unknown" | "unreadable" | "pinned";
/** @description The `felis update --<selector>` flag that prints how to apply it; omitted when none. */
selector?: string;
/** @description What the release lookup learned beyond the version; omitted when none. */
note?: string;
/** @description Why a version is missing; omitted otherwise. */
error?: string;
};
/** @description Display projection of one bound passkey (internal/api/handlers_passkey.go passkeyCredentialView). Carries no secret — the public key is never returned. */
PasskeyCredential: {
/** @description Opaque passkey row id (used to unbind it). */
@@ -5156,6 +5210,28 @@ export interface operations {
403: components["responses"]["Forbidden"];
};
};
getUpdateReport: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The newest recorded check and whether it is stale. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpdateReport"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
};
};
fleet: {
parameters: {
query?: never;
+2
View File
@@ -50,4 +50,6 @@ export type WireParity = [
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
Holds<Parity<T.UpdateReport, S["UpdateReport"]>>,
Holds<Parity<T.UpdateComponent, S["UpdateComponent"]>>,
];
+32
View File
@@ -422,6 +422,38 @@ export interface DBBackupStatus {
max_age_seconds: number;
}
// ---- Component version check (internal/api/handlers_updates.go updateReportView) ----
export type UpdateComponentState = "current" | "available" | "unknown" | "unreadable" | "pinned";
export interface UpdateComponent {
name: string;
/** Installed version; absent when unreadable. */
current?: string;
/** The newer stable release; present only when state is available. */
latest?: string;
state: UpdateComponentState;
/** The `felis update --<selector>` flag that prints how to apply it. */
selector?: string;
note?: string;
/** Why a version is missing (unknown / unreadable). */
error?: string;
}
export interface UpdateStatusReport {
checked_at: string;
felis: string;
components: UpdateComponent[];
}
export interface UpdateReport {
/** Null until the host's felis-update-check.timer has recorded a check. */
report: UpdateStatusReport | null;
/** True when there is no record or it is older than max_age_seconds. */
stale: boolean;
max_age_seconds: number;
}
// ---- User admin types (internal/api/repo.go UserView, UserDetail, QuotaView, SessionView) ----
export interface UserView {
+1 -1
View File
@@ -24,7 +24,7 @@ const LABEL_KEY: Record<DBBackupLabel, string> = {
const FIX_COMMANDS = ["sudo felis db backup", "journalctl -u felis-db-backup -n 50 --no-pager"];
function CopyCommand({ command }: { command: string }) {
export function CopyCommand({ command }: { command: string }) {
const { t } = useTranslation("admin");
const [copied, setCopied] = useState(false);
async function copy() {
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import i18next from "i18next";
import { UpdateReportCard } from "./UpdateReportCard";
import type { UpdateReport } from "@/lib/types";
const calls = vi.hoisted(() => ({ getUpdateReport: vi.fn() }));
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const CHECKED = new Date(Date.now() - 7 * 3600 * 1000).toISOString();
const WITH_UPDATES: UpdateReport = {
report: {
checked_at: CHECKED,
felis: "v0.4.0",
components: [
{ name: "felis-api", current: "v0.4.0", latest: "v0.5.1", state: "available", selector: "panel" },
{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" },
{ name: "cloudflared", current: "2026.6.1", latest: "2026.9.0", state: "available", selector: "cloudflared" },
{ name: "jre", current: "21.0.8+9", state: "unknown", selector: "jre", error: "adoptium: context deadline exceeded" },
{ name: "postgresql", state: "unreadable", selector: "postgres", error: "psql: not found", note: "PostgreSQL 13 is past its end of life" },
],
},
stale: false,
max_age_seconds: 93600,
};
beforeEach(() => {
calls.getUpdateReport.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
return i18next.changeLanguage("en-US");
});
function rowText(name: string): string {
const row = screen.getByText(name).closest("li");
if (!row) throw new Error(`no row for ${name}`);
return row.textContent ?? "";
}
describe("UpdateReportCard", () => {
it("lists each component's state and hands over the host command for the ones with updates", async () => {
calls.getUpdateReport.mockResolvedValue(WITH_UPDATES);
render(<UpdateReportCard />);
expect(await screen.findByText("2 updates available")).toBeTruthy();
expect(screen.getByText("Checked 7 hours ago by felis v0.4.0")).toBeTruthy();
expect(rowText("felis-api")).toBe("felis-apiInstalled: v0.4.0Newer release: v0.5.1Update available");
expect(rowText("k3s")).toBe("k3sInstalled: v1.36.2+k3s1Newer release: —Up to date");
expect(rowText("jre")).toBe("jreInstalled: 21.0.8+9Newer release: —Feed unreachableadoptium: context deadline exceeded");
expect(rowText("postgresql")).toBe(
"postgresqlInstalled: —Newer release: —Version unreadablepsql: not foundPostgreSQL 13 is past its end of life",
);
expect(screen.getByTitle("sudo felis update --panel --cloudflared").textContent).toBe("sudo felis update --panel --cloudflared");
expect(screen.queryByText("sudo felis update --record")).toBeNull();
});
it("says up to date and offers no apply command when nothing is newer", async () => {
calls.getUpdateReport.mockResolvedValue({
...WITH_UPDATES,
report: { ...WITH_UPDATES.report!, components: [{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" }] },
});
render(<UpdateReportCard />);
expect(await screen.findByText("k3s")).toBeTruthy();
const title = screen.getByText("Component versions").closest("div")!;
expect(within(title).getByText("Up to date")).toBeTruthy();
expect(document.body.textContent).not.toContain("sudo felis update");
});
it("tells the admin how to run the check when the timer never recorded one", async () => {
calls.getUpdateReport.mockResolvedValue({ report: null, stale: true, max_age_seconds: 93600 });
render(<UpdateReportCard />);
expect(await screen.findByText("Never checked")).toBeTruthy();
expect(screen.getByText("No version check has been recorded yet")).toBeTruthy();
expect(screen.getByTitle("sudo felis update --record")).toBeTruthy();
expect(screen.getByTitle("journalctl -u felis-update-check -n 50 --no-pager")).toBeTruthy();
});
it("flags an overdue check and keeps its versions visible", async () => {
calls.getUpdateReport.mockResolvedValue({ ...WITH_UPDATES, stale: true });
render(<UpdateReportCard />);
expect(await screen.findByText("Check overdue")).toBeTruthy();
expect(screen.getByText("The newest version check is more than 26 hours old")).toBeTruthy();
expect(rowText("felis-api")).toContain("v0.5.1");
// A stale list is not a basis to apply from: the apply hint waits for a fresh check.
expect(screen.queryByTitle("sudo felis update --panel --cloudflared")).toBeNull();
expect(screen.getByTitle("sudo felis update --record")).toBeTruthy();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { AlertTriangle, ArrowUpCircle, CheckCircle2, Loader2, PackageCheck, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { UpdateComponent, UpdateComponentState } from "@/lib/types";
import { CopyCommand } from "./DBBackupCard";
// The installed versions are only readable on the host, so the check runs there
// (felis-update-check.timer → `felis update --record`) and this card reads what
// it recorded. Felis applies nothing on its own: an available update comes with
// the host command that prints how to apply it.
const STATE_STYLE: Record<UpdateComponentState, string> = {
available: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25",
current: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25",
unknown: "bg-zinc-500/10 text-muted-foreground border-zinc-500/25",
unreadable: "bg-rose-500/10 text-rose-500 border-rose-500/25",
pinned: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/25",
};
const FIX_COMMANDS = ["sudo felis update --record", "journalctl -u felis-update-check -n 50 --no-pager"];
const GRID = "sm:grid sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_minmax(0,1fr)_7.5rem] sm:items-center sm:gap-3";
export function UpdateReportCard() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
const { data, error, loading, reload } = useAsync(() => api.getUpdateReport(), []);
const report = data?.report ?? null;
const maxAgeHours = data ? Math.round(data.max_age_seconds / 3600) : 26;
const available = report?.components.filter((c) => c.state === "available") ?? [];
const selectors = [...new Set(available.map((c) => c.selector).filter((s): s is string => !!s))];
const state: "loading" | "error" | "never" | "stale" | "ok" = !data
? error
? "error"
: "loading"
: !report
? "never"
: data.stale
? "stale"
: "ok";
const badge = (() => {
if (state === "never") {
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t("versions_status_never")}
</Badge>
);
}
if (state === "stale") {
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t("versions_status_stale")}
</Badge>
);
}
if (state !== "ok") return null;
return available.length > 0 ? (
<Badge className="gap-1 border-transparent bg-amber-500/15 text-amber-600 dark:text-amber-400">
<ArrowUpCircle className="h-3 w-3" />
{t("versions_status_available", { count: available.length })}
</Badge>
) : (
<Badge className="gap-1 border-transparent bg-emerald-500/15 text-emerald-500">
<CheckCircle2 className="h-3 w-3" />
{t("versions_status_current")}
</Badge>
);
})();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"hidden rounded-md p-2 sm:block",
state === "stale" || state === "never"
? "bg-destructive/10 text-destructive"
: available.length > 0
? "bg-amber-500/10 text-amber-500"
: "bg-primary/10 text-primary",
)}
>
<PackageCheck className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold">
{t("versions_title")}
{badge}
</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">{t("versions_subtitle")}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={reload}
disabled={loading}
aria-label={t("versions_refresh")}
title={t("versions_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{state === "loading" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("versions_loading")}
</div>
)}
{state === "error" && <MessageLine kind="error" message={humanizeError(error)} />}
{report && (
<>
<p className="text-xs text-muted-foreground">
<span title={formatAbsolute(report.checked_at, locale)} className={cn(state === "stale" && "text-destructive")}>
{t("versions_checked", { when: formatRelative(report.checked_at, Date.now(), locale), felis: report.felis })}
</span>
</p>
{report.components.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("versions_none")}</p>
) : (
<div className="rounded-md border border-border">
<div className={cn("hidden border-b px-3 py-2 text-[11px] font-semibold text-muted-foreground", GRID)}>
<div>{t("versions_col_component")}</div>
<div>{t("versions_col_installed")}</div>
<div>{t("versions_col_latest")}</div>
<div>{t("versions_col_status")}</div>
</div>
<ul className="divide-y divide-border">
{report.components.map((c) => (
<ComponentRow key={c.name} c={c} />
))}
</ul>
</div>
)}
</>
)}
{state === "ok" && available.length > 0 && (
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/5 p-4">
<p className="text-xs leading-relaxed text-foreground">{t("versions_apply_hint")}</p>
<CopyCommand command={`sudo felis update ${selectors.map((s) => `--${s}`).join(" ")}`.trimEnd()} />
</div>
)}
{(state === "stale" || state === "never") && (
<div className="space-y-3 rounded-lg border border-destructive/25 bg-destructive/5 p-4">
<div className="flex items-start gap-2 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="font-semibold">
{state === "never" ? t("versions_never_title") : t("versions_stale_title", { hours: maxAgeHours })}
</p>
<p className="text-xs leading-relaxed text-destructive/90">{t("versions_fix_hint")}</p>
</div>
</div>
<div className="space-y-2">
{FIX_COMMANDS.map((cmd) => (
<CopyCommand key={cmd} command={cmd} />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
function ComponentRow({ c }: { c: UpdateComponent }) {
const { t } = useTranslation("admin");
return (
<li className="px-3 py-2.5 text-xs">
<div className={cn("flex flex-wrap items-center gap-x-3 gap-y-1", GRID)}>
<div className="w-full font-semibold text-foreground sm:w-auto">{c.name}</div>
<div className="min-w-0 truncate font-mono text-muted-foreground" title={c.current}>
<span className="sm:hidden">{t("versions_col_installed")}: </span>
{c.current ?? "—"}
</div>
<div className="min-w-0 truncate font-mono" title={c.latest}>
<span className="text-muted-foreground sm:hidden">{t("versions_col_latest")}: </span>
{c.latest ? <span className="text-amber-600 dark:text-amber-400">{c.latest}</span> : <span className="text-muted-foreground">—</span>}
</div>
<div>
<span className={cn("inline-block rounded border px-1.5 py-px text-[10px] font-semibold", STATE_STYLE[c.state])}>
{t(`versions_state_${c.state}`)}
</span>
</div>
</div>
{c.error && <p className="mt-1 break-words text-[11px] leading-relaxed text-rose-500/90">{c.error}</p>}
{c.note && <p className="mt-1 break-words text-[11px] leading-relaxed text-muted-foreground">{c.note}</p>}
</li>
);
}
+4
View File
@@ -14,6 +14,7 @@ import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute } from "@/lib/format";
import { DBBackupCard } from "./DBBackupCard";
import { UpdateReportCard } from "./UpdateReportCard";
function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string {
if (!dateOrStr) return "";
@@ -170,6 +171,9 @@ export function UpdatesPage() {
{/* Control-plane database backup freshness (read-only, host timer) */}
<DBBackupCard />
{/* Installed vs newest upstream versions (read-only, host timer) */}
<UpdateReportCard />
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard