Skip to content

Commit 67493b5

Browse files
Merge pull request #7 from OpenElementsLabs/feat/status-capabilities
feat: add capabilities panel to server status page (0.7.0)
2 parents ec3609b + d81a207 commit 67493b5

7 files changed

Lines changed: 193 additions & 23 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@open-elements/nextjs-app-layer",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Next.js foundation (auth, proxy, admin pages, login/forbidden, layout) for Open Elements applications",
55
"packageManager": "pnpm@11.3.0",
66
"engines": {
@@ -51,7 +51,7 @@
5151
"prepublishOnly": "pnpm run build"
5252
},
5353
"peerDependencies": {
54-
"@open-elements/ui": "^0.8.0",
54+
"@open-elements/ui": "^0.9.0",
5555
"lucide-react": "^0.500.0",
5656
"next": "^15.3.0",
5757
"next-auth": "5.0.0-beta.30",
@@ -60,7 +60,7 @@
6060
},
6161
"devDependencies": {
6262
"@eslint/js": "^9.0.0",
63-
"@open-elements/ui": "^0.8.0",
63+
"@open-elements/ui": "^0.9.0",
6464
"@testing-library/jest-dom": "^6.9.1",
6565
"@testing-library/react": "^16.3.2",
6666
"@types/react": "^19.0.0",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ allowBuilds:
77
sharp: true
88

99
minimumReleaseAgeExclude:
10-
- '@open-elements/ui@0.8.0'
10+
- '@open-elements/ui@0.9.0'

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export { usersPageMeta } from "./pages/admin/users/meta";
3131

3232
export { createServerStatusPage } from "./pages/admin/status/page";
3333
export { ServerStatusClient } from "./pages/admin/status/server-status-client";
34+
export type {
35+
StatusCapabilitiesConfig,
36+
StatusCapabilityItem,
37+
} from "./pages/admin/status/server-status-client";
3438
export { serverStatusPageMeta } from "./pages/admin/status/meta";
3539

3640
export { createBearerTokenPage } from "./pages/admin/token/page";
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, afterEach, vi } from "vitest";
2+
import { cleanup, screen, waitFor } from "@testing-library/react";
3+
import { ServerStatusClient, type StatusCapabilitiesConfig } from "../server-status-client";
4+
import { renderWithLibProviders } from "../../../../test/render-with-providers";
5+
6+
const HEIC_CONFIG: StatusCapabilitiesConfig = {
7+
endpoint: "/api/admin/capabilities",
8+
items: [
9+
{
10+
id: "heicAvailable",
11+
label: "HEIC image decoding",
12+
availableText: "Available",
13+
unavailableText: "Not available",
14+
hint: "HEIC uploads will be rejected with 415 — check Dockerfile",
15+
},
16+
],
17+
};
18+
19+
function jsonResponse(body: unknown): Response {
20+
return { ok: true, json: () => Promise.resolve(body) } as unknown as Response;
21+
}
22+
23+
/** Route fetch responses by URL; an unmapped URL rejects (surfacing test gaps). */
24+
function stubFetch(handlers: Record<string, () => Promise<Response>>) {
25+
vi.stubGlobal(
26+
"fetch",
27+
vi.fn((input: RequestInfo | URL) => {
28+
const url = typeof input === "string" ? input : input.toString();
29+
const handler = handlers[url];
30+
if (!handler) {
31+
return Promise.reject(new Error(`unexpected fetch: ${url}`));
32+
}
33+
return handler();
34+
}),
35+
);
36+
}
37+
38+
afterEach(() => {
39+
cleanup();
40+
vi.unstubAllGlobals();
41+
});
42+
43+
describe("ServerStatusClient", () => {
44+
it("renders only the backend health row when no capabilities config is given", async () => {
45+
stubFetch({
46+
"/api/health": () => Promise.resolve(jsonResponse({ status: "UP" })),
47+
});
48+
49+
renderWithLibProviders(<ServerStatusClient />);
50+
51+
await waitFor(() => expect(screen.getByText("System Status")).toBeInTheDocument());
52+
expect(screen.queryByText("HEIC image decoding")).not.toBeInTheDocument();
53+
});
54+
55+
it("renders a green capability row from the fetched capabilities map", async () => {
56+
stubFetch({
57+
"/api/health": () => Promise.resolve(jsonResponse({ status: "UP" })),
58+
"/api/admin/capabilities": () => Promise.resolve(jsonResponse({ heicAvailable: true })),
59+
});
60+
61+
renderWithLibProviders(<ServerStatusClient capabilities={HEIC_CONFIG} />);
62+
63+
await waitFor(() => expect(screen.getByText("HEIC image decoding")).toBeInTheDocument());
64+
expect(screen.getByText("Available")).toBeInTheDocument();
65+
// Backend health row is still present.
66+
expect(screen.getByText("System Status")).toBeInTheDocument();
67+
});
68+
69+
it("renders the capability as available=false when the endpoint reports it unavailable", async () => {
70+
stubFetch({
71+
"/api/health": () => Promise.resolve(jsonResponse({ status: "UP" })),
72+
"/api/admin/capabilities": () => Promise.resolve(jsonResponse({ heicAvailable: false })),
73+
});
74+
75+
renderWithLibProviders(<ServerStatusClient capabilities={HEIC_CONFIG} />);
76+
77+
await waitFor(() => expect(screen.getByText("Not available")).toBeInTheDocument());
78+
});
79+
80+
it("fails safe to unavailable when the capabilities fetch rejects", async () => {
81+
stubFetch({
82+
"/api/health": () => Promise.resolve(jsonResponse({ status: "UP" })),
83+
"/api/admin/capabilities": () => Promise.reject(new Error("network down")),
84+
});
85+
86+
renderWithLibProviders(<ServerStatusClient capabilities={HEIC_CONFIG} />);
87+
88+
await waitFor(() => expect(screen.getByText("Not available")).toBeInTheDocument());
89+
expect(screen.queryByText("Available")).not.toBeInTheDocument();
90+
});
91+
92+
it("fails safe to unavailable when the capabilities endpoint returns a non-ok response", async () => {
93+
stubFetch({
94+
"/api/health": () => Promise.resolve(jsonResponse({ status: "UP" })),
95+
"/api/admin/capabilities": () =>
96+
Promise.resolve({ ok: false, json: () => Promise.resolve(null) } as unknown as Response),
97+
});
98+
99+
renderWithLibProviders(<ServerStatusClient capabilities={HEIC_CONFIG} />);
100+
101+
await waitFor(() => expect(screen.getByText("Not available")).toBeInTheDocument());
102+
});
103+
});

