fix(panel): 版本徽标读取服务端构建信息并标出开发版,配置加载失败时显示提示条

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:14:21 +08:00
1 parent 34ea733775
commit 1a8cccf245
9 files changed
+287 -9

No files matched your search

+107
View File
@@ -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("<html>Access login</html>")],
["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");
});
});
+47 -4
View File
@@ -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.<root>), 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<BuildInfo>;
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<RuntimeConfig> {
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<RuntimeConfig>;
const raw = (await res.json()) as Partial<RuntimeConfig> | 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;