diff --git a/docs/server/web-configuration.md b/docs/server/web-configuration.md index 2b8894d..0e3de46 100644 --- a/docs/server/web-configuration.md +++ b/docs/server/web-configuration.md @@ -22,11 +22,12 @@ Every environment variable `Ignis.Web` (the BFF) reads. All default to off. Set to `"true"` to enable. -| Variable | Notes | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `IGNIS_WEB_FEATURES_ADMIN` | Enables the admin UI at `/admin/*`. Requires `IGNIS_WEB_FEATURES_AUTH=true`. See [Admin UI](../admin/admin-ui.md). | -| `IGNIS_WEB_FEATURES_AUTH` | Master switch for the OAuth/BFF login flow. Most other features require this. | -| `IGNIS_WEB_FEATURES_OPERATIONS` | Enables the operations log at `/admin/operations`. Requires `IGNIS_WEB_FEATURES_ADMIN=true`. | +| Variable | Notes | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `IGNIS_WEB_FEATURES_ADMIN` | Enables the admin UI at `/admin/*`. Requires `IGNIS_WEB_FEATURES_AUTH=true`. See [Admin UI](../admin/admin-ui.md). | +| `IGNIS_WEB_FEATURES_AUTH` | Master switch for the OAuth/BFF login flow. Most other features require this. | +| `IGNIS_WEB_FEATURES_OPERATIONS` | Enables the operations log at `/admin/operations`. Requires `IGNIS_WEB_FEATURES_ADMIN=true`. | +| `IGNIS_WEB_FEATURES_RESOURCES_UI` | Enables the resource browser at `/resources`. Requires `IGNIS_WEB_FEATURES_AUTH=true`. | ## Cross-references with the API diff --git a/src/Ignis.Web/.env.example b/src/Ignis.Web/.env.example index 68a3eb2..27b0bde 100644 --- a/src/Ignis.Web/.env.example +++ b/src/Ignis.Web/.env.example @@ -25,3 +25,4 @@ IGNIS_WEB_FHIR_BASE_URL=https://localhost:5201/fhir/ IGNIS_WEB_FEATURES_ADMIN=true IGNIS_WEB_FEATURES_AUTH=true IGNIS_WEB_FEATURES_OPERATIONS=false +IGNIS_WEB_FEATURES_RESOURCES_UI=false diff --git a/src/Ignis.Web/app/features/resources-ui/config.server.ts b/src/Ignis.Web/app/features/resources-ui/config.server.ts new file mode 100644 index 0000000..c71e9bf --- /dev/null +++ b/src/Ignis.Web/app/features/resources-ui/config.server.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026, Incendi + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { envBool } from "#app/env.server"; +import * as authConfig from "#app/features/auth/config.server"; + +export function isEnabled(): boolean { + // Resource browser is available to any authenticated user (clinicians, + // admins, etc.). Requires auth plus its own IGNIS_WEB_FEATURES_RESOURCES_UI + // flag so it can be rolled out independently. + return authConfig.isEnabled() && envBool("IGNIS_WEB_FEATURES_RESOURCES_UI", { default: false }); +} diff --git a/src/Ignis.Web/app/features/resources-ui/fhir-client.server.ts b/src/Ignis.Web/app/features/resources-ui/fhir-client.server.ts new file mode 100644 index 0000000..75e7c4c --- /dev/null +++ b/src/Ignis.Web/app/features/resources-ui/fhir-client.server.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Incendi + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { env } from "#app/env.server"; +import { Logger } from "#app/logger"; + +const logger = Logger.create({ namespace: "resources-ui:fhir-client" }); + +interface CapabilityResource { + type: string; +} + +interface CapabilityRest { + resource?: CapabilityResource[]; +} + +interface CapabilityStatement { + resourceType?: string; + rest?: CapabilityRest[]; +} + +interface CountBundle { + total?: number; +} + +// FHIR resource type names are PascalCase ASCII (per the spec). Reject +// anything else so the helper is safe by construction even when called +// with input that didn't come from the CapabilityStatement. +const FHIR_RESOURCE_TYPE_NAME = /^[A-Z][A-Za-z]+$/; + +function resolveFhirUrl(request: Request, endpoint: string): URL { + const configuredBase = env("IGNIS_WEB_FHIR_BASE_URL", { default: "" }); + const baseUrl = new URL(configuredBase === "" + ? new URL("/fhir/", request.url).toString() + : configuredBase); + const normalizedPath = baseUrl.pathname === "/" + ? "/fhir/" + : baseUrl.pathname.endsWith("/") + ? baseUrl.pathname + : `${baseUrl.pathname}/`; + return new URL(endpoint, `${baseUrl.origin}${normalizedPath}`); +} + +function fhirHeaders(accessToken: string | undefined): HeadersInit { + const headers: Record = { + Accept: "application/fhir+json, application/json", + }; + if (accessToken !== undefined && accessToken !== "") { + headers.Authorization = `Bearer ${accessToken}`; + } + return headers; +} + +/** + * Returns the list of resource types declared by the FHIR server's + * CapabilityStatement, or `null` if the statement can't be retrieved. + */ +export async function fetchResourceTypes( + request: Request, + accessToken: string | undefined, +): Promise { + try { + const url = resolveFhirUrl(request, "metadata"); + const response = await fetch(url, { headers: fhirHeaders(accessToken) }); + if (!response.ok) { + logger.warn( + { context: { status: response.status } }, + "CapabilityStatement fetch failed", + ); + return null; + } + const body = (await response.json()) as CapabilityStatement; + return body.rest?.[0]?.resource?.map((r) => r.type) ?? []; + } catch (error) { + logger.warn({ error }, "CapabilityStatement fetch threw"); + return null; + } +} + +/** + * Fetches the total instance count for one resource type using + * `_summary=count`. Returns `null` when the count cannot be determined so + * callers can render a placeholder instead of failing the whole page. + */ +export async function fetchResourceCount( + request: Request, + accessToken: string | undefined, + resourceType: string, +): Promise { + if (!FHIR_RESOURCE_TYPE_NAME.test(resourceType)) { + logger.warn( + { context: { resourceType } }, + "Rejected count request for non-FHIR resource type name", + ); + return null; + } + try { + const url = resolveFhirUrl(request, `${resourceType}?_summary=count`); + const response = await fetch(url, { headers: fhirHeaders(accessToken) }); + if (!response.ok) return null; + const body = (await response.json()) as CountBundle; + return body.total ?? null; + } catch { + return null; + } +} diff --git a/src/Ignis.Web/app/features/resources-ui/routes/index.tsx b/src/Ignis.Web/app/features/resources-ui/routes/index.tsx new file mode 100644 index 0000000..b672f55 --- /dev/null +++ b/src/Ignis.Web/app/features/resources-ui/routes/index.tsx @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Incendi + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { Heading } from "@eventuras/ratio-ui/core/Heading"; +import { Panel } from "@eventuras/ratio-ui/core/Panel"; +import { Table } from "@eventuras/ratio-ui/core/Table"; +import { Text } from "@eventuras/ratio-ui/core/Text"; +import { Container } from "@eventuras/ratio-ui/layout/Container"; +import { Stack } from "@eventuras/ratio-ui/layout/Stack"; +import { redirect } from "react-router"; + +import { getSessionFromRequest } from "#app/features/auth/session.server"; +import { m } from "#app/i18n/paraglide/messages"; + +import type { Route } from "./+types/index"; +import { isEnabled } from "../config.server"; +import { fetchResourceCount, fetchResourceTypes } from "../fhir-client.server"; + +interface ResourceRow { + type: string; + count: number | null; +} + +// Cap parallel count requests so a 140-type CapabilityStatement doesn't +// fire 140 concurrent calls at the FHIR backend on every page load. +const COUNT_FETCH_CONCURRENCY = 8; + +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + async function worker(): Promise { + while (next < items.length) { + const i = next; + next += 1; + results[i] = await fn(items[i]); + } + } + const workerCount = Math.min(limit, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +export async function loader({ request }: Route.LoaderArgs) { + if (!isEnabled()) return redirect("/"); + const session = await getSessionFromRequest(request); + if (session === null) return redirect("/auth/login"); + + const accessToken = session.tokens?.accessToken; + const types = await fetchResourceTypes(request, accessToken); + if (types === null) { + return { ok: false as const, rows: [] }; + } + + const rows: ResourceRow[] = await mapWithConcurrency( + types, + COUNT_FETCH_CONCURRENCY, + async (type) => ({ + type, + count: await fetchResourceCount(request, accessToken, type), + }), + ); + rows.sort((a, b) => a.type.localeCompare(b.type)); + return { ok: true as const, rows }; +} + +export default function ResourcesIndex({ loaderData }: Route.ComponentProps) { + return ( + + + + {m.resources_title()} + {m.resources_subtitle()} + + + {loaderData.ok ? ( + + + + {m.resources_table_type()} + {m.resources_table_count()} + + + + {loaderData.rows.map((row) => ( + + {row.type} + {row.count ?? "—"} + + ))} + +
+ ) : ( + + {m.resources_capability_error()} + + )} +
+
+ ); +} diff --git a/src/Ignis.Web/app/i18n/messages/en.json b/src/Ignis.Web/app/i18n/messages/en.json index d0e5a7a..4e7e0ae 100644 --- a/src/Ignis.Web/app/i18n/messages/en.json +++ b/src/Ignis.Web/app/i18n/messages/en.json @@ -21,5 +21,10 @@ "operations_title": "Operations", "operations_subtitle": "Live progress for long-running server operations.", "operations_console_title": "Operations log", - "operations_console_status_dummy": "Showing placeholder data — live wiring lands in a follow-up." + "operations_console_status_dummy": "Showing placeholder data — live wiring lands in a follow-up.", + "resources_capability_error": "Could not load the FHIR server capability statement. Check that the FHIR API is reachable and try again.", + "resources_subtitle": "Resource types declared by the FHIR server, with instance counts.", + "resources_table_count": "Count", + "resources_table_type": "Resource type", + "resources_title": "Resources" } diff --git a/src/Ignis.Web/app/i18n/messages/nb.json b/src/Ignis.Web/app/i18n/messages/nb.json index 63d346a..89a6b5f 100644 --- a/src/Ignis.Web/app/i18n/messages/nb.json +++ b/src/Ignis.Web/app/i18n/messages/nb.json @@ -21,5 +21,10 @@ "operations_title": "Operasjoner", "operations_subtitle": "Sanntidsfremdrift for langvarige operasjoner på serveren.", "operations_console_title": "Operasjonslogg", - "operations_console_status_dummy": "Viser plassholder-data — ekte tilkobling kommer i en oppfølger." + "operations_console_status_dummy": "Viser plassholder-data — ekte tilkobling kommer i en oppfølger.", + "resources_capability_error": "Kunne ikke hente capability statement fra FHIR-serveren. Sjekk at FHIR-API-et er tilgjengelig og prøv igjen.", + "resources_subtitle": "Ressurstyper som FHIR-serveren tilbyr, med antall instanser.", + "resources_table_count": "Antall", + "resources_table_type": "Ressurstype", + "resources_title": "Ressurser" } diff --git a/src/Ignis.Web/app/routes.ts b/src/Ignis.Web/app/routes.ts index eb04afb..49fa10f 100644 --- a/src/Ignis.Web/app/routes.ts +++ b/src/Ignis.Web/app/routes.ts @@ -14,6 +14,7 @@ export default [ index("routes/home.tsx"), route("admin", "features/admin/routes/index.tsx"), route("admin/operations", "features/operations/routes/index.tsx"), + route("resources", "features/resources-ui/routes/index.tsx"), ]), route("healthz", "routes/healthz.ts"), route("auth/login", "features/auth/routes/login.tsx"),