diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 6ede726..5da1e51 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -2176,7 +2176,11 @@ export function mockApiPlugin(): Plugin { const parts = url.pathname.split("/").filter(Boolean); if (method === "GET" && url.pathname === "/config.json") { - sendJSON(res, 200, { apiBase: API_BASE, rootDomain: ROOT_DOMAIN }); + sendJSON(res, 200, { + apiBase: API_BASE, + rootDomain: ROOT_DOMAIN, + build: { version: "v0.4.0+g1a2b3c4", release: "v0.4.0", commit: "1a2b3c4", dev: true }, + }); return; } if (parts[0] !== "api" || parts[1] !== "v1") { diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index d91dbf0..bd1e015 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { useTheme } from "@/lib/theme"; import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api"; import { Button } from "@/components/ui/button"; +import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus"; import { ErrorBoundary } from "@/components/ErrorBoundary"; function SectionGroup({ @@ -225,6 +226,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) {
{/* The strip's sign-out button grows to a thumb-sized target below md. */} +
@@ -264,14 +266,12 @@ export function AppShell() {
{/* 1. Toggles & Meta */} -
+
- - Felis v0.1.0 - +
{/* 2. Profile Card */} @@ -286,6 +286,7 @@ export function AppShell() {
+
diff --git a/panel/src/components/AuthLayout.tsx b/panel/src/components/AuthLayout.tsx index 1a21758..8393571 100644 --- a/panel/src/components/AuthLayout.tsx +++ b/panel/src/components/AuthLayout.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useEffect } from "react"; import { Cat } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { ConfigBanner } from "@/components/RuntimeStatus"; // AuthLayout is the chrome for the pre-app auth surfaces (login, forced change). // These live OUTSIDE AppShell — there is no nav, no tier context to honor yet — @@ -23,6 +24,7 @@ export function AuthLayout({ return (
+

{title}

diff --git a/panel/src/components/RuntimeStatus.test.tsx b/panel/src/components/RuntimeStatus.test.tsx new file mode 100644 index 0000000..d0f74da --- /dev/null +++ b/panel/src/components/RuntimeStatus.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import i18next from "i18next"; +import type { RuntimeConfig } from "@/lib/config"; +import { ConfigBanner, VersionBadge } from "./RuntimeStatus"; + +const state = vi.hoisted(() => ({ cfg: null as RuntimeConfig | null })); +vi.mock("@/lib/hooks", () => ({ useConfig: () => state.cfg })); + +const base: RuntimeConfig = { apiBase: "/api/v1", rootDomain: "mc.example" }; + +beforeEach(() => { + state.cfg = null; +}); + +describe("VersionBadge", () => { + it("shows the release of a clean build, with the full stamp as its tooltip", () => { + state.cfg = { ...base, build: { version: "v1.2.0", release: "v1.2.0", dev: false } }; + const { container } = render(); + expect(container.textContent).toBe("v1.2.0"); + expect(container.firstElementChild?.getAttribute("title")).toBe("v1.2.0"); + }); + + it("marks a dev build and shows its commit", () => { + state.cfg = { ...base, build: { version: "v1.2.0+g1a2b3c4", release: "v1.2.0", commit: "1a2b3c4", dev: true } }; + render(); + expect(screen.getByText("v1.2.0+1a2b3c4")).toBeTruthy(); + expect(screen.getByText(i18next.t("common:build_dev"))).toBeTruthy(); + }); + + it("shows only the name without a build stamp", () => { + state.cfg = base; + const { container } = render(); + expect(container.textContent).toBe("Felis"); + }); +}); + +describe("ConfigBanner", () => { + it("stays hidden while the config loads and once it loaded", () => { + const { container, rerender } = render(); + expect(container.textContent).toBe(""); + state.cfg = base; + rerender(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("warns when the panel runs on fallback defaults, and reloads on request", () => { + state.cfg = { ...base, fallback: true }; + const reload = vi.fn(); + Object.defineProperty(window, "location", { value: { ...window.location, reload }, configurable: true }); + render(); + + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain(i18next.t("common:config_unavailable")); + fireEvent.click(screen.getByRole("button", { name: i18next.t("common:reload_page") })); + expect(reload).toHaveBeenCalled(); + }); +}); diff --git a/panel/src/components/RuntimeStatus.tsx b/panel/src/components/RuntimeStatus.tsx new file mode 100644 index 0000000..8cc8260 --- /dev/null +++ b/panel/src/components/RuntimeStatus.tsx @@ -0,0 +1,58 @@ +import { AlertTriangle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { versionLabel } from "@/lib/config"; +import { useConfig } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; + +// VersionBadge shows what the server actually runs, from the build stamp in +// /config.json: the release for a clean build, release+commit and a dev mark +// otherwise. It sits under the brand in a narrow sidebar, so it is the version +// alone, cut short with an ellipsis if need be; the full stamp is in the +// tooltip. Without a stamp it shows the product name. +export function VersionBadge({ className }: { className?: string }) { + const { t } = useTranslation("common"); + const build = useConfig()?.build; + return ( + + {build ? versionLabel(build) : t("brand_name")} + {build?.dev && ( + + {t("build_dev")} + + )} + + ); +} + +// ConfigBanner says when /config.json could not be read: the panel then runs +// on build-time defaults, so server addresses and console links it shows may +// be wrong. It does not block anything; a reload fetches the file again. +export function ConfigBanner({ className }: { className?: string }) { + const { t } = useTranslation("common"); + const cfg = useConfig(); + if (!cfg?.fallback) return null; + return ( +
+ + + {t("config_unavailable")} + + +
+ ); +} diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json index d32cc78..2b648f4 100644 --- a/panel/src/i18n/resources/en-US/common.json +++ b/panel/src/i18n/resources/en-US/common.json @@ -31,5 +31,7 @@ "not_found_body": "The name in the link is not valid, or the page does not exist.", "connection_lost": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. This clears by itself once the network is back; an expired Access sign-in needs a page reload.", "reload_page": "Reload page", + "config_unavailable": "The panel configuration (/config.json) did not load, so server addresses and links shown here may be wrong. Reload to try again.", + "build_dev": "dev", "open_menu": "Open menu" } diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json index 09c2603..4f240b4 100644 --- a/panel/src/i18n/resources/zh-CN/common.json +++ b/panel/src/i18n/resources/zh-CN/common.json @@ -31,5 +31,7 @@ "not_found_body": "链接里的名称不合法,或者这个页面不存在。", "connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。", "reload_page": "刷新页面", + "config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。", + "build_dev": "开发版", "open_menu": "打开菜单" } diff --git a/panel/src/lib/config.test.ts b/panel/src/lib/config.test.ts new file mode 100644 index 0000000..25503f9 --- /dev/null +++ b/panel/src/lib/config.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// config.ts caches the first load, so every test imports a fresh copy. +async function freshConfig() { + vi.resetModules(); + return import("./config"); +} + +function serve(body: unknown, status = 200) { + const fetchMock = vi.fn(async () => + new Response(typeof body === "string" ? body : JSON.stringify(body), { status }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const build = { version: "v1.2.0+g1a2b3c4", release: "v1.2.0", commit: "1a2b3c4", dev: true }; + +beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("loadConfig", () => { + it("reads the server's config and build stamp", async () => { + serve({ apiBase: "/api/v1", rootDomain: "mc.example", adminHostname: "op.console.mc.example", build }); + const { loadConfig } = await freshConfig(); + + const cfg = await loadConfig(); + expect(cfg).toEqual({ + apiBase: "/api/v1", + rootDomain: "mc.example", + panelHostname: undefined, + adminHostname: "op.console.mc.example", + build, + }); + expect(cfg.fallback).toBeUndefined(); + }); + + it("fetches once and keeps the result", async () => { + const fetchMock = serve({ apiBase: "/api/v1", rootDomain: "mc.example" }); + const { loadConfig } = await freshConfig(); + await loadConfig(); + await loadConfig(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("drops a build stamp it cannot read, keeping the rest", async () => { + serve({ apiBase: "/api/v1", rootDomain: "mc.example", build: { version: 3 } }); + const { loadConfig } = await freshConfig(); + const cfg = await loadConfig(); + expect(cfg.build).toBeUndefined(); + expect(cfg.fallback).toBeUndefined(); + }); + + it("reads a release build without a commit", async () => { + serve({ rootDomain: "mc.example", build: { version: "v1.2.0", release: "v1.2.0", commit: "", dev: false } }); + const { loadConfig } = await freshConfig(); + expect((await loadConfig()).build).toEqual({ version: "v1.2.0", release: "v1.2.0", commit: undefined, dev: false }); + }); + + const failures: Array<[string, () => void]> = [ + ["an error status", () => serve({ rootDomain: "mc.example" }, 502)], + ["a network failure", () => vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(new TypeError("offline"))))], + ["a body that is not JSON", () => serve("Access login")], + ["JSON null", () => serve("null")], + ["no rootDomain", () => serve({ apiBase: "/api/v1" })], + ["an empty rootDomain", () => serve({ apiBase: "/api/v1", rootDomain: "" })], + ]; + for (const [what, arrange] of failures) { + it(`marks the defaults as a fallback after ${what}`, async () => { + arrange(); + const { loadConfig } = await freshConfig(); + const cfg = await loadConfig(); + expect(cfg.fallback).toBe(true); + expect(cfg.apiBase).toBe("/api/v1"); + expect(cfg.rootDomain).toBe("localhost"); + expect(console.warn).toHaveBeenCalled(); + }); + } +}); + +describe("versionLabel", () => { + it("shows a clean release as its tag", async () => { + const { versionLabel } = await freshConfig(); + expect(versionLabel({ version: "v1.2.0", release: "v1.2.0", dev: false })).toBe("v1.2.0"); + }); + + it("appends a commit only for a dev build, as the server documents", async () => { + const { versionLabel } = await freshConfig(); + expect(versionLabel({ version: "v1.2.0", release: "v1.2.0", commit: "1a2b3c4", dev: false })).toBe("v1.2.0"); + }); + + it("adds the commit to a dev build", async () => { + const { versionLabel } = await freshConfig(); + expect(versionLabel(build)).toBe("v1.2.0+1a2b3c4"); + }); + + it("shows a dev build without a commit as its release", async () => { + const { versionLabel } = await freshConfig(); + expect(versionLabel({ version: "dev", release: "dev", dev: true })).toBe("dev"); + }); +}); diff --git a/panel/src/lib/config.ts b/panel/src/lib/config.ts index ccb33a8..70fa4a3 100644 --- a/panel/src/lib/config.ts +++ b/panel/src/lib/config.ts @@ -3,6 +3,18 @@ // /config.json at boot, so one build serves any deployment (mirrors the Go side's // --set root_domain portability, spec §16/§27). A dev fallback reads VITE_* env. +/** BuildInfo is the server's build stamp (internal/panel buildInfo). */ +export interface BuildInfo { + /** The raw stamp, e.g. "v1.0.0-earlyAccess+g1a2b3c4". */ + version: string; + /** The tag without any commit suffix, e.g. "v1.0.0-earlyAccess". */ + release: string; + /** Short commit of a dev build; absent for a clean release. */ + commit?: string; + /** Anything but a clean release build. */ + dev: boolean; +} + export interface RuntimeConfig { apiBase: string; rootDomain: string; @@ -10,30 +22,61 @@ export interface RuntimeConfig { panelHostname?: string; /** Operator-console hostname (op.console.), absent when unconfigured. */ adminHostname?: string; + /** What the server runs, for the version badge. */ + build?: BuildInfo; + /** /config.json could not be read, so these are build-time defaults and + * addresses built from them may be wrong. The shell says so. */ + fallback?: boolean; } const FALLBACK: RuntimeConfig = { apiBase: import.meta.env.VITE_API_BASE ?? "/api/v1", rootDomain: import.meta.env.VITE_ROOT_DOMAIN ?? "localhost", + fallback: true, }; +function parseBuild(raw: unknown): BuildInfo | undefined { + if (!raw || typeof raw !== "object") return undefined; + const b = raw as Partial; + if (typeof b.version !== "string" || typeof b.release !== "string") return undefined; + return { + version: b.version, + release: b.release, + commit: typeof b.commit === "string" && b.commit ? b.commit : undefined, + dev: b.dev === true, + }; +} + +/** versionLabel is what the badge shows: the release, plus the commit for a + * dev build (the Go side's documented rendering). */ +export function versionLabel(b: BuildInfo): string { + return b.dev && b.commit ? `${b.release}+${b.commit}` : b.release; +} + let cached: RuntimeConfig | null = null; /** loadConfig fetches /config.json once and caches it; failures fall back to env - * so the SPA still mounts (and surfaces the misconfig in the UI). */ + * so the SPA still mounts, marked `fallback` so the shell can say so. */ export async function loadConfig(): Promise { if (cached) return cached; try { const res = await fetch("/config.json", { cache: "no-store" }); if (!res.ok) throw new Error(`config.json ${res.status}`); - const raw = (await res.json()) as Partial; + const raw = (await res.json()) as Partial | null; + // Every address the panel shows is built on rootDomain; a file without one + // is as good as none. + if (typeof raw?.rootDomain !== "string" || raw.rootDomain === "") { + throw new Error("config.json has no rootDomain"); + } cached = { apiBase: raw.apiBase ?? FALLBACK.apiBase, - rootDomain: raw.rootDomain ?? FALLBACK.rootDomain, + rootDomain: raw.rootDomain, panelHostname: raw.panelHostname, adminHostname: raw.adminHostname, + build: parseBuild(raw.build), }; - } catch { + } catch (err) { + console.warn("felis panel: /config.json unavailable, using build-time defaults", err); cached = FALLBACK; } return cached;