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() + }) })