Compare commits

...

4 commits

7 changed files with 745 additions and 21 deletions

View file

@ -1,6 +1,8 @@
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { AppStateView } from "./App";
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import { AppStateView, dashboardHydrationTiles } from "./App";
describe("React app dashboard state view", () => {
test("renders loading dashboard state", () => {
@ -41,4 +43,43 @@ describe("React app dashboard state view", () => {
expect(html).toContain("Dark");
expect(html).toContain("Light");
});
test("renders the dashboard shell while individual items hydrate", () => {
const html = renderToString(
<AppStateView
dashboard={{
state: "ready",
document: genericDashboardFixture,
schemaVersion: "dashboard.v1",
currentRevisionId: "revision-1234567890",
}}
hydratingItemIds={new Set([
"telemetry:service-uptime",
"service:core-services:identity",
"module:ambient",
"status:runtime:status",
])}
/>,
);
expect(html).toContain("Operations Console");
expect(html).toContain("Service Uptime");
expect(html).toContain("Identity");
expect(html).toContain("Environment");
expect(html).not.toContain("Loading Dashboard");
expect(html).toContain(
'data-severity="loading" data-model-id="service-uptime"',
);
expect(html).toContain('data-severity="loading" data-model-id="identity"');
expect(html).toContain('data-severity="loading" data-model-id="ambient"');
expect(html).toContain('data-severity="loading" data-model-id="runtime:status"');
});
test("hydrates every status cell from the Dimension Lab shell", () => {
expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({
kind: "status",
stripId: "footer-status",
id: "auto-refresh",
});
});
});

View file

@ -1,4 +1,11 @@
import { useEffect, useState } from "react";
import type {
DashboardDocument,
DashboardModule,
ServiceEntry,
StatusItem,
TelemetryCard,
} from "@dimensionlab/dashboard-model";
import type { DashboardRuntimeState } from "$lib/server/dashboard";
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
import {
@ -9,21 +16,35 @@ import {
resolveInitialUiTheme,
type UiTheme,
type UiSeverity,
type UiDashboardPreview,
} from "@dimensionlab/ui";
const loadingDashboardState: DashboardRuntimeState = {
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
};
type DashboardTileReference =
| { kind: "telemetry"; id: string }
| { kind: "service"; groupId: string; id: string }
| { kind: "module"; id: string }
| { kind: "status"; stripId: string; id: string };
type DashboardTileResponse =
| {
state: "ready";
tile: DashboardTileReference;
item: DashboardModule | ServiceEntry | StatusItem | TelemetryCard;
}
| {
state: "not_found";
tile: DashboardTileReference;
message: string;
};
export function AppStateView({
dashboard,
onThemeChange,
theme,
hydratingItemIds,
}: {
dashboard: DashboardRuntimeState;
hydratingItemIds?: ReadonlySet<string>;
onThemeChange?: (theme: UiTheme) => void;
theme?: UiTheme;
}) {
@ -33,9 +54,14 @@ export function AppStateView({
) : null;
if (dashboard.state === "ready") {
const uiDashboard = markHydratingItems(
dashboardDocumentToUiDashboard(dashboard.document),
hydratingItemIds,
);
return (
<DashboardFrame
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
dashboard={uiDashboard}
actions={themeToggle}
/>
);
@ -89,7 +115,10 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
export default function App() {
const [dashboard, setDashboard] =
useState<DashboardRuntimeState>(loadingDashboardState);
useState<DashboardRuntimeState | null>(null);
const [hydratingItemIds, setHydratingItemIds] = useState<Set<string>>(
() => new Set(),
);
const [theme, setTheme] = useState<UiTheme>(() => {
if (typeof window === "undefined") return "dark";
@ -97,6 +126,8 @@ export default function App() {
});
useEffect(() => {
if (!dashboard) return;
const metadata = resolveDocumentMetadata(dashboard);
document.title = metadata.title;
@ -119,6 +150,7 @@ export default function App() {
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;
let hydrationRun = 0;
async function loadDashboard() {
const response = await fetch("/api/dashboard");
@ -127,6 +159,18 @@ export default function App() {
if (cancelled) return;
setDashboard(nextDashboard);
const currentRun = ++hydrationRun;
if (
nextDashboard.state === "ready" &&
nextDashboard.liveDatasourceHydration?.enabled !== false
) {
const tiles = dashboardHydrationTiles(nextDashboard.document);
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
hydrateDashboardTiles(tiles, currentRun);
} else {
setHydratingItemIds(new Set());
}
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = undefined;
@ -145,6 +189,54 @@ export default function App() {
}
}
function hydrateDashboardTiles(
tiles: DashboardTileReference[],
run: number,
) {
tiles.forEach((tile) => {
void hydrateDashboardTile(tile, run);
});
}
async function hydrateDashboardTile(
tile: DashboardTileReference,
run: number,
) {
const key = dashboardTileKey(tile);
try {
const response = await fetch(dashboardTileUrl(tile));
const tileResponse = (await response.json()) as DashboardTileResponse;
if (
cancelled ||
run !== hydrationRun ||
!response.ok ||
tileResponse.state !== "ready"
) {
return;
}
const readyTileResponse = tileResponse;
setDashboard((current) =>
current?.state === "ready"
? {
...current,
document: applyDashboardTile(current.document, readyTileResponse),
}
: current,
);
} finally {
if (!cancelled && run === hydrationRun) {
setHydratingItemIds((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
}
void loadDashboard();
return () => {
@ -153,13 +245,14 @@ export default function App() {
};
}, []);
return (
return dashboard ? (
<AppStateView
dashboard={dashboard}
hydratingItemIds={hydratingItemIds}
theme={theme}
onThemeChange={setTheme}
/>
);
) : null;
}
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
@ -181,3 +274,154 @@ function getThemeStorage(): Storage | undefined {
return undefined;
}
}
function markHydratingItems(
dashboard: UiDashboardPreview,
hydratingItemIds?: ReadonlySet<string>,
): UiDashboardPreview {
if (!hydratingItemIds?.size) return dashboard;
return {
...dashboard,
telemetry: dashboard.telemetry.map((card) =>
hydratingItemIds.has(`telemetry:${card.id}`)
? {
...card,
severity: "loading",
detail: "loading live telemetry",
}
: card,
),
serviceGroups: dashboard.serviceGroups.map((group) => ({
...group,
services: group.services.map((service) =>
hydratingItemIds.has(`service:${group.id}:${service.id}`)
? {
...service,
severity: "loading",
detail: "loading",
}
: service,
),
})),
modules: dashboard.modules.map((module) =>
hydratingItemIds.has(`module:${module.id}`)
? {
...module,
severity: "loading",
detail: "loading live data",
}
: module,
),
statusItems: dashboard.statusItems.map((item) =>
hydratingItemIds.has(`status:${item.id}`)
? {
...item,
severity: "loading",
value: "loading",
}
: item,
),
};
}
export function dashboardHydrationTiles(
document: DashboardDocument,
): DashboardTileReference[] {
const telemetry = document.telemetry
.filter((card) => card.datasource?.type === "external")
.map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id }));
const services = document.serviceGroups.flatMap((group) =>
group.services
.filter((service) => service.datasource?.type === "external")
.map((service): DashboardTileReference => ({
kind: "service",
groupId: group.id,
id: service.id,
})),
);
const modules = (document.modules || [])
.filter((module) =>
module.datasource?.type === "external" ||
module.id === "runtime-health-summary"
)
.map((module): DashboardTileReference => ({ kind: "module", id: module.id }));
const status = document.statusStrips.flatMap((strip) =>
strip.items
.map((item): DashboardTileReference => ({
kind: "status",
stripId: strip.id,
id: item.id,
})),
);
return [...telemetry, ...services, ...modules, ...status];
}
function dashboardTileKey(tile: DashboardTileReference): string {
if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
return `${tile.kind}:${tile.id}`;
}
function dashboardTileUrl(tile: DashboardTileReference): string {
const parts = tile.kind === "status"
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
: tile.kind === "service"
? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id]
: ["api", "dashboard", "tile", tile.kind, tile.id];
return `/${parts.map(encodeURIComponent).join("/")}`;
}
function applyDashboardTile(
document: DashboardDocument,
response: Extract<DashboardTileResponse, { state: "ready" }>,
): DashboardDocument {
if (response.tile.kind === "telemetry") {
return {
...document,
telemetry: document.telemetry.map((card) =>
card.id === response.tile.id ? response.item as TelemetryCard : card,
),
};
}
if (response.tile.kind === "service") {
const tile = response.tile;
return {
...document,
serviceGroups: document.serviceGroups.map((group) => ({
...group,
services: group.id === tile.groupId
? group.services.map((service) =>
service.id === tile.id ? response.item as ServiceEntry : service,
)
: group.services,
})),
};
}
if (response.tile.kind === "module") {
return {
...document,
modules: (document.modules || []).map((module) =>
module.id === response.tile.id ? response.item as DashboardModule : module,
),
};
}
const tile = response.tile;
return {
...document,
statusStrips: document.statusStrips.map((strip) =>
strip.id === tile.stripId
? {
...strip,
items: strip.items.map((item) =>
item.id === tile.id ? response.item as StatusItem : item,
),
}
: strip,
),
};
}

View file

@ -18,6 +18,9 @@ export interface DashboardRuntimeReady {
document: DashboardDocument;
schemaVersion: string;
currentRevisionId: string;
liveDatasourceHydration?: {
enabled: boolean;
};
}
export interface DashboardRuntimeEmpty {

View file

@ -10,6 +10,35 @@ import type {
TelemetryCard,
} from "@dimensionlab/dashboard-model";
export type DashboardTileReference =
| { kind: "telemetry"; id: string }
| { kind: "service"; groupId: string; id: string }
| { kind: "module"; id: string }
| { kind: "status"; stripId: string; id: string };
export type DashboardTileItem =
| DashboardModule
| ServiceEntry
| StatusItem
| TelemetryCard;
export type DashboardTileResolution =
| {
state: "ready";
tile: DashboardTileReference;
item: DashboardTileItem;
}
| {
state: "not_found";
tile: DashboardTileReference;
message: string;
}
| {
state: "disabled";
tile: DashboardTileReference;
message: string;
};
export interface DatasourceResolutionOptions {
fetch?: DatasourceFetch;
prometheusBaseUrl?: string;
@ -46,6 +75,69 @@ export async function resolveDashboardDatasources(
};
}
export async function resolveDashboardTile(
document: DashboardDocument,
tile: DashboardTileReference,
options: DatasourceResolutionOptions = {},
): Promise<DashboardTileResolution> {
const context = datasourceContext(options);
if (tile.kind === "telemetry") {
const card = document.telemetry.find((item) => item.id === tile.id);
if (!card) return missingTile(tile);
return {
state: "ready",
tile,
item: await resolveTelemetryCard(card, context),
};
}
if (tile.kind === "service") {
const service = document.serviceGroups
.find((group) => group.id === tile.groupId)
?.services.find((item) => item.id === tile.id);
if (!service) return missingTile(tile);
return {
state: "ready",
tile,
item: await resolveService(service, context),
};
}
if (tile.kind === "module") {
const module = document.modules?.find((item) => item.id === tile.id);
if (!module) return missingTile(tile);
const item = module.id === "runtime-health-summary"
? runtimeHealthSummary(
module,
await Promise.all(
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
),
)
: await resolveModule(module, context);
return { state: "ready", tile, item };
}
const strip = document.statusStrips.find((item) => item.id === tile.stripId);
const statusItem = strip?.items.find((item) => item.id === tile.id);
if (!strip || !statusItem) return missingTile(tile);
return {
state: "ready",
tile,
item: await resolveStatusTile(
statusItem,
document.metadata.refreshIntervalSeconds,
document.serviceGroups,
context,
),
};
}
interface DatasourceContext {
fetch: DatasourceFetch;
prometheusBaseUrl: string;
@ -390,6 +482,68 @@ function resolveStatusItem(
return structuredClone(item);
}
async function resolveStatusTile(
item: StatusItem,
refreshIntervalSeconds: number | undefined,
serviceGroups: ServiceGroup[],
context: DatasourceContext,
): Promise<StatusItem> {
if (item.id === "system-status") {
const resolvedGroups = await Promise.all(
serviceGroups.map((group) => resolveServiceGroup(group, context)),
);
const health = serviceHealthSummary(resolvedGroups);
return {
...structuredClone(item),
value: health.value,
severity: health.severity,
};
}
if (item.id === "last-sync") {
return {
...structuredClone(item),
value: "just now",
severity: "ok",
};
}
if (item.id === "uptime") {
const uptime = await prometheusScalar(
'time() - node_boot_time_seconds{job="node",host="linux-infra"}',
context,
).catch(() => null);
return uptime === null
? structuredClone(item)
: {
...structuredClone(item),
value: formatDuration(uptime),
severity: "ok",
};
}
if (item.id === "load-avg") {
const loadAverage = await prometheusLoadAverage(context).catch(() => null);
return loadAverage
? {
...structuredClone(item),
value: loadAverage,
severity: "neutral",
}
: structuredClone(item);
}
if (item.id === "auto-refresh" && refreshIntervalSeconds) {
return {
...structuredClone(item),
value: `${refreshIntervalSeconds}s`,
severity: "neutral",
};
}
return structuredClone(item);
}
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
severity: Severity;
value: string;
@ -673,3 +827,17 @@ function formatDuration(totalSeconds: number): string {
const minutes = Math.floor((seconds % 3_600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
function missingTile(tile: DashboardTileReference): DashboardTileResolution {
return {
state: "not_found",
tile,
message: `Dashboard tile not found: ${tileKey(tile)}`,
};
}
function tileKey(tile: DashboardTileReference): string {
if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
return `${tile.kind}:${tile.id}`;
}

View file

@ -1,6 +1,6 @@
import { extname, normalize } from "node:path";
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
import { handleDashboardRoute } from "./routes/dashboard";
import { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard";
const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || 3000);
@ -23,6 +23,11 @@ export async function handleRequest(request: Request): Promise<Response> {
return handleDashboardRoute();
}
if (url.pathname.startsWith("/api/dashboard/tile/")) {
if (request.method !== "GET") return methodNotAllowed(["GET"]);
return handleDashboardTileRoute(url.pathname);
}
if (url.pathname === "/api/agent/dashboard") {
if (request.method !== "POST") return methodNotAllowed(["POST"]);
return handleAgentDashboardRoute(request);

View file

@ -1,11 +1,14 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import { loadDashboardResponse } from "./dashboard";
import { loadDashboardResponse, loadDashboardTileResponse } from "./dashboard";
describe("dashboard API route", () => {
test("returns ready dashboard runtime state from the existing model loader", async () => {
test("returns the ready dashboard shell without hydrating live datasources", async () => {
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("live datasource fetch should not run for the shell response"),
);
const response = await loadDashboardResponse({
disableLiveDatasources: true,
refreshSeedDocument: true,
seedIfEmpty: true,
});
@ -15,5 +18,164 @@ describe("dashboard API route", () => {
expect(response.document.metadata.title).toBe(
dimensionLabDashboardFixture.metadata.title,
);
expect(fetch).not.toHaveBeenCalled();
fetch.mockRestore();
});
test("reports when client-side live hydration is disabled", async () => {
const previous = process.env.DISABLE_LIVE_DATASOURCES;
process.env.DISABLE_LIVE_DATASOURCES = "1";
try {
const response = await loadDashboardResponse({
refreshSeedDocument: true,
seedIfEmpty: true,
});
expect(response.state).toBe("ready");
if (response.state !== "ready") throw new Error("expected ready dashboard");
expect(response.liveDatasourceHydration).toEqual({ enabled: false });
} finally {
if (previous === undefined) {
delete process.env.DISABLE_LIVE_DATASOURCES;
} else {
process.env.DISABLE_LIVE_DATASOURCES = previous;
}
}
});
test("hydrates a telemetry tile independently from the dashboard shell", async () => {
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("https://prometheus.example/api/v1/query_range")) {
return jsonResponse({
status: "success",
data: {
result: [
{
metric: { host: "linux-infra" },
values: [
[1771430000, "10"],
[1771430060, "20"],
[1771430120, "42"],
],
},
],
},
});
}
if (url.startsWith("https://prometheus.example/api/v1/query")) {
return jsonResponse({
status: "success",
data: {
result: [
{
metric: { host: "linux-infra" },
value: [1771430400, "42"],
},
],
},
});
}
throw new Error(`Unhandled test request: ${url}`);
});
const response = await loadDashboardTileResponse(
{ kind: "telemetry", id: "infra-ram" },
{
fetch,
prometheusBaseUrl: "https://prometheus.example",
refreshSeedDocument: true,
seedIfEmpty: true,
},
);
expect(response.state).toBe("ready");
if (response.state !== "ready") throw new Error("expected ready tile");
expect(response.tile).toEqual({ kind: "telemetry", id: "infra-ram" });
expect(response.item).toMatchObject({
id: "infra-ram",
value: { kind: "percent", value: 42 },
severity: "ok",
detail: "linux-infra",
sparkline: [10, 20, 42],
});
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/query?"),
expect.objectContaining({ cache: "no-store" }),
);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/query_range?"),
expect.objectContaining({ cache: "no-store" }),
);
});
test("hydrates a service tile with its service group identity", async () => {
const fetch = vi.fn(async () =>
jsonResponse({
status: "UP",
ping: 42,
}),
);
const response = await loadDashboardTileResponse(
{ kind: "service", groupId: "essentials", id: "vaultwarden" },
{
fetch,
refreshSeedDocument: true,
seedIfEmpty: true,
},
);
expect(response.state).toBe("ready");
if (response.state !== "ready") throw new Error("expected ready tile");
expect(response.tile).toEqual({
kind: "service",
groupId: "essentials",
id: "vaultwarden",
});
expect(response.item).toMatchObject({
id: "vaultwarden",
severity: "ok",
detail: "42 ms",
});
});
test("does not hydrate tile routes when live datasources are disabled", async () => {
const previous = process.env.DISABLE_LIVE_DATASOURCES;
process.env.DISABLE_LIVE_DATASOURCES = "1";
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("live datasource fetch should not run when disabled"),
);
try {
const response = await loadDashboardTileResponse(
{ kind: "telemetry", id: "infra-ram" },
{
refreshSeedDocument: true,
seedIfEmpty: true,
},
);
expect(response.state).toBe("disabled");
expect(fetch).not.toHaveBeenCalled();
} finally {
fetch.mockRestore();
if (previous === undefined) {
delete process.env.DISABLE_LIVE_DATASOURCES;
} else {
process.env.DISABLE_LIVE_DATASOURCES = previous;
}
}
});
});
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
headers: { "content-type": "application/json" },
});
}

View file

@ -3,19 +3,30 @@ import {
type DashboardRuntimeOptions,
type DashboardRuntimeState,
} from "$lib/server/dashboard";
import { resolveDashboardDatasources } from "$lib/server/datasources";
import {
resolveDashboardDatasources,
resolveDashboardTile,
type DashboardTileReference,
type DashboardTileResolution,
type DatasourceResolutionOptions,
} from "$lib/server/datasources";
export interface LoadDashboardResponseOptions
extends Pick<
DashboardRuntimeOptions,
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
> {
>,
DatasourceResolutionOptions {
disableLiveDatasources?: boolean;
hydrateLiveDatasources?: boolean;
}
export async function loadDashboardResponse(
options: LoadDashboardResponseOptions = {},
): Promise<DashboardRuntimeState> {
const liveHydrationEnabled =
!options.disableLiveDatasources &&
process.env.DISABLE_LIVE_DATASOURCES !== "1";
const dashboard = loadDashboardRuntime(undefined, {
refreshSeedDocument: options.refreshSeedDocument ?? true,
seedIfEmpty: options.seedIfEmpty ?? true,
@ -26,16 +37,106 @@ export async function loadDashboardResponse(
return dashboard;
}
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
return dashboard;
if (
!options.hydrateLiveDatasources ||
options.disableLiveDatasources ||
process.env.DISABLE_LIVE_DATASOURCES === "1"
) {
return {
...dashboard,
liveDatasourceHydration: {
enabled: liveHydrationEnabled,
},
};
}
return {
...dashboard,
document: await resolveDashboardDatasources(dashboard.document),
document: await resolveDashboardDatasources(dashboard.document, options),
liveDatasourceHydration: {
enabled: false,
},
};
}
export async function loadDashboardTileResponse(
tile: DashboardTileReference,
options: LoadDashboardResponseOptions = {},
): Promise<DashboardTileResolution> {
if (
options.disableLiveDatasources ||
process.env.DISABLE_LIVE_DATASOURCES === "1"
) {
return {
state: "disabled",
tile,
message: "Live datasource hydration is disabled.",
};
}
const dashboard = loadDashboardRuntime(undefined, {
refreshSeedDocument: options.refreshSeedDocument ?? true,
seedIfEmpty: options.seedIfEmpty ?? true,
seedDocument: options.seedDocument,
});
if (dashboard.state !== "ready") {
return {
state: "not_found",
tile,
message: `Dashboard is not ready: ${dashboard.state}`,
};
}
return resolveDashboardTile(dashboard.document, tile, options);
}
export async function handleDashboardRoute(): Promise<Response> {
return Response.json(await loadDashboardResponse());
}
export async function handleDashboardTileRoute(pathname: string): Promise<Response> {
const tile = parseDashboardTilePath(pathname);
if (!tile) {
return Response.json(
{ ok: false, message: "Invalid dashboard tile route" },
{ status: 404 },
);
}
const response = await loadDashboardTileResponse(tile);
return Response.json(response, {
status: response.state === "not_found" ? 404 : 200,
});
}
function parseDashboardTilePath(pathname: string): DashboardTileReference | null {
const parts = pathname.split("/").filter(Boolean);
const [, dashboard, tileRoot, kind, firstId, secondId] = parts;
if (dashboard !== "dashboard" || tileRoot !== "tile" || !kind || !firstId) {
return null;
}
const id = decodeURIComponent(firstId);
if (kind === "telemetry" || kind === "module") {
return { kind, id };
}
if (kind === "service" && secondId) {
return {
kind,
groupId: id,
id: decodeURIComponent(secondId),
};
}
if (kind === "status" && secondId) {
return {
kind,
stripId: id,
id: decodeURIComponent(secondId),
};
}
return null;
}