Skip to content

Commit 4887291

Browse files
committed
System metrics-diagnostics page
1 parent 43b132c commit 4887291

4 files changed

Lines changed: 323 additions & 2 deletions

File tree

apps/api-manager/src/lib/config/navigation.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
ShieldOff,
4545
MessageSquare,
4646
Mail,
47+
Stethoscope,
4748
} from "@lucide/svelte";
4849
import { env } from "$env/dynamic/public";
4950

@@ -266,6 +267,11 @@ function buildMetricsItems(): NavigationItem[] {
266267
label: "Connector Counts",
267268
iconComponent: Hash,
268269
},
270+
{
271+
href: "/metrics-diagnostics",
272+
label: "Diagnostics",
273+
iconComponent: Stethoscope,
274+
},
269275
];
270276

271277
return items;
@@ -279,7 +285,8 @@ export function getActiveMetricsMenuItem(pathname: string) {
279285
if (item.external) {
280286
return false;
281287
}
282-
return pathname.startsWith(item.href);
288+
// Match on path boundaries so e.g. /metrics-diagnostics is not treated as /metrics
289+
return pathname === item.href || pathname.startsWith(item.href + "/");
283290
});
284291

285292
return found || metricsItems[0]; // fallback to first item
@@ -748,7 +755,7 @@ export const navSections: NavigationSection[] = [
748755
{ id: "system", label: "System", iconComponent: Server, items: systemItems, basePaths: ["/system"] },
749756
{ id: "signals", label: "Signals", iconComponent: Radio, items: signalsItems, basePaths: ["/system/signal-publish", "/system/signal-channels", "/system/signal-channels-stats"] },
750757
{ id: "integration", label: "Integration", iconComponent: Plug, items: integrationItems, basePaths: ["/integration"] },
751-
{ id: "metrics", label: "Metrics", iconComponent: BarChart3, items: metricsItems, basePaths: ["/metrics", "/aggregate-metrics", "/aggregate-metrics-live", "/aggregate-metrics-trends", "/connector-metrics", "/connector-traces", "/connector-counts"] },
758+
{ id: "metrics", label: "Metrics", iconComponent: BarChart3, items: metricsItems, basePaths: ["/metrics", "/aggregate-metrics", "/aggregate-metrics-live", "/aggregate-metrics-trends", "/connector-metrics", "/connector-traces", "/connector-counts", "/metrics-diagnostics"] },
752759
{ id: "abac", label: "ABAC", iconComponent: Lock, items: abacItems, basePaths: ["/abac"] },
753760
{ id: "products", label: "API Products", iconComponent: Package, items: productsItems, basePaths: ["/products"] },
754761
{ id: "financial-products", label: "Financial Products", iconComponent: Banknote, items: financialProductsItems, basePaths: ["/products/financial", "/products/collections"] },

apps/api-manager/src/lib/utils/roleChecker.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ export const SITE_MAP: Record<string, PageRoleConfig> = {
217217
"/connector-counts": {
218218
required: [{ role: "CanGetConnectorMetrics" }],
219219
},
220+
"/metrics-diagnostics": {
221+
required: [{ role: "CanGetMetricsDiagnostics" }],
222+
},
220223

221224
// ── ABAC ──────────────────────────────────────────────
222225
"/abac/rules": {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { PageServerLoad } from "./$types";
2+
import { error } from "@sveltejs/kit";
3+
import { createLogger } from "@obp/shared/utils";
4+
import { SessionOAuthHelper } from "$lib/oauth/sessionHelper";
5+
import { obp_requests } from "$lib/obp/requests";
6+
7+
const logger = createLogger("MetricsDiagnosticsPageServer");
8+
9+
export const load: PageServerLoad = async ({ locals, depends }) => {
10+
depends("app:metrics-diagnostics");
11+
logger.info("=== Metrics Diagnostics Page Load Started ===");
12+
13+
const session = locals.session;
14+
15+
if (!session?.data?.user) {
16+
logger.error("No user in session");
17+
throw error(401, "Unauthorized");
18+
}
19+
20+
const sessionOAuth = SessionOAuthHelper.getSessionOAuth(session);
21+
const accessToken = sessionOAuth?.accessToken;
22+
23+
if (!accessToken) {
24+
logger.error("No access token available");
25+
throw error(401, "No API access token available");
26+
}
27+
28+
const userEntitlements =
29+
(session.data.user as any)?.entitlements?.list || [];
30+
31+
const hasRole = userEntitlements.some(
32+
(ent: any) => ent.role_name === "CanGetMetricsDiagnostics",
33+
);
34+
35+
if (!hasRole) {
36+
logger.info(
37+
"User lacks CanGetMetricsDiagnostics role, skipping diagnostics fetch",
38+
);
39+
return {
40+
diagnostics: null,
41+
hasRole: false,
42+
userEntitlements,
43+
};
44+
}
45+
46+
try {
47+
const endpoint = "/obp/v7.0.0/management/system/diagnostics/metrics";
48+
logger.info(`Making API request to: ${endpoint}`);
49+
50+
const diagnostics = await obp_requests.get(endpoint, accessToken);
51+
52+
return {
53+
diagnostics,
54+
hasRole: true,
55+
userEntitlements,
56+
};
57+
} catch (err: any) {
58+
logger.error("Error fetching metrics diagnostics:", err);
59+
throw error(
60+
err?.status && Number.isInteger(err.status) ? err.status : 500,
61+
err instanceof Error ? err.message : "Failed to fetch metrics diagnostics",
62+
);
63+
}
64+
};
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<script lang="ts">
2+
import type { PageData } from "./$types";
3+
import MissingRoleAlert from "$lib/components/MissingRoleAlert.svelte";
4+
import {
5+
Activity,
6+
Archive,
7+
CheckCircle2,
8+
AlertTriangle,
9+
XCircle,
10+
Settings,
11+
} from "@lucide/svelte";
12+
13+
let { data }: { data: PageData } = $props();
14+
15+
const diagnostics = $derived(data.diagnostics);
16+
const config = $derived(diagnostics?.config);
17+
const metric = $derived(diagnostics?.metric);
18+
const metricArchive = $derived(diagnostics?.metric_archive);
19+
const checks = $derived(diagnostics?.checks ?? []);
20+
const everythingAsExpected = $derived(
21+
diagnostics?.everything_as_expected ?? false,
22+
);
23+
24+
function formatDate(value: string | null | undefined): string {
25+
if (!value) return "";
26+
const d = new Date(value);
27+
if (isNaN(d.getTime())) return String(value);
28+
return d.toLocaleString();
29+
}
30+
31+
function formatCount(value: number | null | undefined): string {
32+
if (value === null || value === undefined) return "";
33+
return value.toLocaleString();
34+
}
35+
36+
function formatAge(days: number | null | undefined): string {
37+
if (days === null || days === undefined) return "";
38+
return `${days.toLocaleString()} day${days === 1 ? "" : "s"}`;
39+
}
40+
41+
function checkBadgeColor(status: string): string {
42+
if (status === "OK")
43+
return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400";
44+
if (status === "WARNING")
45+
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400";
46+
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400";
47+
}
48+
</script>
49+
50+
<svelte:head>
51+
<title>Metrics Diagnostics - API Manager</title>
52+
</svelte:head>
53+
54+
<div class="container mx-auto px-4 py-8" data-testid="metrics-diagnostics-page">
55+
<!-- Header -->
56+
<div class="mb-6">
57+
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
58+
Metrics Diagnostics
59+
</h1>
60+
<p class="mt-1 text-gray-600 dark:text-gray-400">
61+
State of the <code class="font-mono">metric</code> and
62+
<code class="font-mono">metricarchive</code> tables, the archiving
63+
configuration, and integrity checks for the metrics archive scheduler.
64+
</p>
65+
</div>
66+
67+
{#if !data.hasRole}
68+
<MissingRoleAlert
69+
roles={["CanGetMetricsDiagnostics"]}
70+
message="You need this role to view metrics diagnostics"
71+
/>
72+
{:else if diagnostics}
73+
<!-- Overall Status -->
74+
<div
75+
class="mb-6 rounded-lg border p-4 {everythingAsExpected
76+
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20'
77+
: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20'}"
78+
data-testid="overall-status"
79+
data-state={everythingAsExpected ? "ok" : "attention"}
80+
>
81+
<div class="flex items-center gap-3">
82+
{#if everythingAsExpected}
83+
<CheckCircle2 class="h-6 w-6 text-green-600 dark:text-green-400" />
84+
<p class="text-base font-semibold text-green-800 dark:text-green-200">
85+
Everything as expected
86+
</p>
87+
{:else}
88+
<AlertTriangle class="h-6 w-6 text-red-600 dark:text-red-400" />
89+
<p class="text-base font-semibold text-red-800 dark:text-red-200">
90+
Some checks need attention
91+
</p>
92+
{/if}
93+
</div>
94+
</div>
95+
96+
<!-- Table Stats -->
97+
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
98+
{#each [{ title: "Metric Table", icon: Activity, stats: metric, color: "text-blue-500" }, { title: "Metric Archive Table", icon: Archive, stats: metricArchive, color: "text-purple-500" }] as card (card.title)}
99+
<div
100+
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"
101+
data-testid="table-stats"
102+
data-table={card.stats?.table_name}
103+
>
104+
<div class="mb-4 flex items-center justify-between">
105+
<div class="flex items-center gap-2">
106+
<card.icon class="h-5 w-5 {card.color}" />
107+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
108+
{card.title}
109+
</h2>
110+
</div>
111+
<code
112+
class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-gray-900 dark:text-gray-300"
113+
>
114+
{card.stats?.table_name ?? ""}
115+
</code>
116+
</div>
117+
118+
<div class="mb-4">
119+
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
120+
Row Count
121+
</p>
122+
<p
123+
class="mt-1 text-3xl font-bold text-gray-900 dark:text-gray-100"
124+
data-testid="row-count"
125+
>
126+
{formatCount(card.stats?.count)}
127+
</p>
128+
</div>
129+
130+
<div class="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-900/50">
131+
<div>
132+
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
133+
Oldest Record
134+
</p>
135+
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">
136+
{formatDate(card.stats?.oldest_record_date)}
137+
</p>
138+
<p class="text-xs text-gray-500 dark:text-gray-400">
139+
{formatAge(card.stats?.oldest_record_age_days)} old
140+
</p>
141+
</div>
142+
<div>
143+
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
144+
Newest Record
145+
</p>
146+
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">
147+
{formatDate(card.stats?.newest_record_date)}
148+
</p>
149+
<p class="text-xs text-gray-500 dark:text-gray-400">
150+
{formatAge(card.stats?.newest_record_age_days)} old
151+
</p>
152+
</div>
153+
</div>
154+
</div>
155+
{/each}
156+
</div>
157+
158+
<!-- Archiving Configuration -->
159+
<div
160+
class="mb-6 rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"
161+
>
162+
<div class="flex items-center gap-2 border-b border-gray-200 p-6 dark:border-gray-700">
163+
<Settings class="h-5 w-5 text-gray-500" />
164+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
165+
Archiving Configuration
166+
</h2>
167+
</div>
168+
<div class="grid grid-cols-1 gap-px overflow-hidden rounded-b-lg bg-gray-200 dark:bg-gray-700 sm:grid-cols-2 lg:grid-cols-3">
169+
{#each [{ key: "write_metrics", label: "Write Metrics", value: config?.write_metrics }, { key: "enable_metrics_scheduler", label: "Scheduler Enabled", value: config?.enable_metrics_scheduler }, { key: "retain_metrics_move_limit", label: "Move Batch Limit", value: config?.retain_metrics_move_limit }, { key: "retain_metrics_days", label: "Retain Metrics (days)", value: config?.retain_metrics_days, effective: config?.retain_metrics_days_effective }, { key: "retain_archive_metrics_days", label: "Retain Archive (days)", value: config?.retain_archive_metrics_days, effective: config?.retain_archive_metrics_days_effective }] as item (item.key)}
170+
<div class="bg-white p-4 dark:bg-gray-800" data-testid="config-item" data-config-key={item.key}>
171+
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
172+
{item.label}
173+
</p>
174+
<p class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
175+
{#if typeof item.value === "boolean"}
176+
{item.value ? "Yes" : "No"}
177+
{:else}
178+
{item.value ?? ""}
179+
{/if}
180+
</p>
181+
{#if item.effective !== undefined && item.effective !== item.value}
182+
<p class="text-xs text-gray-500 dark:text-gray-400">
183+
effective: {item.effective}
184+
</p>
185+
{/if}
186+
</div>
187+
{/each}
188+
</div>
189+
</div>
190+
191+
<!-- Integrity Checks -->
192+
<div
193+
class="mb-6 rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"
194+
>
195+
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
196+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
197+
Integrity Checks ({checks.length})
198+
</h2>
199+
</div>
200+
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
201+
{#each checks as check (check.name)}
202+
<li class="flex items-start gap-3 p-4" data-testid="integrity-check" data-check={check.name} data-status={check.status}>
203+
<div class="mt-0.5">
204+
{#if check.status === "OK"}
205+
<CheckCircle2 class="h-5 w-5 text-green-600 dark:text-green-400" />
206+
{:else if check.status === "WARNING"}
207+
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
208+
{:else}
209+
<XCircle class="h-5 w-5 text-red-600 dark:text-red-400" />
210+
{/if}
211+
</div>
212+
<div class="flex-1">
213+
<div class="flex items-center gap-2">
214+
<code class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
215+
{check.name}
216+
</code>
217+
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium {checkBadgeColor(check.status)}">
218+
{check.status}
219+
</span>
220+
</div>
221+
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
222+
{check.message}
223+
</p>
224+
</div>
225+
</li>
226+
{/each}
227+
</ul>
228+
</div>
229+
230+
<!-- Raw Data -->
231+
<div
232+
class="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"
233+
>
234+
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
235+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
236+
Raw Diagnostics Data
237+
</h2>
238+
</div>
239+
<div class="p-6">
240+
<div class="overflow-auto max-h-[600px]">
241+
<pre class="whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-xs dark:bg-gray-900"><code
242+
class="text-gray-900 dark:text-gray-100">{JSON.stringify(diagnostics, null, 2)}</code></pre>
243+
</div>
244+
</div>
245+
</div>
246+
{/if}
247+
</div>

0 commit comments

Comments
 (0)