fix(panel): 版本徽标读取服务端构建信息并标出开发版,配置加载失败时显示提示条
This commit is contained in:
9 files changed
+287
-9
No files matched your search
@@ -2176,7 +2176,11 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const parts = url.pathname.split("/").filter(Boolean);
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
if (method === "GET" && url.pathname === "/config.json") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (parts[0] !== "api" || parts[1] !== "v1") {
|
if (parts[0] !== "api" || parts[1] !== "v1") {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
|||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
|
import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
function SectionGroup({
|
function SectionGroup({
|
||||||
@@ -225,6 +226,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) {
|
|||||||
<div className="mt-auto border-t border-border/50 pt-3">
|
<div className="mt-auto border-t border-border/50 pt-3">
|
||||||
{/* The strip's sign-out button grows to a thumb-sized target below md. */}
|
{/* The strip's sign-out button grows to a thumb-sized target below md. */}
|
||||||
<UserStrip />
|
<UserStrip />
|
||||||
|
<VersionBadge className="mt-2 flex justify-center" />
|
||||||
</div>
|
</div>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
@@ -264,14 +266,12 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5">
|
<div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5">
|
||||||
{/* 1. Toggles & Meta */}
|
{/* 1. Toggles & Meta */}
|
||||||
<div className="flex items-center justify-between px-1">
|
<div className="flex items-center justify-between gap-2 px-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/40 select-none">
|
<VersionBadge />
|
||||||
Felis v0.1.0
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Profile Card */}
|
{/* 2. Profile Card */}
|
||||||
@@ -286,6 +286,7 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
|
||||||
<ConnectionBanner />
|
<ConnectionBanner />
|
||||||
|
<ConfigBanner />
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden">
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MobileNav sections={sections} />
|
<MobileNav sections={sections} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ReactNode, useEffect } from "react";
|
import { type ReactNode, useEffect } from "react";
|
||||||
import { Cat } from "lucide-react";
|
import { Cat } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConfigBanner } from "@/components/RuntimeStatus";
|
||||||
|
|
||||||
// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
|
// 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 —
|
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
|
||||||
@@ -23,6 +24,7 @@ export function AuthLayout({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
|
||||||
<div className="w-full max-w-sm space-y-6">
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<ConfigBanner className="rounded-md border" />
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Cat className="h-9 w-9 text-primary" />
|
<Cat className="h-9 w-9 text-primary" />
|
||||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
|||||||
@@ -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(<VersionBadge />);
|
||||||
|
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(<VersionBadge />);
|
||||||
|
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(<VersionBadge />);
|
||||||
|
expect(container.textContent).toBe("Felis");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigBanner", () => {
|
||||||
|
it("stays hidden while the config loads and once it loaded", () => {
|
||||||
|
const { container, rerender } = render(<ConfigBanner />);
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
state.cfg = base;
|
||||||
|
rerender(<ConfigBanner />);
|
||||||
|
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(<ConfigBanner />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-0 items-center gap-1 whitespace-nowrap font-mono text-[10px] text-muted-foreground/60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={build?.version}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 select-all truncate">{build ? versionLabel(build) : t("brand_name")}</span>
|
||||||
|
{build?.dev && (
|
||||||
|
<span className="shrink-0 rounded bg-amber-500/15 px-1 text-[9px] font-semibold uppercase text-amber-700 dark:text-amber-300">
|
||||||
|
{t("build_dev")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center justify-between gap-2 border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-200",
|
||||||
|
className ?? "border-b",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
{t("config_unavailable")}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
{t("reload_page")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,5 +31,7 @@
|
|||||||
"not_found_body": "The name in the link is not valid, or the page does not exist.",
|
"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.",
|
"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",
|
"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"
|
"open_menu": "Open menu"
|
||||||
}
|
}
|
||||||
@@ -31,5 +31,7 @@
|
|||||||
"not_found_body": "链接里的名称不合法,或者这个页面不存在。",
|
"not_found_body": "链接里的名称不合法,或者这个页面不存在。",
|
||||||
"connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
|
"connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
|
||||||
"reload_page": "刷新页面",
|
"reload_page": "刷新页面",
|
||||||
|
"config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。",
|
||||||
|
"build_dev": "开发版",
|
||||||
"open_menu": "打开菜单"
|
"open_menu": "打开菜单"
|
||||||
}
|
}
|
||||||
@@ -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
@@ -3,6 +3,18 @@
|
|||||||
// /config.json at boot, so one build serves any deployment (mirrors the Go side's
|
// /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.
|
// --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 {
|
export interface RuntimeConfig {
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
rootDomain: string;
|
rootDomain: string;
|
||||||
@@ -10,30 +22,61 @@ export interface RuntimeConfig {
|
|||||||
panelHostname?: string;
|
panelHostname?: string;
|
||||||
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
|
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
|
||||||
adminHostname?: string;
|
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 = {
|
const FALLBACK: RuntimeConfig = {
|
||||||
apiBase: import.meta.env.VITE_API_BASE ?? "/api/v1",
|
apiBase: import.meta.env.VITE_API_BASE ?? "/api/v1",
|
||||||
rootDomain: import.meta.env.VITE_ROOT_DOMAIN ?? "localhost",
|
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;
|
let cached: RuntimeConfig | null = null;
|
||||||
|
|
||||||
/** loadConfig fetches /config.json once and caches it; failures fall back to env
|
/** 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> {
|
export async function loadConfig(): Promise<RuntimeConfig> {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/config.json", { cache: "no-store" });
|
const res = await fetch("/config.json", { cache: "no-store" });
|
||||||
if (!res.ok) throw new Error(`config.json ${res.status}`);
|
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 = {
|
cached = {
|
||||||
apiBase: raw.apiBase ?? FALLBACK.apiBase,
|
apiBase: raw.apiBase ?? FALLBACK.apiBase,
|
||||||
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
|
rootDomain: raw.rootDomain,
|
||||||
panelHostname: raw.panelHostname,
|
panelHostname: raw.panelHostname,
|
||||||
adminHostname: raw.adminHostname,
|
adminHostname: raw.adminHostname,
|
||||||
|
build: parseBuild(raw.build),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn("felis panel: /config.json unavailable, using build-time defaults", err);
|
||||||
cached = FALLBACK;
|
cached = FALLBACK;
|
||||||
}
|
}
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
Reference in new issue
Block a user