Loading panel/dev/mockApi.ts +5 −1 Changes for panel/dev/mockApi.ts: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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") { Loading panel/src/components/AppShell.tsx +5 −4 Changes for panel/src/components/AppShell.tsx: 5 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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({ Loading Loading @@ -225,6 +226,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) { <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. */} <UserStrip /> <VersionBadge className="mt-2 flex justify-center" /> </div> </DialogPrimitive.Content> </DialogPrimitive.Portal> Loading Loading @@ -264,14 +266,12 @@ export function AppShell() { <div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5"> {/* 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"> <LangToggle /> <ThemeToggle /> </div> <span className="text-[10px] font-mono text-muted-foreground/40 select-none"> Felis v0.1.0 </span> <VersionBadge /> </div> {/* 2. Profile Card */} Loading @@ -286,6 +286,7 @@ export function AppShell() { <div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto"> <ConnectionBanner /> <ConfigBanner /> <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"> <MobileNav sections={sections} /> Loading panel/src/components/AuthLayout.tsx +2 −0 Changes for panel/src/components/AuthLayout.tsx: 2 added lines, 0 removed lines. Original line number Diff line number Diff line 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 — Loading @@ -23,6 +24,7 @@ export function AuthLayout({ return ( <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"> <ConfigBanner className="rounded-md border" /> <div className="flex flex-col items-center gap-2 text-center"> <Cat className="h-9 w-9 text-primary" /> <h1 className="text-xl font-semibold tracking-tight">{title}</h1> Loading panel/src/components/RuntimeStatus.test.tsx 0 → 100644 +59 −0 Changes for panel/src/components/RuntimeStatus.test.tsx: 59 added lines, 0 removed lines. Original line number Diff line number Diff line // @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(); }); }); panel/src/components/RuntimeStatus.tsx 0 → 100644 +58 −0 Changes for panel/src/components/RuntimeStatus.tsx: 58 added lines, 0 removed lines. Original line number Diff line number Diff line 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> ); } Loading
panel/dev/mockApi.ts +5 −1 Changes for panel/dev/mockApi.ts: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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") { Loading
panel/src/components/AppShell.tsx +5 −4 Changes for panel/src/components/AppShell.tsx: 5 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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({ Loading Loading @@ -225,6 +226,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) { <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. */} <UserStrip /> <VersionBadge className="mt-2 flex justify-center" /> </div> </DialogPrimitive.Content> </DialogPrimitive.Portal> Loading Loading @@ -264,14 +266,12 @@ export function AppShell() { <div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5"> {/* 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"> <LangToggle /> <ThemeToggle /> </div> <span className="text-[10px] font-mono text-muted-foreground/40 select-none"> Felis v0.1.0 </span> <VersionBadge /> </div> {/* 2. Profile Card */} Loading @@ -286,6 +286,7 @@ export function AppShell() { <div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto"> <ConnectionBanner /> <ConfigBanner /> <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"> <MobileNav sections={sections} /> Loading
panel/src/components/AuthLayout.tsx +2 −0 Changes for panel/src/components/AuthLayout.tsx: 2 added lines, 0 removed lines. Original line number Diff line number Diff line 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 — Loading @@ -23,6 +24,7 @@ export function AuthLayout({ return ( <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"> <ConfigBanner className="rounded-md border" /> <div className="flex flex-col items-center gap-2 text-center"> <Cat className="h-9 w-9 text-primary" /> <h1 className="text-xl font-semibold tracking-tight">{title}</h1> Loading
panel/src/components/RuntimeStatus.test.tsx 0 → 100644 +59 −0 Changes for panel/src/components/RuntimeStatus.test.tsx: 59 added lines, 0 removed lines. Original line number Diff line number Diff line // @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(); }); });
panel/src/components/RuntimeStatus.tsx 0 → 100644 +58 −0 Changes for panel/src/components/RuntimeStatus.tsx: 58 added lines, 0 removed lines. Original line number Diff line number Diff line 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> ); }