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:7 → next 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:
- Session lives in
localStorage(src/context/AuthContext.tsx:29-46), which is not available server-side. - The Connect-protocol client (
src/lib/grpc-client.ts) sends from the browser only — no server bridge. - 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
createGrpcWebTransportinstead of the hand-rolled JSONfetchingrpc-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:
- 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").- It's testable end-to-end with react-testing-library —
window.confirmis 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):
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)¶
/symbolspage 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-makinghas atier=HALTvalue in theSpreadTierenum (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.PermissionliteralHALT_SYMBOLexists intypes/index.ts:97, granted toRISK_MANAGERandSUPER_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.js—ts-jestpreset, jsdom env,<rootDir>/srcroots,@/*path alias, ESM.jssuffix stripped for CommonJS resolutionjest.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 unused — AuthContext 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.ts — services/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.
Related¶
- 01-architecture.md — overall service map; explains why admin → matching-engine is direct
- 03-ledger-accounting.md — why
AdjustBalanceis 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 criteriadocs/components-index.md— admin-panel listed as M1 deliverable