SPB Git

Repository Public

routes.ts 4.9 KB 74 lines typescript

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
Files main
Menu

Theme