Repository Public
1// Monitoring dashboard — read-only endpoints under /api/v1/monitor/, behind the owner session like every other /api/ route2// (the global onRequest hook of server.ts). Each answer is cached for a few seconds: opening the screen costs a handful of3// indexed queries, never a call to a chain, a venue or a provider.4import type { FastifyInstance, FastifyRequest } from 'fastify';5import { signer } from '../signer-client.ts';6import { healthCache, loopStats } from '../worker.ts';7import { alertSettings } from '../alerts/store.ts';8import { capital, exposure, pnl } from './aggregate.ts';9import { auditChainStatus, collectConnectors, diskFree, lastBackup, versions } from './health.ts';10import { labStatus } from '../agents/lab.ts'; // v811import { capabilities } from '../capabilities.ts'; // v81213const cache = new Map<string, { at: number; v: unknown }>();14async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {15 const c = cache.get(key);16 if (c && Date.now() - c.at < ttlMs) return c.v as T;17 const v = await fn();18 cache.set(key, { at: Date.now(), v });19 return v;20}21export const clearMonitorCache = (): void => cache.clear();2223interface PortfolioAnswer { prices: Record<string, { usd: number; cad: number }> | null; balances: { symbol: string; display: string | null; error: string | null }[] }24/** The wallet total, from the existing /portfolio endpoint (same session, same balance cache): no second way of reading balances. */25async function walletTotal(app: FastifyInstance, req: FastifyRequest): Promise<{ usd: number | null; cad: number | null; assets: number; unpriced: number; unavailable: number }> {26 const r = await app.inject({ method: 'GET', url: '/api/v1/portfolio', headers: { cookie: req.headers.cookie ?? '' } });27 if (r.statusCode !== 200) return { usd: null, cad: null, assets: 0, unpriced: 0, unavailable: 0 };28 const p = r.json() as PortfolioAnswer;29 let usd = 0, cad = 0, assets = 0, unpriced = 0, unavailable = 0;30 for (const b of p.balances) {31 if (b.display === null) { unavailable += 1; continue; }32 const v = Number(b.display);33 if (!(v > 0)) continue;34 assets += 1;35 const px = p.prices?.[b.symbol];36 if (!px) { unpriced += 1; continue; }37 usd += v * px.usd; cad += v * px.cad;38 }39 const rd = (x: number) => Math.round(x * 100) / 100;40 return p.prices ? { usd: rd(usd), cad: rd(cad), assets, unpriced, unavailable } : { usd: null, cad: null, assets, unpriced, unavailable };41}4243export function monitorRoutes(app: FastifyInstance): void {44 const P = '/api/v1/monitor';45 app.get(`${P}/capital`, async (req) => {46 const [wallet, rest] = await Promise.all([cached('wallet', 15_000, () => walletTotal(app, req)), cached('capital', 15_000, () => capital(Date.now()))]);47 return { ...rest, wallet };48 });49 app.get(`${P}/pnl`, async () => cached('pnl', 30_000, async () => pnl(Date.now(), (await alertSettings()).quiet.tz)));50 app.get(`${P}/exposure`, async () => cached('exposure', 15_000, () => exposure(Date.now())));51 app.get(`${P}/health`, async () => cached('health', 10_000, async () => {52 const now = Date.now();53 const { connectors, signer: st } = await collectConnectors({ now: () => now, signerStatus: () => signer.status().catch(() => null), rpcHealth: () => [...healthCache.values()], loops: () => [...loopStats.values()] });54 const backup = lastBackup(), audit = await auditChainStatus(now);55 return {56 asOf: now,57 connectors: connectors.map((c) => ({ ...c, ageSec: c.lastSuccessAt === null ? null : Math.max(0, Math.round((now - c.lastSuccessAt) / 1000)) })),58 loops: [...loopStats.values()].map((l) => ({ name: l.name, everyMs: l.everyMs, runs: l.runs, lastOkAt: l.lastOkAt, lastErrorAt: l.lastErrorAt, lastError: l.lastError, failures: l.failures,59 ageSec: l.lastOkAt === null ? null : Math.round((now - l.lastOkAt) / 1000), late: l.lastOkAt !== null && now - l.lastOkAt > Math.max(3 * l.everyMs, 60_000) })),60 // v8: the agent engines (`agent-engine`, `perp-agent-engine`) report their heartbeat like every other loop61 loopsNote: null,62 backup: backup ? { visible: true, lastAt: backup.lastAt, files: backup.files, ageHours: backup.lastAt === null ? null : Math.round((now - backup.lastAt) / 360_000) / 10 } : { visible: false, lastAt: null, files: 0, ageHours: null },63 disk: await diskFree(),64 audit,65 signer: st ? { reachable: true, halted: st.halted } : { reachable: false, halted: null },66 versions: versions(st),67 // v8: optional capabilities of this server and the variable NAMES missing for those that are off (never a value)68 capabilities: capabilities(),69 // v8: the OS sandbox every strategy process runs in; `ok: false` = signals and backtests do not run (agents hold)70 lab: await labStatus().then((l) => ({ mode: l.mode, ok: l.ok, reason: l.reason, checkedAt: l.checkedAt, lastFailureAt: l.lastFailureAt, lastFailure: l.lastFailure, calls: l.calls, refused: l.refused }), () => null),71 };72 }));73}74