Observability: Prometheus metrics, log panel, runbook

- GET /api/metrics renders Prometheus text (equity, P&L, per-strategy
  equity/pnl/allocation/halted, info) with no client library.
- In-memory ring log handler (app/logs.py) exposed at GET /api/logs and a
  dashboard "Server logs" panel; Runtime mirrors bot events into the logger.
- docs/runbook.md covers deploys, the kill switch, resetting paper state and
  going live. Adds tests for metrics rendering and the log ring.
This commit is contained in:
matitos committed 2026-09-22 17:10:38 +00:00
1 parent c605a0f316
commit ac92dd5ac4
12 files changed
+462 -1

No files matched your search

+3
View File
@@ -4,6 +4,7 @@ import * as api from './api'
import BacktestPanel from './components/BacktestPanel.vue'
import EquityChart from './components/EquityChart.vue'
import EventLog from './components/EventLog.vue'
import LogsPanel from './components/LogsPanel.vue'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue'
@@ -374,6 +375,8 @@ onUnmounted(() => {
<EventLog :events="events" />
</div>
<LogsPanel />
<PositionsTable :positions="positions" @close="handleClose" />
<OrdersTable :orders="orders" />
+2
View File
@@ -12,6 +12,8 @@ export const getPositions = () => http.get('/positions').then((r) => r.data)
export const getOrders = () => http.get('/orders').then((r) => r.data)
export const getEvents = () => http.get('/events').then((r) => r.data)
export const getEquity = () => http.get('/equity').then((r) => r.data)
export const getLogs = (limit = 100) =>
http.get('/logs', { params: { limit } }).then((r) => r.data)
export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data)
export const stopBot = () => http.post('/bot/stop').then((r) => r.data)
+73
View File
@@ -0,0 +1,73 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import * as api from '../api'
import { fmtTime } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
const entries = ref([])
const levelFilter = ref('all')
let timer
const levelClass = {
info: 'text-slate-300',
warning: 'text-amber-300',
error: 'text-rose-300',
critical: 'text-rose-400',
debug: 'text-slate-500',
}
const filtered = computed(() => {
if (levelFilter.value === 'all') return entries.value
return entries.value.filter((e) => e.level === levelFilter.value)
})
async function load() {
try {
const data = await api.getLogs(200)
entries.value = data.entries || []
} catch {
// ignore transient errors
}
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<CollapsiblePanel
title="Server logs"
storage-key="logs"
:default-open="false"
:summary="`${entries.length} lines`"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="space-y-1">
<span class="label">Level</span>
<select v-model="levelFilter" class="input">
<option value="all">all</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
</label>
</div>
<div class="max-h-72 space-y-0.5 overflow-y-auto font-mono text-xs">
<div v-for="(e, i) in filtered" :key="i" class="flex gap-2 border-b border-edge/40 py-0.5">
<span class="shrink-0 text-slate-500">{{ fmtTime(e.time) }}</span>
<span class="shrink-0" :class="levelClass[e.level] || 'text-slate-300'">
[{{ e.level }}]
</span>
<span class="shrink-0 text-slate-500">{{ e.logger }}</span>
<span :class="levelClass[e.level] || 'text-slate-300'">{{ e.message }}</span>
</div>
<div v-if="!filtered.length" class="py-6 text-center text-slate-500">No log lines</div>
</div>
</div>
</CollapsiblePanel>
</template>