feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令
This commit is contained in:
28 files changed
+994
-10
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "无匹配的提交或暂未加载",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]>>,
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user