src/pages/admin/status/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import type { Session } from "next-auth";
22
import { ForbiddenPage } from "../../../components/forbidden-page";
33
import { ROLE_IT_ADMIN } from "../../../lib/roles";
4-
import { ServerStatusClient } from "./server-status-client";
4+
import { ServerStatusClient, type StatusCapabilitiesConfig } from "./server-status-client";
55

66
type AuthFn = () => Promise<Session | null>;
77

88
export function createServerStatusPage({
99
auth,
1010
homeRoute,
11+
capabilities,
1112
}: {
1213
readonly auth: AuthFn;
1314
readonly homeRoute?: string;
15+
readonly capabilities?: StatusCapabilitiesConfig;
1416
}) {
1517
return async function ServerStatusPage() {
1618
const session = await auth();
1719
if (!session?.roles?.includes(ROLE_IT_ADMIN)) {
1820
return <ForbiddenPage homeRoute={homeRoute} />;
1921
}
20-
return <ServerStatusClient />;
22+
return <ServerStatusClient capabilities={capabilities} />;
2123
};
2224
}
Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
"use client";
22

33
import { useEffect, useState } from "react";
4-
import { HealthStatus } from "@open-elements/ui";
4+
import { CapabilityStatus, HealthStatus } from "@open-elements/ui";
55
import { useAppLayerTranslations } from "../../../translations/provider";
66

7-
export function ServerStatusClient() {
7+
/** One runtime-capability row configured by the consuming app. */
8+
export interface StatusCapabilityItem {
9+
/** Matches a boolean key returned by the capabilities endpoint. */
10+
readonly id: string;
11+
readonly label: string;
12+
readonly availableText: string;
13+
readonly unavailableText: string;
14+
readonly hint?: string;
15+
}
16+
17+
/** Optional "runtime capabilities" panel for the server status page. */
18+
export interface StatusCapabilitiesConfig {
19+
/** App endpoint returning a `{ [id]: boolean }` map (fetched via the Next proxy). */
20+
readonly endpoint: string;
21+
readonly items: ReadonlyArray<StatusCapabilityItem>;
22+
}
23+
24+
export function ServerStatusClient({
25+
capabilities,
26+
}: {
27+
readonly capabilities?: StatusCapabilitiesConfig;
28+
}) {
829
const t = useAppLayerTranslations();
930
const [healthy, setHealthy] = useState<boolean | null>(null);
31+
// `null` until the capabilities fetch settles; then a `{ id -> available }` map.
32+
const [capabilityState, setCapabilityState] = useState<Record<string, boolean> | null>(null);
1033

1134
useEffect(() => {
1235
fetch("/api/health")
@@ -15,19 +38,57 @@ export function ServerStatusClient() {
1538
.catch(() => setHealthy(false));
1639
}, []);
1740

41+
useEffect(() => {
42+
if (!capabilities) {
43+
return;
44+
}
45+
let cancelled = false;
46+
// Fail-safe: any failure resolves to an empty map, so every configured row
47+
// renders as unavailable — an operator must never see a false "available".
48+
fetch(capabilities.endpoint)
49+
.then((res) => (res.ok ? res.json() : null))
50+
.then((data: Record<string, boolean> | null) => {
51+
if (!cancelled) {
52+
setCapabilityState(data ?? {});
53+
}
54+
})
55+
.catch(() => {
56+
if (!cancelled) {
57+
setCapabilityState({});
58+
}
59+
});
60+
return () => {
61+
cancelled = true;
62+
};
63+
}, [capabilities]);
64+
1865
return (
1966
<div>
2067
<h1 className="mb-6 font-heading text-2xl font-bold text-oe-dark">{t.nav.serverStatus}</h1>
21-
{healthy !== null && (
22-
<HealthStatus
23-
healthy={healthy}
24-
translations={{
25-
title: t.health.title,
26-
statusUp: t.health.statusUp,
27-
statusDown: t.health.statusDown,
28-
}}
29-
/>
30-
)}
68+
<div className="space-y-4">
69+
{healthy !== null && (
70+
<HealthStatus
71+
healthy={healthy}
72+
translations={{
73+
title: t.health.title,
74+
statusUp: t.health.statusUp,
75+
statusDown: t.health.statusDown,
76+
}}
77+
/>
78+
)}
79+
{capabilities &&
80+
capabilityState !== null &&
81+
capabilities.items.map((item) => (
82+
<CapabilityStatus
83+
key={item.id}
84+
available={capabilityState[item.id] === true}
85+
label={item.label}
86+
availableText={item.availableText}
87+
unavailableText={item.unavailableText}
88+
hint={item.hint}
89+
/>
90+
))}
91+
</div>
3192
</div>
3293
);
3394
}

0 commit comments

Comments
 (0)