Skip to content

06 — Admin Panel

As of 2026-05-28.

The operator console for QuantaTrade. Lives in a separate repo from the platform monorepo: QuantaTradeAI/admin-panel. Built as a Next.js 14 app that talks directly to the Java matching engine over Connect-protocol gRPC-web — not via the platform's NestJS gateway. This is a deliberate architectural choice: operator commands need to land on the engine's authoritative UserRegistry without a round-trip through api-gateway / order-router.

This document is grounded in the working copy at /Users/pk/ws/quantatrade-admin-override (admin-panel PR#2 branch, with the new ForceCancelButton shipped 2026-05-27).


1. Stack and structure

Runtime stack

From package.json:14-46:

Concern Choice Notes
Framework Next.js 14.0.4 (App Router) Older than the trading-ui (Next 15); refresh planned but not blocking
React 18.2
RPC transport @connectrpc/connect-web ^1.2 + @connectrpc/connect ^1.2 Connect-protocol, not gRPC-web wire format — see §4
Proto codegen @bufbuild/buf + protoc-gen-es + protoc-gen-connect-es buf.yaml is a minimal v1 lint config with no deps (it does not yet point at exchange-core's proto tree — generation is on-demand, not CI-enforced)
Data fetching @tanstack/react-query ^5.17 Listed in deps but not actually used yet — every page uses raw useState + useEffect. Tech debt
State zustand ^4.4 Same story: declared, not wired
Styling TailwindCSS 3.4 + a homegrown set of utility class names (btn-primary, card, badge-success…) Defined in globals.css
Charts recharts ^2.10 Used on hedging / market-making pages
Icons lucide-react + inline SVG Sidebar icons are inline SVG (src/components/Sidebar.tsx:18-124)
Testing jest + @testing-library/react + ts-jest Added in PR#2 — see §9

Dev server runs on port 3002 (package.json:7next dev -p 3002). The Dockerfile builds a Next standalone output and runs on port 3000 inside the container (Dockerfile:33-64).

Top-level layout

src/
├── app/
│   ├── layout.tsx           # Root layout: AuthProvider + ThemeProvider
│   ├── page.tsx             # Login page (208 LOC)
│   ├── globals.css
│   └── (authenticated)/     # Route group — every page below is auth-gated
│       ├── layout.tsx       # Sidebar shell + redirect-if-no-session
│       ├── dashboard/
│       ├── orders/
│       ├── trades/
│       ├── balances/
│       ├── participants/
│       ├── orderbook/
│       ├── positions/
│       ├── risk/
│       ├── treasury/
│       ├── hedging/
│       ├── market-making/
│       ├── presale/
│       ├── symbols/
│       ├── kyc/
│       ├── approvals/
│       └── services/
├── components/
│   ├── Sidebar.tsx          # Permission-filtered nav (281 LOC)
│   ├── DataTable.tsx        # Sortable generic table
│   ├── StatCard.tsx         # Dashboard tiles
│   ├── ForceCancelButton.tsx          # Shipped PR#2 — see §6
│   └── ForceCancelButton.test.tsx
├── context/
│   ├── AuthContext.tsx      # localStorage-backed session
│   └── ThemeContext.tsx     # Dark/light, persisted
├── lib/
│   ├── grpc-client.ts       # Low-level Connect-protocol client (437 LOC)
│   ├── api-unified.ts       # Unified gRPC-first / REST-fallback facade (905 LOC)
│   └── api.ts               # Legacy REST helper
└── types/
    └── index.ts             # ParticipantType, Permission, ROLE_PERMISSIONS, all DTOs

Client-component model

No SSR for authenticated routes. The wrapping layout starts with 'use client' (src/app/(authenticated)/layout.tsx:1), and every page underneath does the same. This is intentional:

  1. Session lives in localStorage (src/context/AuthContext.tsx:29-46), which is not available server-side.
  2. The Connect-protocol client (src/lib/grpc-client.ts) sends from the browser only — no server bridge.
  3. Operator pages re-poll every 5s (dashboard, risk, market-making) or 10s (hedging); SSR would add no value.

Trade-off: full client rendering means a small flash of "Loading…" on every navigation. Acceptable for an internal tool.


2. Route inventory

Sixteen authenticated routes (the Sidebar exposes two extras — /aml-alerts and /audit-logs — but those page directories do not exist in the working tree).

# Route LOC Purpose Backend it talks to
1 /dashboard 313 Live exchange stats: throughput, latency p50/p99, open orders, participant counts, service health AdminService.GetExchangeStats (5s polling)
2 /participants 287 CRUD on participants — create, activate/deactivate, view STP mode AdminService.{ListParticipants, CreateParticipant, ActivateParticipant, DeactivateParticipant, UpdateStpMode}
3 /orders 187 Live order book browser with filters (participant / symbol / status) + per-row Force-Cancel AdminService.{ListOrders, CancelOrderAdmin}
4 /trades 133 Recent trades, filterable by symbol/participant AdminService.ListTrades
5 /orderbook 202 L2 depth view for a chosen symbol (bids/asks ladder) REST: matching-engine /api/v1/marketdata/orderbook/:symbol (gRPC has no streaming surface yet — see §10)
6 /balances 232 Per-currency balances across all participants, with operator-initiated deposit/withdraw adjustments AdminService.{ListBalances, AdjustBalance}
7 /positions 670 Spot positions, transfers, and operator-side ops (large — duplicates a lot of trading-ui logic) REST: api-gateway + pms-service (talks via api-unified's REST fallback paths)
8 /risk 243 Per-participant margin level, unrealized P&L, open positions; auto-refresh every 5s AdminService.ListRiskMetrics
9 /treasury 718 Custodian balances (BitGo/Coinbase/Fitbank), reconciliation status, transfers — planned, currently mock data None wired (M2 work)
10 /hedging 565 Net exposure per symbol, exchange quotes, auto-hedge toggle, manual hedge form REST: hedge-service :3008/api/v1/hedge/*
11 /market-making 794 MM control panel: per-symbol status, fair-value, spread tier, inventory skew, config edit, force-tier override REST: mm-service :3010/api/v1/mm/*
12 /presale 573 Token presale admin: rounds, whitelist, allocations, exports, audit log — mock data (contracts not deployed) None wired (M2 work)
13 /symbols 941 Symbol + currency CRUD: create / update / delete trading pairs, base/quote asset config REST: matching-engine /api/v1/admin/{symbols,currencies}
14 /kyc 529 KYC submission review queue: approve / reject / request more info — mock data None wired (M4 work)
15 /approvals 654 Multi-sig approvals for withdrawals and limit increases — mock data None wired (M4 work)
16 /services 163 Per-service health: matching, api-gateway, fix-gateway, ouch-gateway, itch-publisher, ledger, risk, custody, kyc REST: each service's /health or /actuator/health

The four with mock data (treasury, presale, kyc, approvals) are scaffolds — the UX is complete but data is hardcoded at the top of each page file (search for mock in any of them). They will be wired against real backends as M2 / M4 land.


3. AdminService gRPC surface

All sixteen methods live on exchange.v1.AdminService (the Java matching engine), wrapped one-to-one in src/lib/grpc-client.ts:255-430. The TypeScript wrappers are thin: each invokes GrpcWebClient.call(service, method, request) (grpc-client.ts:223-251) which is a hand-rolled fetch + JSON POST — see §4 for why this is not strictly gRPC-web.

# Method Request Response Used by Operator workflow
1 Login (:255-267) { participantId, password } { success, error, token, participantId, name, type, expiresAt } AuthContext.login Operator types creds → JWT issued, gates the panel; rejected unless type ∈ {SUPER_ADMIN, SYSTEM} (api-unified.ts:229-231)
2 ValidateToken (:269-275) { token } { valid, participantId, type } Defined but not called from the UI — token freshness is checked only via local expiresAt (AuthContext:35). Latent bug: a server-side revoke is invisible to the panel until next call fails 401
3 GetExchangeStats (:277-283) {} ExchangeStatsResponse (matching / orders / trades / participants / system stats) /dashboard (5s poll) Live exchange overview
4 ListParticipants (:285-291) { limit, offset } { participants, total } /participants Operator browses the participant table
5 GetParticipant (:293-299) { participantId } { success, error, participant } (defined; called by api-unified.getParticipant) Single-record lookup
6 CreateParticipant (:301-313) { participantId, password, name, type, stpMode } { success, error, participant } /participants create modal Onboard a new maker/taker/broker — no email verification, password set by operator
7 ActivateParticipant (:315-321) { participantId } { success, error, participant } /participants Re-enable a suspended account
8 DeactivateParticipant (:323-329) { participantId } { success, error, participant } /participants Suspend; existing orders are not auto-cancelled (operator must Force-Cancel them separately)
9 UpdateStpMode (:331-337) { participantId, stpMode } { success, error, participant } /participants Change Self-Trade-Prevention behaviour: NONE / CANCEL_RESTING / CANCEL_INCOMING / CANCEL_BOTH / DECREMENT_AND_CANCEL
10 ListOrders (:339-357) { participantId, symbol, status, limit, offset } { orders, total } /orders Operator drills into a participant's open orders
11 CancelOrderAdmin (:359-365) { orderId, participantId, symbol } { orderId, status, resultCode } /orders Force-Cancel button Operator removes a stuck or mis-priced order from the book on behalf of a user (§6)
12 ListTrades (:367-383) { symbol, participantId, limit, offset } { trades, total } /trades Audit recent fills
13 ListBalances (:385-391) { participantId } (empty = all) { balances } /balances View matching-engine balances (the runtime authority — see 01-architecture.md)
14 AdjustBalance (:393-414) { participantId, currency, amount, type: DEPOSIT \| WITHDRAW } { success, resultCode } /balances adjust modal Manual credit/debit. This bypasses the platform ledger and writes directly to the engine's in-memory UserRegistry. Operator-side reconciliation is the only safety net (see 03-ledger-accounting.md)
15 ListRiskMetrics (:416-422) { participantId } (empty = all) { metrics } /risk (5s poll) Margin level monitor (M8 derivatives prep; spot today)
16 ListSymbols (:424-430) {} { symbols } /orderbook, /symbols Populate symbol pickers
%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
graph LR
    UI[Admin Panel pages] --> GC[grpc-client.ts]
    GC -- "POST /exchange.v1.AdminService/{Method}" --> ENVOY[Envoy gRPC-web proxy<br/>grpc.quanta.emoment.tech:443]
    ENVOY -- "gRPC :9090" --> ME[matching-engine<br/>AdminServiceImpl.java]
    ME --> UREG[UserRegistry<br/>in-memory]
    style ME fill:#ddf4ff
    style UREG fill:#ddf4ff

4. Auth flow — and the protocol mismatch

What ships

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
    participant U as Operator browser
    participant LP as /app/page.tsx<br/>(Login)
    participant AC as AuthContext
    participant API as api-unified
    participant GC as grpc-client
    participant E as Envoy
    participant ME as matching-engine

    U->>LP: enters participantId + password
    LP->>AC: login(participantId, password)
    AC->>API: api.login(participantId, password)
    API->>GC: grpcClient.login({participantId, password})
    GC->>E: POST /exchange.v1.AdminService/Login<br/>Content-Type: application/json
    E->>ME: gRPC AdminService.Login
    ME-->>E: LoginResponse {token, type, expiresAt}
    E-->>GC: JSON {success, token, ...}
    GC->>GC: setToken(token)
    GC-->>API: result
    API->>API: reject unless type ∈ {SUPER_ADMIN, SYSTEM}
    API-->>AC: AdminSession
    AC->>U: localStorage.setItem("adminSession" / "adminToken")
    Note over AC,U: subsequent calls send "Authorization: Bearer <token>"

The Connect / gRPC-web mismatch (still open)

milestone-1-status.md flagged this as "documented separately". Here it is.

What the panel sends (grpc-client.ts:230-243): - POST to /exchange.v1.AdminService/Login - Content-Type: application/json - Accept: application/json - Body: plain JSON (not protobuf-encoded)

That is the Connect protocol unary call format — not gRPC-web wire format (application/grpc-web+proto).

What Envoy speaks (per docs/deployment-state.md:249): gRPC-web-proxy is envoyproxy/envoy:v1.29 configured as a gRPC-web ↔ gRPC translator. It accepts application/grpc-web+proto and application/grpc-web-text, not application/json.

Result historically: requests reach Envoy and bounce back as 415 Unsupported Media Type. Direct gRPC-web curl tests confirm the backend itself is correct.

Current state (2026-05-28): the matching engine's AdminServiceImpl was patched to also accept JSON on the same endpoint via an Envoy-bypass route. Login works end-to-end on grpc.quanta.emoment.tech for super-admin credentials. Every other AdminService method works for the same reason. This is a stop-gap, not a fix. The clean resolution is one of:

  • (a) Change the admin to use createGrpcWebTransport instead of the hand-rolled JSON fetch in grpc-client.ts. ~50 line change, removes all the manual JSON serialisation.
  • (b) Replace Envoy with a Connect-aware proxy (e.g. connect-go-based reverse proxy).

Option (a) is preferred — keeps the existing Envoy infra and aligns the admin with the standard transport.

Logout

Pure client-side (AuthContext:62-66): wipes localStorage keys adminSession and adminToken. No server-side revocation — the token remains valid on the engine until its expiresAt. Acceptable for an internal tool today; will need a real revocation list when operators rotate out.


5. Authorisation / RBAC posture

The panel ships a rich permission model in TypeScript that the matching engine does not enforce.

src/types/index.ts:34-111 defines 60+ Permission literals and 14 ParticipantType roles. src/types/index.ts:113-191 maps roles → permission lists (mirrors the Java RolePermissions — comment at line 113). The Sidebar (src/components/Sidebar.tsx:169-173) filters nav items via hasAnyPermission(session.type, item.requiredPermissions).

What that gives you:

Layer Check Effective
Nav visibility Sidebar.tsx filters items by role 🟢 Hides links a role shouldn't see
Page guards Each page uses useAuth().session.type ad-hoc 🟡 Inconsistent — /kyc, /approvals, /treasury check; /orders, /balances, /participants do not
Server enforcement AdminService only checks type ∈ {SUPER_ADMIN, SYSTEM} to gate login 🔴 Once you're in, every method works — the engine does not check per-method permissions

Practical posture: - A non-SUPER_ADMIN cannot log in via the admin-panel UI (api-unified.ts:229-231). - But the server does not verify role-vs-method: if you forge a JWT with type=SUPER_ADMIN (the JWT secret is engine-side), or call the gRPC endpoint directly with a stale token, you can invoke any AdminService method. - The RBAC matrix in types/index.ts is planning material, not policy.

M4 deliverable: enforce the role/permission matrix server-side (exchange-core's AdminServiceImpl needs interceptor + claim check). Until then: treat the admin panel as super-admin-only, sandbox access via Cloudflare Access at the domain level (not yet in front of admin.quanta.emoment.tech).


6. Force-Cancel button (PR#2)

Shipped 2026-05-27 to satisfy M1 acceptance criterion: "manual operator override of stuck or mis-priced orders".

Component

src/components/ForceCancelButton.tsx — 152 lines. Props (:5-23):

{
  orderId: string;
  participantId: string;
  symbol: string;
  onConfirm: (orderId, participantId, symbol) => Promise<void>;
  onSuccess?: () => void;
  disabled?: boolean;
  className?: string;
}

UX flow

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
    participant O as Operator
    participant B as ForceCancelButton
    participant M as Modal dialog
    participant API as api-unified
    participant ME as matching-engine

    O->>B: clicks "Force-cancel"
    B->>M: open() — error=null, isOpen=true
    M->>O: shows "Force-cancel order? <id> for <participant> on <symbol>"
    O->>M: clicks "Confirm cancel"
    M->>API: onConfirm(orderId, participantId, symbol)
    API->>ME: AdminService.CancelOrderAdmin
    alt success
        ME-->>API: { resultCode: SUCCESS }
        API-->>M: resolved
        M->>B: setIsOpen(false), onSuccess()
        B->>O: dialog closes, orders list refreshes
    else engine error
        ME-->>API: throw "order already terminal"
        API-->>M: rejected
        M->>O: shows red alert banner inside dialog<br/>dialog stays open<br/>operator can dismiss with "Keep order"
    end

Why a real modal (not window.confirm)

Documented inline at ForceCancelButton.tsx:25-35:

  1. Operators routinely cancel multiple orders in a row; a real modal gives a place to surface the matching-engine error inline (e.g. "order already terminal", "unknown participant").
  2. It's testable end-to-end with react-testing-library — window.confirm is not without monkey-patching globals.

The dialog also prevents close-during-submit (:54-57: if (isSubmitting) return). Operators can't accidentally double-cancel by clicking the backdrop mid-flight.

Wiring from /orders

src/app/(authenticated)/orders/page.tsx:39-42:

const handleForceCancel = async (orderId, participantId, symbol) => {
  await api.cancelOrder(orderId, participantId, symbol);
  await fetchOrders();
};

api.cancelOrder is in api-unified.ts:446-460 — gRPC-first via grpcClient.cancelOrderAdmin, REST fallback to DELETE /api/v1/orders/:id if Connect/JSON path fails.

Button only renders for terminable statuses (page.tsx:103):

row.status === 'OPEN' || row.status === 'PARTIALLY_FILLED'

Errors from the matching engine (Error("gRPC error: 4xx - <text>") from grpc-client.ts:247) bubble up through handleForceCancel's rejected promise into the modal's error state and render in a red banner (ForceCancelButton.tsx:119-127).

Tests

Six tests in ForceCancelButton.test.tsx:

Test Asserts
does not show dialog until trigger clicked dialog hidden by default
opens dialog with order/participant/symbol three identifiers all surface in modal copy
"Keep order" closes without calling onConfirm dismiss-without-side-effect
onConfirm invoked + onSuccess fires on confirm happy path
error surfaces inline, dialog stays open failure path — operator sees the engine error
disabled=true blocks the trigger guards terminal orders

All pass with npm test (jest + ts-jest, jsdom env). See §9 for the test infra.


7. Halt-market gap

The AdminService proto does not yet expose HaltMarket / PauseSymbol / ResumeSymbol.

PR#2's review thread explicitly called this out: the operator UI cannot ship a "Halt BTC-USDT" button until the proto carries the method. The panel refused to invent a client wrapper around a non-existent RPC — correct call.

What's there today (as a UI affordance, not a real backend)

  • /symbols page surfaces an "active" toggle on each symbol — but flipping it only updates the DB row via the REST admin endpoint; the matching engine does not consult that flag on order entry.
  • /market-making has a tier=HALT value in the SpreadTier enum (market-making/page.tsx:14) — but this only halts the MM service's quoting, not order entry by other participants. Visible as "Quoting halted" label only.
  • Permission literal HALT_SYMBOL exists in types/index.ts:97, granted to RISK_MANAGER and SUPER_ADMIN — UI-only convention.

Path forward (3 repos, in order)

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
graph LR
    subgraph EC[QuantaTradeAI/exchange-core]
        P[admin.proto<br/>add HaltMarket / ResumeMarket]
        J[AdminServiceImpl.java<br/>implement handler<br/>flip ExchangeApi flag]
        UR[UserRegistry / OrderBook<br/>reject orders on halted symbol]
    end
    subgraph AP[QuantaTradeAI/admin-panel]
        GC[grpc-client.ts<br/>add haltMarket / resumeMarket wrapper]
        SP[symbols/page.tsx<br/>wire "Halt" button to GC]
    end
    subgraph PL[QuantaTradeAI/platform]
        AG[api-gateway/markets<br/>surface halt status on /api/v1/markets/:sym]
    end

    P --> J
    J --> UR
    P -.regen via buf.- GC
    GC --> SP
    UR -.NATS market.halted.- AG

Estimated scope:

Step Where LOC
Add HaltMarket + ResumeMarket RPCs to admin.proto exchange-core/src/main/proto/admin.proto ~15
Implement Java handler AdminServiceImpl.java ~40
Plumb flag into order acceptance OrderService.java ~10
Regenerate Connect-ES stubs admin-panel/buf generate auto
Add haltMarket / resumeMarket to grpc-client.ts src/lib/grpc-client.ts ~20
Wire Halt/Resume buttons on /symbols src/app/(authenticated)/symbols/page.tsx ~50
Publish market.halted to NATS exchange-core + ws-gateway ~30

Total: 1–2 days of focused work. Blocked on: PR#2 is admin-panel only; no proto change PR has been opened against exchange-core yet.


8. Other features — page by page

/dashboard (313 LOC)

Six tiles + a service-health strip: - Matching engine: status, throughput, p50/p99 latency, orders in book - Orders 24h: total, open, filled, cancelled, rejected - Trades 24h: count, volume, notional - Participants: total, active, by type - System: CPU%, memory%, disk%, net I/O - Services: per-service ServiceStatus[] row

Single source: AdminService.GetExchangeStats() every 5s (page.tsx:24-27). The "Services" section consumes whatever the engine reports — there is no separate health probe from the admin panel.

/participants (287 LOC)

Operator CRUD. Create modal lets operator set participantId, password, name, type ∈ {MAKER, TAKER, BOTH, BROKER, SYSTEM, SUPER_ADMIN}, stpMode. Deactivate uses window.confirm (page.tsx:38) — pre-PR#2 pattern; force-cancel-style modal is the long-term target.

/balances (232 LOC)

Per-row "Adjust" button opens a modal that calls AdminService.AdjustBalance with { DEPOSIT | WITHDRAW, amount, currency }. Critical detail: this writes only to the matching engine's UserRegistry. The platform ledger is not updated. Operator must follow up with a manual ledger entry until the deposit/withdraw sync wiring (M2) is complete — see 03-ledger-accounting.md §5.

/trades (133 LOC)

Simple filterable list. No actions — view-only.

/orderbook (202 LOC)

Two-column ladder for a chosen symbol. REST polling (matching-engine /api/v1/marketdata/orderbook/:symbol) — there is no streaming gRPC depth feed today, so the page polls.

/risk (243 LOC)

Per-participant margin metrics (5s polling). Margin-level colour-coded: green ≥ 150%, yellow ≥ 100%, red < 100%. Used as the operator early-warning for margin calls (M8). Spot trading today, so most rows show marginLevel = ∞.

/positions (670 LOC)

Largest of the operator pages. Three tabs: open positions, balances, transfers. REST-fetched from api-gateway + pms-service. Significant duplication with the trading-ui — candidate for shared components when M3 refactor lands.

/hedging (565 LOC)

Net exposure dashboard + manual hedge. Talks to hedge-service:3008 (not yet documented in this set — see deployment-state). Auto-hedge toggle, limit configuration modal, manual hedge form. The hedge service is a Phase-2 deliverable (M9).

/market-making (794 LOC)

MM operator console: - Per-symbol status grid (active / quoting / inventory) - Selected-symbol dashboard: fair value, current spread, inventory skew, volatility, quoting conditions - Config edit modal (baseSpreadBps, minSpread, maxSpread, numLevels, levelSpacing, baseQuantity, quantityMultiplier) - Force-tier override modal: TIGHT | NORMAL | WIDE | DEFENSIVE | HALT for N seconds

REST endpoints on mm-service:3010. M9 deliverable; the page ships ahead of the service for design review.

/symbols (941 LOC)

Symbol + currency CRUD. Operator can: - Create a new trading pair (symbol name, symbol id, base + quote asset, lot size, tick size, taker fee, maker fee) - Update an existing symbol - Delete a symbol (only if no open orders) - Same operations on Currency rows (asset definitions: BTC, USDT, ETH, etc.)

REST against matching-engine /api/v1/admin/{symbols,currencies}. Note: gRPC AdminService.ListSymbols returns only { symbol, symbolId, baseCurrency, quoteCurrency, active } — the full config (fees, lot/tick) is REST-only today.

/kyc (529 LOC)

Mock data. Operator reviews KYC submissions: documents (passport, selfie, proof-of-address), personal info, risk score, AML flags. Buttons for Approve / Reject / Request more info. Will wire to a real KYC service in M4 (Sumsub integration).

/approvals (654 LOC)

Mock data. Multi-sig approvals queue: - Withdrawal approvals (require 2-of-N signatures) - Limit-increase approvals - Large-trade approvals Each row tracks currentApprovals / requiredApprovals and expiresAt. M4 deliverable.

/treasury (718 LOC)

Mock data. Cross-custodian view: BitGo, Coinbase, Fitbank, internal hot wallet. Totals USD, per-asset breakdown by source. M2 deliverable (BitGo custody wiring).

/presale (573 LOC)

Mock data. Token presale admin: rounds, whitelist (Merkle root), allocations, exports, audit log. M2/M5 (depends on contract deployment).

/services (163 LOC)

Per-service health pings. The service list is hardcoded (page.tsx:14-24): - Matching Engine, API Gateway, FIX Gateway, OUCH Gateway, ITCH Publisher, Ledger Service, Risk Service, Custody Service, KYC Service

For each, the panel hits the service's /health or /actuator/health and records UP / DOWN / DEGRADED + latency. Refresh button at the top. Decoupled from the dashboard system.services[] — the two could drift.


9. Test infrastructure

Added in PR#2 alongside ForceCancelButton. Files:

  • jest.config.jsts-jest preset, jsdom env, <rootDir>/src roots, @/* path alias, ESM .js suffix stripped for CommonJS resolution
  • jest.setup.ts — single line: import '@testing-library/jest-dom'
  • src/components/*.test.tsx — co-located with the component under test

Pattern (jest.config.js:14-31):

transform: {
  '^.+\\.[tj]sx?$': ['ts-jest', {
    tsconfig: {
      jsx: 'react-jsx',
      esModuleInterop: true,
      target: 'es2019',
      module: 'commonjs',
      moduleResolution: 'node',
      strict: true,
      skipLibCheck: true,
      isolatedModules: true,
      ...
    },
    isolatedModules: true,
  }],
},
transformIgnorePatterns: ['node_modules/(?!(nanoid)/)'],

Run: npm test. Single test file today (ForceCancelButton.test.tsx, 6 tests, all green). The pattern is in place for new components to add their own *.test.tsx next door.

What's not tested: - Pages (every page is currently untested — the gRPC client and route components have no jest tests; manual smoke testing on the deployed env is the only safety net) - The grpc-client.ts wrappers (would need a Connect-protocol mock; deferred) - api-unified.ts fallback paths (gRPC → REST switch logic)

Adding page-level tests is a follow-up; the immediate priority is to keep new operator components covered.


10. Known issues and quirks

🔴 Connect / gRPC-web protocol mismatch — see §4. Currently masked by an engine-side JSON-bypass route. Clean fix: switch admin-panel to createGrpcWebTransport.

🔴 No server-side RBAC — see §5. The TS permission matrix is presentational only. Sandbox via Cloudflare Access at the domain level until M4.

🔴 AdjustBalance bypasses the ledger — manual deposits via the admin panel update only the engine's UserRegistry. Operator must follow up with a hand-written ledger entry. See 03-ledger-accounting.md.

🟡 HaltMarket is missing from the proto — see §7. Single biggest functional gap for M1 acceptance.

🟡 @tanstack/react-query and zustand are declared but unused — every page reinvents useState/useEffect/setInterval polling. Tech debt; consolidating onto react-query would shed ~500 LOC across the page set.

🟡 /orderbook polls instead of streams — there is no gRPC streaming MarketDataService.WatchBook today, so depth view polls every couple of seconds. Acceptable for ops, ugly for the trading-ui (which uses the REST + WS hybrid documented in 02-trading-system.md).

🟡 ValidateToken defined but unusedAuthContext only checks local expiresAt. Server revocation goes unnoticed.

🟡 participants/page.tsx uses window.confirm — pre-PR#2 pattern. Migrate to a modal like ForceCancelButton for testability.

🟡 api.ts (legacy REST helper) coexists with api-unified.tsservices/page.tsx still imports from api.ts. Two helpers doing the same job; pick one.

🟢 The hardcoded SERVICES list in /services/page.tsx — straightforward, but every new platform service needs a manual edit here. Worth pulling from a shared config eventually.

🟢 Sidebar contains /aml-alerts and /audit-logs links that don't have corresponding page directories. Clicking either 404s. Either remove from Sidebar.tsx:147,152 or scaffold the pages.

🟢 No SSR / <Suspense> boundaries — every page is fully client-rendered with a "Loading…" flash. By design (see §1) but worth noting.


  • 01-architecture.md — overall service map; explains why admin → matching-engine is direct
  • 03-ledger-accounting.md — why AdjustBalance is dangerous without follow-up
  • 05-services-reference.md — the platform services the admin panel does not talk to
  • docs/milestone-1-status.md — original PR#2 context and M1 acceptance criteria
  • docs/components-index.md — admin-panel listed as M1 deliverable