From 0cdd12879c94ac00c2edb9cd482df34cc5f9c2f0 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Sun, 28 Jun 2026 20:34:21 -0300 Subject: [PATCH] fix(web): drive the sandbox overview metrics from the api read the transaction, payment and account totals and the open-case count from the live sandbox api instead of hardcoded sample numbers. fall back to clearly labelled sample values with an offline notice when the api is unreachable, and drop the fabricated sparkline trend from live counts. rename the demo tenant to a neutral placeholder. --- web/src/components/Kpi.tsx | 78 +++--- web/src/components/Sidebar.tsx | 2 +- web/src/features/overview/liveKpis.ts | 37 +++ .../features/overview/useOverviewMetrics.ts | 64 +++++ web/src/mock/overview.ts | 241 ++++++++++++------ web/src/mock/types.ts | 72 +++--- web/src/routes/Overview.tsx | 48 +++- web/src/test/Overview.test.tsx | 75 +++++- 8 files changed, 462 insertions(+), 155 deletions(-) create mode 100644 web/src/features/overview/liveKpis.ts create mode 100644 web/src/features/overview/useOverviewMetrics.ts diff --git a/web/src/components/Kpi.tsx b/web/src/components/Kpi.tsx index 035d4ed..e21acb2 100644 --- a/web/src/components/Kpi.tsx +++ b/web/src/components/Kpi.tsx @@ -6,34 +6,54 @@ import type { KpiDatum } from '@/mock/types' import { Sparkline } from './Sparkline' export function Kpi({ datum }: { datum: KpiDatum }) { - return ( -
-
-
-
- {datum.label} -
-
- {datum.value} -
-
- {datum.sub.map((segment, i) => ( - - {segment.text} - - ))} -
+ return ( +
+
+
+
+ {datum.label} +
+
+ {datum.value} +
+
+ {datum.sub.map((segment, i) => ( + + {segment.text} + + ))} +
+
+ {datum.spark && ( + + )} +
- -
-
- ) + ) } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 97e4f0a..bedcb8f 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -223,7 +223,7 @@ export function Sidebar({ active = 'Overview' }: { active?: string }) { whiteSpace: 'nowrap', }} > - mercator-eu-prod + acme-eu-prod
diff --git a/web/src/features/overview/liveKpis.ts b/web/src/features/overview/liveKpis.ts new file mode 100644 index 0000000..4f94875 --- /dev/null +++ b/web/src/features/overview/liveKpis.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { formatNumber } from '@/lib/format' +import type { KpiDatum } from '@/mock/types' +import type { OverviewMetrics } from './useOverviewMetrics' + +// Maps the truthful API counts onto the headline cards. No sparklines: a single +// count has no time series to draw. +export function liveKpis(metrics: OverviewMetrics): KpiDatum[] { + return [ + { + label: 'Transactions · total', + value: formatNumber(metrics.transactions), + sub: [{ text: 'posted to the ledger', tone: 'neutral' }], + }, + { + label: 'Payments · total', + value: formatNumber(metrics.payments), + sub: [{ text: 'across all statuses', tone: 'neutral' }], + }, + { + label: 'Accounts · total', + value: formatNumber(metrics.accounts), + sub: [{ text: 'open in the sandbox', tone: 'neutral' }], + }, + { + label: 'Compliance · open', + value: formatNumber(metrics.openCases), + sub: [ + metrics.openCases === 0 + ? { text: 'no open cases', tone: 'credit' } + : { text: 'cases awaiting review', tone: 'amber' }, + ], + }, + ] +} diff --git a/web/src/features/overview/useOverviewMetrics.ts b/web/src/features/overview/useOverviewMetrics.ts new file mode 100644 index 0000000..4e05f33 --- /dev/null +++ b/web/src/features/overview/useOverviewMetrics.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { useQuery } from '@tanstack/react-query' +import { apiFetch } from '@/api/client' +import type { + AccountResponse, + CaseResponse, + PageResponse, + PaymentResponse, + TransactionResponse, +} from '@/api/types' + +export interface OverviewMetrics { + transactions: number + payments: number + accounts: number + openCases: number +} + +// Headline counts the sandbox API can answer truthfully today. Paged endpoints +// give totals through totalElements (size=1 keeps the payload tiny); open cases +// come from the status-filtered list. Latency or "today" windows have no public +// endpoint, so they are intentionally not surfaced here. +export function useOverviewMetrics(): { + isLoading: boolean + isError: boolean + metrics: OverviewMetrics | null +} { + const transactions = useQuery({ + queryKey: ['overview', 'transactions'], + queryFn: () => + apiFetch>('/v1/transactions?page=0&size=1'), + }) + const payments = useQuery({ + queryKey: ['overview', 'payments'], + queryFn: () => apiFetch>('/v1/payments?page=0&size=1'), + }) + const accounts = useQuery({ + queryKey: ['overview', 'accounts'], + queryFn: () => apiFetch>('/v1/accounts?page=0&size=1'), + }) + const openCases = useQuery({ + queryKey: ['overview', 'cases', 'OPEN'], + queryFn: () => apiFetch('/v1/compliance/cases?status=OPEN'), + }) + + const queries = [transactions, payments, accounts, openCases] + const metrics: OverviewMetrics | null = + transactions.data && payments.data && accounts.data && openCases.data + ? { + transactions: transactions.data.totalElements, + payments: payments.data.totalElements, + accounts: accounts.data.totalElements, + openCases: openCases.data.length, + } + : null + + return { + isLoading: queries.some((query) => query.isLoading), + isError: queries.some((query) => query.isError), + metrics, + } +} diff --git a/web/src/mock/overview.ts b/web/src/mock/overview.ts index d9cbc3e..95e0a9e 100644 --- a/web/src/mock/overview.ts +++ b/web/src/mock/overview.ts @@ -6,81 +6,176 @@ import type { OverviewData } from './types' // Synthetic sandbox data. Swap getOverview() for a TanStack Query hook against the REST API per screen. const OVERVIEW: OverviewData = { - status: { - title: 'Sandbox running locally', - version: 'v0.4.0', - build: 'c4f9e2', - uptime: '14h 21m', - user: 'Lena', - tenant: 'mercator-eu-prod', - }, - services: [ - { name: 'postgres', version: '17.0', ok: true }, - { name: 'redpanda', version: '24.1', ok: true }, - { name: 'keycloak', version: '26.6', ok: true }, - { name: 'redis', version: '7.4', ok: true }, - ], - kpis: [ - { - label: 'Transactions · today', - value: '1,247', - sub: [ - { text: '+18%', tone: 'credit' }, - { text: 'vs yesterday', tone: 'neutral' }, - ], - spark: genSeries(24, 100, 0.06, 0.015), + status: { + title: 'Sandbox running locally', + version: 'v0.4.0', + build: 'c4f9e2', + uptime: '14h 21m', + user: 'Lena', + tenant: 'acme-eu-prod', }, - { - label: 'Payments · in flight', - value: '8', - sub: [{ text: '4 SEPA · 3 INTERNAL · 1 SWIFT', tone: 'neutral' }], - spark: genSeries(24, 10, 0.18, 0.001), - }, - { - label: 'Compliance · open', - value: '3', - sub: [ - { text: '1 P0', tone: 'debit' }, - { text: '2 P1', tone: 'amber' }, - ], - spark: genSeries(24, 5, 0.15, 0), - sparkColor: 'var(--amber)', - }, - { - label: 'Decision latency · p50', - value: '4.2ms', - sub: [ - { text: '-0.3ms', tone: 'credit' }, - { text: '· 5,841 evals', tone: 'neutral' }, - ], - spark: genSeries(24, 4, 0.08, -0.001), - }, - ], - activity: [ - { type: 'transaction.posted', detail: 'tx_01HXVK4T8P · 1,240.00 EUR · acc_01HXT4M9 -> acc_01HXT4P1', ts: '2s ago', icon: 'arrows', tone: 'neutral' }, - { type: 'payment.initiated', detail: 'pmt_01HXVW9PMR · 1,200.00 EUR · SEPA SCT', ts: '14s ago', icon: 'send', tone: 'accent' }, - { type: 'decision.evaluated', detail: 'dec_01HXVK1MFT · aml_velocity@v3 · REVIEW · 62.4ms', ts: '38s ago', icon: 'scale', tone: 'amber' }, - { type: 'account.created', detail: 'acc_01HXVW2KFN · Olamide Adekunle · USER_WALLET · EUR', ts: '1m ago', icon: 'wallet', tone: 'credit' }, - { type: 'compliance.case.opened', detail: 'case_01HXVB7M · AML hit · P0', ts: '2m ago', icon: 'shield', tone: 'debit' }, - { type: 'decision.evaluated', detail: 'dec_01HXVJ7W · sanctions_screen@2.1 · DENY · 4.6ms', ts: '3m ago', icon: 'scale', tone: 'debit' }, - { type: 'transaction.posted', detail: 'tx_01HXVH9N · 4,200.00 EUR · payroll-batch-052', ts: '4m ago', icon: 'arrows', tone: 'neutral' }, - { type: 'payment.settled', detail: 'pmt_01HXSL3M · 1,200.00 EUR · Casa Rosa Ortega', ts: '6m ago', icon: 'check', tone: 'credit' }, - { type: 'account.frozen', detail: 'acc_01HXT4P9 · velocity rule trip', ts: '8m ago', icon: 'key', tone: 'amber' }, - { type: 'kyc.refresh.completed', detail: 'kyc_01HXVQ3M · 4 docs verified', ts: '12m ago', icon: 'shield', tone: 'credit' }, - ], - apiExamples: [ - { title: 'Create a transaction', endpoint: 'POST /v1/transactions', icon: 'arrows', curl: 'curl -X POST $URL/v1/transactions \\\n -H "Idempotency-Key: ..."' }, - { title: 'Initiate a payment', endpoint: 'POST /v1/payments', icon: 'send', curl: 'curl -X POST $URL/v1/payments \\\n -H "Idempotency-Key: ..."' }, - { title: 'Evaluate a decision rule', endpoint: 'POST /v1/decisions:evaluate', icon: 'scale', curl: 'curl -X POST $URL/v1/decisions:evaluate \\\n -d \'{"ruleset":"..."}\'' }, - { title: 'Open a compliance case', endpoint: 'POST /v1/compliance/cases', icon: 'shield', curl: 'curl -X POST $URL/v1/compliance/cases' }, - ], - systemLinks: [ - { title: 'Swagger UI', sub: 'API docs · try requests', url: 'localhost:8080/swagger', icon: 'code' }, - { title: 'Grafana dashboards', sub: 'Metrics · traces · logs', url: 'localhost:3000', icon: 'activity' }, - { title: 'Keycloak admin', sub: 'Realms · users · clients', url: 'localhost:8081/admin', icon: 'key' }, - ], + services: [ + { name: 'postgres', version: '17.0', ok: true }, + { name: 'redpanda', version: '24.1', ok: true }, + { name: 'keycloak', version: '26.6', ok: true }, + { name: 'redis', version: '7.4', ok: true }, + ], + kpis: [ + { + label: 'Transactions · today', + value: '1,247', + sub: [ + { text: '+18%', tone: 'credit' }, + { text: 'vs yesterday', tone: 'neutral' }, + ], + spark: genSeries(24, 100, 0.06, 0.015), + }, + { + label: 'Payments · in flight', + value: '8', + sub: [{ text: '4 SEPA · 3 INTERNAL · 1 SWIFT', tone: 'neutral' }], + spark: genSeries(24, 10, 0.18, 0.001), + }, + { + label: 'Compliance · open', + value: '3', + sub: [ + { text: '1 P0', tone: 'debit' }, + { text: '2 P1', tone: 'amber' }, + ], + spark: genSeries(24, 5, 0.15, 0), + sparkColor: 'var(--amber)', + }, + { + label: 'Decision latency · p50', + value: '4.2ms', + sub: [ + { text: '-0.3ms', tone: 'credit' }, + { text: '· 5,841 evals', tone: 'neutral' }, + ], + spark: genSeries(24, 4, 0.08, -0.001), + }, + ], + activity: [ + { + type: 'transaction.posted', + detail: 'tx_01HXVK4T8P · 1,240.00 EUR · acc_01HXT4M9 -> acc_01HXT4P1', + ts: '2s ago', + icon: 'arrows', + tone: 'neutral', + }, + { + type: 'payment.initiated', + detail: 'pmt_01HXVW9PMR · 1,200.00 EUR · SEPA SCT', + ts: '14s ago', + icon: 'send', + tone: 'accent', + }, + { + type: 'decision.evaluated', + detail: 'dec_01HXVK1MFT · aml_velocity@v3 · REVIEW · 62.4ms', + ts: '38s ago', + icon: 'scale', + tone: 'amber', + }, + { + type: 'account.created', + detail: 'acc_01HXVW2KFN · Olamide Adekunle · USER_WALLET · EUR', + ts: '1m ago', + icon: 'wallet', + tone: 'credit', + }, + { + type: 'compliance.case.opened', + detail: 'case_01HXVB7M · AML hit · P0', + ts: '2m ago', + icon: 'shield', + tone: 'debit', + }, + { + type: 'decision.evaluated', + detail: 'dec_01HXVJ7W · sanctions_screen@2.1 · DENY · 4.6ms', + ts: '3m ago', + icon: 'scale', + tone: 'debit', + }, + { + type: 'transaction.posted', + detail: 'tx_01HXVH9N · 4,200.00 EUR · payroll-batch-052', + ts: '4m ago', + icon: 'arrows', + tone: 'neutral', + }, + { + type: 'payment.settled', + detail: 'pmt_01HXSL3M · 1,200.00 EUR · Casa Rosa Ortega', + ts: '6m ago', + icon: 'check', + tone: 'credit', + }, + { + type: 'account.frozen', + detail: 'acc_01HXT4P9 · velocity rule trip', + ts: '8m ago', + icon: 'key', + tone: 'amber', + }, + { + type: 'kyc.refresh.completed', + detail: 'kyc_01HXVQ3M · 4 docs verified', + ts: '12m ago', + icon: 'shield', + tone: 'credit', + }, + ], + apiExamples: [ + { + title: 'Create a transaction', + endpoint: 'POST /v1/transactions', + icon: 'arrows', + curl: 'curl -X POST $URL/v1/transactions \\\n -H "Idempotency-Key: ..."', + }, + { + title: 'Initiate a payment', + endpoint: 'POST /v1/payments', + icon: 'send', + curl: 'curl -X POST $URL/v1/payments \\\n -H "Idempotency-Key: ..."', + }, + { + title: 'Evaluate a decision rule', + endpoint: 'POST /v1/decisions:evaluate', + icon: 'scale', + curl: 'curl -X POST $URL/v1/decisions:evaluate \\\n -d \'{"ruleset":"..."}\'', + }, + { + title: 'Open a compliance case', + endpoint: 'POST /v1/compliance/cases', + icon: 'shield', + curl: 'curl -X POST $URL/v1/compliance/cases', + }, + ], + systemLinks: [ + { + title: 'Swagger UI', + sub: 'API docs · try requests', + url: 'localhost:8080/swagger', + icon: 'code', + }, + { + title: 'Grafana dashboards', + sub: 'Metrics · traces · logs', + url: 'localhost:3000', + icon: 'activity', + }, + { + title: 'Keycloak admin', + sub: 'Realms · users · clients', + url: 'localhost:8081/admin', + icon: 'key', + }, + ], } export function getOverview(): OverviewData { - return OVERVIEW + return OVERVIEW } diff --git a/web/src/mock/types.ts b/web/src/mock/types.ts index b07e684..a019681 100644 --- a/web/src/mock/types.ts +++ b/web/src/mock/types.ts @@ -5,60 +5,62 @@ import type { IconName } from '@/components/Icon' import type { Tone } from '@/lib/tone' export interface ServiceHealth { - name: string - version: string - ok: boolean + name: string + version: string + ok: boolean } export interface KpiSegment { - text: string - tone: Tone + text: string + tone: Tone } export interface KpiDatum { - label: string - value: string - sub: KpiSegment[] - spark: number[] - sparkColor?: string + label: string + value: string + sub: KpiSegment[] + // Live KPIs derived from the API omit the sparkline: a single count has no + // time series, so a trend line would be fabricated. Sample KPIs keep it. + spark?: number[] + sparkColor?: string } export interface ActivityEvent { - type: string - detail: string - ts: string - icon: IconName - tone: Tone + type: string + detail: string + ts: string + icon: IconName + tone: Tone } export interface ApiExample { - title: string - endpoint: string - icon: IconName - curl: string + title: string + endpoint: string + icon: IconName + curl: string } export interface SystemLink { - title: string - sub: string - url: string - icon: IconName + title: string + sub: string + url: string + icon: IconName } export interface SandboxStatus { - title: string - version: string - build: string - uptime: string - user: string - tenant: string + title: string + version: string + build: string + uptime: string + user: string + tenant: string } export interface OverviewData { - status: SandboxStatus - services: ServiceHealth[] - kpis: KpiDatum[] - activity: ActivityEvent[] - apiExamples: ApiExample[] - systemLinks: SystemLink[] + status: SandboxStatus + services: ServiceHealth[] + kpis: KpiDatum[] + activity: ActivityEvent[] + apiExamples: ApiExample[] + systemLinks: SystemLink[] } diff --git a/web/src/routes/Overview.tsx b/web/src/routes/Overview.tsx index e230605..3b674b2 100644 --- a/web/src/routes/Overview.tsx +++ b/web/src/routes/Overview.tsx @@ -4,13 +4,48 @@ import { Kpi } from '@/components/Kpi' import { Shell } from '@/components/Shell' import { ActivityFeed } from '@/features/overview/ActivityFeed' +import { liveKpis } from '@/features/overview/liveKpis' import { OverviewTopBar } from '@/features/overview/OverviewTopBar' import { SystemLinks } from '@/features/overview/SystemLinks' import { TryTheApi } from '@/features/overview/TryTheApi' +import { useOverviewMetrics } from '@/features/overview/useOverviewMetrics' import { getOverview } from '@/mock/overview' +const NOTICE = { + live: { + color: 'var(--accent)', + body: ( + <> + Live metrics. The headline counts + are read from the sandbox API. The recent-activity feed below is an illustrative + sample, not a live event stream. + + ), + }, + loading: { + color: 'var(--text-3)', + body: <>Connecting to the sandbox API..., + }, + offline: { + color: 'var(--amber)', + body: ( + <> + Sandbox API not reachable. The + headline counts below are sample values; start the local stack to see live data. The + activity feed is always an illustrative sample. + + ), + }, +} as const + export function Overview() { + const { isError, metrics } = useOverviewMetrics() const data = getOverview() + + const mode = metrics ? 'live' : isError ? 'offline' : 'loading' + const kpis = metrics ? liveKpis(metrics) : data.kpis + const notice = NOTICE[mode] + return ( - - Demo overview. FinCore is - fully functional and serves a live API; the other screens read real data. - The headline metrics and activity feed on this landing page are sample - values for demonstration, not a live feed. - + {notice.body}
- {data.kpis.map((kpi) => ( + {kpis.map((kpi) => ( ))}
diff --git a/web/src/test/Overview.test.tsx b/web/src/test/Overview.test.tsx index e447b34..071b7f3 100644 --- a/web/src/test/Overview.test.tsx +++ b/web/src/test/Overview.test.tsx @@ -1,9 +1,11 @@ // SPDX-License-Identifier: BUSL-1.1 // SPDX-FileCopyrightText: 2026 FinCore Engine Authors +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ThemeProvider } from '@/components/ThemeProvider' import type { OverviewData } from '@/mock/types' import { Overview } from '@/routes/Overview' @@ -21,7 +23,7 @@ vi.mock('@/mock/overview', () => ({ services: [{ name: 'postgres', version: '17', ok: true }], kpis: [ { - label: 'Transactions · today', + label: 'Transactions · sample', value: '42', sub: [{ text: '+1%', tone: 'credit' }], spark: [1, 2, 3], @@ -50,17 +52,74 @@ vi.mock('@/mock/overview', () => ({ }), })) -describe('Overview', () => { - it('renders the stubbed landing data without crashing', () => { - render( +function ok(body: unknown) { + return { ok: true, status: 200, json: async () => body } as Response +} + +function renderOverview() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const ui: ReactElement = ( + - , - ) - expect(screen.getByText('Transactions · today')).toBeInTheDocument() + + + ) + return render(ui) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('Overview', () => { + it('falls back to sample KPIs and the offline notice when the API is unreachable', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) + renderOverview() + expect(await screen.findByText('Sandbox API not reachable.')).toBeInTheDocument() + expect(screen.getByText('Transactions · sample')).toBeInTheDocument() + // Activity feed and API playground stay illustrative regardless of mode. expect(screen.getByText('transaction.posted')).toBeInTheDocument() expect(screen.getByText('Create a transaction')).toBeInTheDocument() }) + + it('renders live KPIs from the API counts when the sandbox is reachable', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + const url = String(input) + if (url.includes('/v1/transactions')) { + return Promise.resolve( + ok({ items: [], page: 0, size: 1, totalElements: 1247, totalPages: 1247 }), + ) + } + if (url.includes('/v1/payments')) { + return Promise.resolve( + ok({ items: [], page: 0, size: 1, totalElements: 8, totalPages: 8 }), + ) + } + if (url.includes('/v1/accounts')) { + return Promise.resolve( + ok({ items: [], page: 0, size: 1, totalElements: 53, totalPages: 53 }), + ) + } + if (url.includes('/v1/compliance/cases')) { + return Promise.resolve( + ok([ + { id: 'case_1', reference: 'c1', status: 'OPEN' }, + { id: 'case_2', reference: 'c2', status: 'OPEN' }, + { id: 'case_3', reference: 'c3', status: 'OPEN' }, + ]), + ) + } + return Promise.reject(new Error(`unexpected url ${url}`)) + }) + renderOverview() + expect(await screen.findByText('Live metrics.')).toBeInTheDocument() + expect(screen.getByText('Transactions · total')).toBeInTheDocument() + expect(screen.getByText('1,247')).toBeInTheDocument() + expect(screen.getByText('Compliance · open')).toBeInTheDocument() + // Sample KPI label must be gone once live data has loaded. + expect(screen.queryByText('Transactions · sample')).not.toBeInTheDocument() + }) })