feat(housekeeping): compose operational inbox
This commit is contained in:
1 parent
bcb0ca977f
commit
300ac0ef95
13 files changed
+1594
-7
No files matched your search
@@ -0,0 +1,275 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||
import type {
|
||||
SystemHealthSnapshot,
|
||||
SystemObservationRow,
|
||||
} from "./queries/observability";
|
||||
import { systemObservabilityQuery } from "./queries/observability";
|
||||
import type { SystemAlertRow } from "./queries/operations";
|
||||
import { systemOperationsQuery } from "./queries/operations";
|
||||
|
||||
export const SYSTEM_INBOX_SOURCE_IDS = [
|
||||
"system.alerts",
|
||||
"system.emulator-errors",
|
||||
"system.operational-anomalies",
|
||||
] as const;
|
||||
|
||||
export interface SystemInboxAdapters {
|
||||
readonly alerts: (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly SystemAlertRow[]>;
|
||||
readonly errors: (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly SystemObservationRow[]>;
|
||||
readonly health: (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<SystemHealthSnapshot>;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
type SystemInboxLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly HousekeepingWorkItem[]>;
|
||||
|
||||
function createSource(
|
||||
id: (typeof SYSTEM_INBOX_SOURCE_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: SystemInboxLoader,
|
||||
): HousekeepingInboxSource {
|
||||
return {
|
||||
id,
|
||||
owner: "system",
|
||||
capability,
|
||||
async getItems(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const items = await load(context, signal);
|
||||
return ok(
|
||||
{
|
||||
availability: "available" as const,
|
||||
items: items
|
||||
.filter(
|
||||
(item) =>
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability),
|
||||
)
|
||||
.slice(0, 25),
|
||||
},
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dateValue(
|
||||
value: string | number | Date | null | undefined,
|
||||
): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value : null;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
const millis = Math.abs(value) < 10_000_000_000 ? value * 1_000 : value;
|
||||
const date = new Date(millis);
|
||||
return Number.isFinite(date.getTime()) ? date : null;
|
||||
}
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
if (/^\d+$/u.test(value.trim())) return dateValue(Number(value));
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date : null;
|
||||
}
|
||||
|
||||
function temporal(date: Date, now: Date) {
|
||||
const ageMs = Math.max(0, now.getTime() - date.getTime());
|
||||
return {
|
||||
occurredAt: date.toISOString(),
|
||||
ageMs,
|
||||
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
|
||||
};
|
||||
}
|
||||
|
||||
function alertPriority(severity: string) {
|
||||
const normalized = severity.toLocaleLowerCase();
|
||||
if (["critical", "fatal", "error"].includes(normalized)) {
|
||||
return { severity: "critical" as const, priority: "critical" as const };
|
||||
}
|
||||
if (["warning", "warn"].includes(normalized)) {
|
||||
return { severity: "warning" as const, priority: "high" as const };
|
||||
}
|
||||
return { severity: "info" as const, priority: "normal" as const };
|
||||
}
|
||||
|
||||
export function createSystemInboxSources(
|
||||
adapters: SystemInboxAdapters,
|
||||
): readonly HousekeepingInboxSource[] {
|
||||
const now = adapters.now ?? (() => new Date());
|
||||
const alertsCapability = anyCapability(PERMS.NOTIFICATIONS_VIEW);
|
||||
const devopsCapability = anyCapability(PERMS.DEVOPS_VIEW);
|
||||
return [
|
||||
createSource("system.alerts", alertsCapability, async (context, signal) => {
|
||||
if (signal.aborted) throw new Error("aborted System alerts");
|
||||
const current = now();
|
||||
return (await adapters.alerts(context, signal)).flatMap((alert) => {
|
||||
if (alert.isRead) return [];
|
||||
const date = dateValue(alert.createdAt);
|
||||
if (!date) return [];
|
||||
return [
|
||||
{
|
||||
sourceId: "system.alerts",
|
||||
itemId: String(alert.id),
|
||||
deduplicationKey: `system.alert:${alert.id}`,
|
||||
domain: "system" as const,
|
||||
capability: alertsCapability,
|
||||
...alertPriority(alert.severity),
|
||||
...temporal(date, current),
|
||||
state: "unread",
|
||||
titleKey: "pages.housekeeping.items.systemAlert",
|
||||
context: { type: alert.type, message: alert.message },
|
||||
href: "/ase/system/operations/alerts" as const,
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
}),
|
||||
createSource(
|
||||
"system.emulator-errors",
|
||||
devopsCapability,
|
||||
async (context, signal) => {
|
||||
if (signal.aborted) throw new Error("aborted emulator errors");
|
||||
const current = now();
|
||||
return (await adapters.errors(context, signal)).flatMap((error) => {
|
||||
const date = dateValue(error.timestamp);
|
||||
if (!date) return [];
|
||||
return [
|
||||
{
|
||||
sourceId: "system.emulator-errors",
|
||||
itemId: error.id,
|
||||
deduplicationKey: `system.emulator-error:${error.id}`,
|
||||
domain: "system" as const,
|
||||
capability: devopsCapability,
|
||||
severity: "critical" as const,
|
||||
priority: "high" as const,
|
||||
...temporal(date, current),
|
||||
state: "open",
|
||||
titleKey: "pages.housekeeping.items.emulatorError",
|
||||
context: {
|
||||
title: error.primary,
|
||||
detail: error.secondary,
|
||||
},
|
||||
href: "/ase/system/observability/devops/errors" as const,
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
),
|
||||
createSource(
|
||||
"system.operational-anomalies",
|
||||
devopsCapability,
|
||||
async (context, signal) => {
|
||||
if (signal.aborted) throw new Error("aborted operational health");
|
||||
const health = await adapters.health(context, signal);
|
||||
const current = now();
|
||||
const anomalies = [
|
||||
{
|
||||
id: "database",
|
||||
active: !health.dbOk,
|
||||
severity: "critical" as const,
|
||||
priority: "critical" as const,
|
||||
context: { latencyMs: health.dbLatencyMs },
|
||||
},
|
||||
{
|
||||
id: "redis",
|
||||
active: health.redisOk === false,
|
||||
severity: "warning" as const,
|
||||
priority: "high" as const,
|
||||
context: {},
|
||||
},
|
||||
{
|
||||
id: "emulator",
|
||||
active: !health.emulatorOk,
|
||||
severity: "critical" as const,
|
||||
priority: "critical" as const,
|
||||
context: { onlineUsers: health.onlineUsers },
|
||||
},
|
||||
];
|
||||
return anomalies
|
||||
.filter((anomaly) => anomaly.active)
|
||||
.map((anomaly) => ({
|
||||
sourceId: "system.operational-anomalies",
|
||||
itemId: anomaly.id,
|
||||
deduplicationKey: `system.operational-anomaly:${anomaly.id}`,
|
||||
domain: "system" as const,
|
||||
capability: devopsCapability,
|
||||
severity: anomaly.severity,
|
||||
priority: anomaly.priority,
|
||||
ageMs: 0,
|
||||
state: "degraded",
|
||||
occurredAt: current.toISOString(),
|
||||
titleKey: `pages.housekeeping.items.${anomaly.id}Anomaly`,
|
||||
context: anomaly.context,
|
||||
href: "/ase/system/observability/devops" as const,
|
||||
freshness: "fresh" as const,
|
||||
actions: [],
|
||||
}));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const SYSTEM_INBOX_SOURCES = createSystemInboxSources({
|
||||
async alerts(context, signal) {
|
||||
if (signal.aborted) throw new Error("aborted System alerts");
|
||||
const result = await systemOperationsQuery.run(context, {
|
||||
routeId: "system.operations.alerts",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "alerts") {
|
||||
throw new Error("System alerts unavailable");
|
||||
}
|
||||
return result.data.alerts;
|
||||
},
|
||||
async errors(context, signal) {
|
||||
if (signal.aborted) throw new Error("aborted emulator errors");
|
||||
const result = await systemObservabilityQuery.run(context, {
|
||||
routeId: "system.observability.devops-errors",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "devops-errors") {
|
||||
throw new Error("System emulator errors unavailable");
|
||||
}
|
||||
return result.data.errors;
|
||||
},
|
||||
async health(context, signal) {
|
||||
if (signal.aborted) throw new Error("aborted operational health");
|
||||
const result = await systemObservabilityQuery.run(context, {
|
||||
routeId: "system.observability.devops",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "devops" || !result.data.health) {
|
||||
throw new Error("System health unavailable");
|
||||
}
|
||||
return result.data.health;
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../../foundation/contracts";
|
||||
import { SYSTEM_INBOX_SOURCES } from "./inbox";
|
||||
import { SYSTEM_ROUTES } from "./routes";
|
||||
import { SYSTEM_SEARCH_PROVIDERS } from "./search";
|
||||
import { SYSTEM_WIDGETS } from "./widgets";
|
||||
|
||||
export const systemManifest = {
|
||||
id: "system",
|
||||
@@ -23,7 +26,7 @@ export const systemManifest = {
|
||||
PERMS.NOTIFICATIONS_EDIT,
|
||||
),
|
||||
routes: SYSTEM_ROUTES,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
searchProviders: SYSTEM_SEARCH_PROVIDERS,
|
||||
inboxSources: SYSTEM_INBOX_SOURCES,
|
||||
widgets: SYSTEM_WIDGETS,
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -0,0 +1,148 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingSearchProvider,
|
||||
type HousekeepingSearchResult,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import type { SystemObservationRow } from "./queries/observability";
|
||||
import { systemObservabilityQuery } from "./queries/observability";
|
||||
import type { SystemAlertRow } from "./queries/operations";
|
||||
import { systemOperationsQuery } from "./queries/operations";
|
||||
|
||||
export const SYSTEM_SEARCH_PROVIDER_IDS = [
|
||||
"system.alerts",
|
||||
"system.emulator-errors",
|
||||
] as const;
|
||||
|
||||
export interface SystemSearchAdapters {
|
||||
readonly alerts: (
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
) => Promise<readonly SystemAlertRow[]>;
|
||||
readonly errors: (
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
) => Promise<readonly SystemObservationRow[]>;
|
||||
}
|
||||
|
||||
function boundedLimit(limit: number): number {
|
||||
return Number.isFinite(limit)
|
||||
? Math.min(25, Math.max(1, Math.trunc(limit)))
|
||||
: 25;
|
||||
}
|
||||
|
||||
function normalizedTerm(term: string): string {
|
||||
return term.normalize("NFC").trim().slice(0, 128);
|
||||
}
|
||||
|
||||
function createProvider<T>(
|
||||
id: (typeof SYSTEM_SEARCH_PROVIDER_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: (
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
) => Promise<readonly T[]>,
|
||||
map: (row: T) => HousekeepingSearchResult,
|
||||
): HousekeepingSearchProvider {
|
||||
return {
|
||||
id,
|
||||
owner: "system",
|
||||
capability,
|
||||
async search(context, input) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const term = normalizedTerm(input.term);
|
||||
const limit = boundedLimit(input.limit);
|
||||
try {
|
||||
const rows = await load(context, term, limit);
|
||||
return ok(rows.slice(0, limit).map(map), authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSystemSearchProviders(
|
||||
adapters: SystemSearchAdapters,
|
||||
): readonly HousekeepingSearchProvider[] {
|
||||
return [
|
||||
createProvider(
|
||||
"system.alerts",
|
||||
anyCapability(PERMS.NOTIFICATIONS_VIEW),
|
||||
adapters.alerts,
|
||||
(row) => ({
|
||||
id: `alert-${row.id}`,
|
||||
domain: "system",
|
||||
type: "entity",
|
||||
title: row.message,
|
||||
description: `${row.severity} · ${row.type}`,
|
||||
href: "/ase/system/operations/alerts",
|
||||
capability: anyCapability(PERMS.NOTIFICATIONS_VIEW),
|
||||
}),
|
||||
),
|
||||
createProvider(
|
||||
"system.emulator-errors",
|
||||
anyCapability(PERMS.DEVOPS_VIEW),
|
||||
adapters.errors,
|
||||
(row) => ({
|
||||
id: `emulator-error-${row.id}`,
|
||||
domain: "system",
|
||||
type: "entity",
|
||||
title: row.primary,
|
||||
description: row.secondary,
|
||||
href: "/ase/system/observability/devops/errors",
|
||||
capability: anyCapability(PERMS.DEVOPS_VIEW),
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function includesTerm(term: string, ...values: readonly unknown[]): boolean {
|
||||
const needle = term.toLocaleLowerCase();
|
||||
if (needle.length === 0) return true;
|
||||
return values
|
||||
.map((value) => String(value ?? ""))
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle);
|
||||
}
|
||||
|
||||
export const SYSTEM_SEARCH_PROVIDERS = createSystemSearchProviders({
|
||||
async alerts(context, term, limit) {
|
||||
const result = await systemOperationsQuery.run(context, {
|
||||
routeId: "system.operations.alerts",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "alerts") {
|
||||
throw new Error("System alerts unavailable");
|
||||
}
|
||||
return result.data.alerts
|
||||
.filter((row) => includesTerm(term, row.message, row.type, row.severity))
|
||||
.slice(0, limit);
|
||||
},
|
||||
async errors(context, term, limit) {
|
||||
const result = await systemObservabilityQuery.run(context, {
|
||||
routeId: "system.observability.devops-errors",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "devops-errors") {
|
||||
throw new Error("System emulator errors unavailable");
|
||||
}
|
||||
return result.data.errors
|
||||
.filter((row) => includesTerm(term, row.primary, row.secondary))
|
||||
.slice(0, limit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { createSystemInboxSources, SYSTEM_INBOX_SOURCE_IDS } from "./inbox";
|
||||
import {
|
||||
createSystemSearchProviders,
|
||||
SYSTEM_SEARCH_PROVIDER_IDS,
|
||||
} from "./search";
|
||||
import { createSystemWidgets, SYSTEM_WIDGET_IDS } from "./widgets";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("System providers", () => {
|
||||
it("registers capability-filtered alert and emulator-error search", async () => {
|
||||
const alerts = vi.fn(async () =>
|
||||
Array.from({ length: 30 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
severity: "warning",
|
||||
type: "disk",
|
||||
message: `Disk alert ${index + 1}`,
|
||||
isRead: false,
|
||||
createdAt: "2026-08-30T10:00:00.000Z",
|
||||
})),
|
||||
);
|
||||
const errors = vi.fn(async () => []);
|
||||
const providers = createSystemSearchProviders({ alerts, errors });
|
||||
|
||||
expect(SYSTEM_SEARCH_PROVIDER_IDS).toEqual([
|
||||
"system.alerts",
|
||||
"system.emulator-errors",
|
||||
]);
|
||||
expect(providers.map((provider) => provider.id)).toEqual(
|
||||
SYSTEM_SEARCH_PROVIDER_IDS,
|
||||
);
|
||||
const result = await providers[0].search(
|
||||
context([PERMS.NOTIFICATIONS_VIEW]),
|
||||
{ term: " disk ", limit: 999 },
|
||||
);
|
||||
expect(alerts).toHaveBeenCalledWith(expect.anything(), "disk", 25);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
if (!result.ok) return;
|
||||
expect(result.data).toHaveLength(25);
|
||||
expect(result.data[0]).toMatchObject({
|
||||
id: "alert-1",
|
||||
domain: "system",
|
||||
type: "entity",
|
||||
href: "/ase/system/operations/alerts",
|
||||
});
|
||||
|
||||
const forbidden = await providers[1].search(
|
||||
context([PERMS.NOTIFICATIONS_VIEW]),
|
||||
{ term: "error", limit: 25 },
|
||||
);
|
||||
expect(forbidden).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("derives unread alerts, emulator errors, and health anomalies", async () => {
|
||||
const sources = createSystemInboxSources({
|
||||
alerts: vi.fn(async () => [
|
||||
{
|
||||
id: 1,
|
||||
severity: "critical",
|
||||
type: "database",
|
||||
message: "Database connection lost",
|
||||
isRead: false,
|
||||
createdAt: "2026-08-30T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
severity: "info",
|
||||
type: "recovered",
|
||||
message: "Recovered",
|
||||
isRead: true,
|
||||
createdAt: "2026-08-30T10:01:00.000Z",
|
||||
},
|
||||
]),
|
||||
errors: vi.fn(async () => [
|
||||
{
|
||||
id: "9",
|
||||
primary: "NullPointerException (1.0)",
|
||||
secondary: "stacktrace",
|
||||
timestamp: 1_788_091_200,
|
||||
},
|
||||
]),
|
||||
health: vi.fn(async () => ({
|
||||
dbOk: false,
|
||||
dbLatencyMs: 2_100,
|
||||
redisOk: false,
|
||||
emulatorOk: true,
|
||||
onlineUsers: 12,
|
||||
})),
|
||||
now: () => new Date("2026-08-30T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(SYSTEM_INBOX_SOURCE_IDS).toEqual([
|
||||
"system.alerts",
|
||||
"system.emulator-errors",
|
||||
"system.operational-anomalies",
|
||||
]);
|
||||
expect(sources.map((source) => source.id)).toEqual(SYSTEM_INBOX_SOURCE_IDS);
|
||||
const notificationContext = context([PERMS.NOTIFICATIONS_VIEW]);
|
||||
const alertResult = await sources[0].getItems(
|
||||
notificationContext,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(alertResult).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
itemId: "1",
|
||||
priority: "critical",
|
||||
state: "unread",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const devopsContext = context([PERMS.DEVOPS_VIEW]);
|
||||
const errorResult = await sources[1].getItems(
|
||||
devopsContext,
|
||||
new AbortController().signal,
|
||||
);
|
||||
const anomalyResult = await sources[2].getItems(
|
||||
devopsContext,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(errorResult).toMatchObject({
|
||||
ok: true,
|
||||
data: { items: [{ itemId: "9", state: "open" }] },
|
||||
});
|
||||
expect(anomalyResult).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [
|
||||
{ itemId: "database", state: "degraded" },
|
||||
{ itemId: "redis", state: "degraded" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("registers mandatory health and optional alert widgets", async () => {
|
||||
const widgets = createSystemWidgets({
|
||||
operationalHealth: vi.fn(async () => ({
|
||||
dbOk: true,
|
||||
redisOk: true,
|
||||
emulatorOk: true,
|
||||
onlineUsers: 12,
|
||||
emulatorErrors: 1,
|
||||
})),
|
||||
alertSummary: vi.fn(async () => ({ unread: 3, critical: 1 })),
|
||||
});
|
||||
expect(SYSTEM_WIDGET_IDS).toEqual([
|
||||
"system.operational-health",
|
||||
"system.alert-summary",
|
||||
]);
|
||||
expect(widgets.map((widget) => widget.kind)).toEqual([
|
||||
"mandatory",
|
||||
"optional",
|
||||
]);
|
||||
expect(
|
||||
await widgets[0].load(
|
||||
context([PERMS.DEVOPS_VIEW]),
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
data: { dbOk: true, emulatorErrors: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { systemObservabilityQuery } from "./queries/observability";
|
||||
import { systemOperationsQuery } from "./queries/operations";
|
||||
|
||||
export const SYSTEM_WIDGET_IDS = [
|
||||
"system.operational-health",
|
||||
"system.alert-summary",
|
||||
] as const;
|
||||
|
||||
type SystemWidgetData = Readonly<Record<string, boolean | number | null>>;
|
||||
type SystemWidgetLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<SystemWidgetData>;
|
||||
|
||||
export interface SystemWidgetAdapters {
|
||||
readonly operationalHealth: SystemWidgetLoader;
|
||||
readonly alertSummary: SystemWidgetLoader;
|
||||
}
|
||||
|
||||
function createWidget(
|
||||
id: (typeof SYSTEM_WIDGET_IDS)[number],
|
||||
kind: "mandatory" | "optional",
|
||||
capability: CapabilityRequirement,
|
||||
load: SystemWidgetLoader,
|
||||
): HousekeepingWidgetDefinition {
|
||||
return {
|
||||
id,
|
||||
owner: "system",
|
||||
kind,
|
||||
capability,
|
||||
async load(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
if (signal.aborted) throw new Error(`aborted ${id}`);
|
||||
return ok(await load(context, signal), authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSystemWidgets(
|
||||
adapters: SystemWidgetAdapters,
|
||||
): readonly HousekeepingWidgetDefinition[] {
|
||||
return [
|
||||
createWidget(
|
||||
"system.operational-health",
|
||||
"mandatory",
|
||||
anyCapability(PERMS.DEVOPS_VIEW),
|
||||
adapters.operationalHealth,
|
||||
),
|
||||
createWidget(
|
||||
"system.alert-summary",
|
||||
"optional",
|
||||
anyCapability(PERMS.NOTIFICATIONS_VIEW),
|
||||
adapters.alertSummary,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const SYSTEM_WIDGETS = createSystemWidgets({
|
||||
async operationalHealth(context, signal) {
|
||||
if (signal.aborted) throw new Error("aborted operational health widget");
|
||||
const result = await systemObservabilityQuery.run(context, {
|
||||
routeId: "system.observability.devops",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "devops" || !result.data.health) {
|
||||
throw new Error("System health unavailable");
|
||||
}
|
||||
return {
|
||||
dbOk: result.data.health.dbOk,
|
||||
redisOk: result.data.health.redisOk,
|
||||
emulatorOk: result.data.health.emulatorOk,
|
||||
onlineUsers: result.data.health.onlineUsers,
|
||||
emulatorErrors: result.data.partialDependencies.includes(
|
||||
"emulator-errors",
|
||||
)
|
||||
? null
|
||||
: result.data.errors.length,
|
||||
};
|
||||
},
|
||||
async alertSummary(context, signal) {
|
||||
if (signal.aborted) throw new Error("aborted alert summary widget");
|
||||
const result = await systemOperationsQuery.run(context, {
|
||||
routeId: "system.operations.alerts",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "alerts") {
|
||||
throw new Error("System alerts unavailable");
|
||||
}
|
||||
const unread = result.data.alerts.filter((alert) => !alert.isRead);
|
||||
return {
|
||||
unread: unread.length,
|
||||
critical: unread.filter((alert) =>
|
||||
["critical", "fatal", "error"].includes(
|
||||
alert.severity.toLocaleLowerCase(),
|
||||
),
|
||||
).length,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user