Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/server/web-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/Ignis.Web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions src/Ignis.Web/app/features/resources-ui/config.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026, Incendi <info@incendi.no>
*
* 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 });
}
109 changes: 109 additions & 0 deletions src/Ignis.Web/app/features/resources-ui/fhir-client.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2026, Incendi <info@incendi.no>
*
* 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<string, string> = {
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<string[] | null> {
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<number | null> {
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`);
Comment thread
losolio marked this conversation as resolved.
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;
}
}
107 changes: 107 additions & 0 deletions src/Ignis.Web/app/features/resources-ui/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026, Incendi <info@incendi.no>
*
* 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<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array<R>(items.length);
let next = 0;
async function worker(): Promise<void> {
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 (
<Container as="main">
<Stack direction="vertical" gap="lg">
<Stack direction="vertical" gap="sm">
<Heading as="h1">{m.resources_title()}</Heading>
<Text>{m.resources_subtitle()}</Text>
</Stack>

{loaderData.ok ? (
<Table>
<Table.Header>
<Table.Row>
<Table.HeadCell>{m.resources_table_type()}</Table.HeadCell>
<Table.HeadCell>{m.resources_table_count()}</Table.HeadCell>
</Table.Row>
</Table.Header>
<Table.Body>
{loaderData.rows.map((row) => (
<Table.Row key={row.type}>
<Table.Cell>{row.type}</Table.Cell>
<Table.Cell>{row.count ?? "—"}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<Panel variant="alert" status="error">
<Text>{m.resources_capability_error()}</Text>
</Panel>
)}
</Stack>
</Container>
);
}
7 changes: 6 additions & 1 deletion src/Ignis.Web/app/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
7 changes: 6 additions & 1 deletion src/Ignis.Web/app/i18n/messages/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
1 change: 1 addition & 0 deletions src/Ignis.Web/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down