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