horizon-code 0.3.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,295 +1,399 @@
1
- // Dashboard spawner — local HTTP server for live strategy monitoring
2
- // Serves a self-contained HTML+CSS dashboard with Chart.js
1
+ // Dashboard — local HTTP server for live strategy monitoring
2
+ // Serves self-contained HTML dashboard with Chart.js
3
+ // Supports both local processes (via runningProcesses) and platform deployments
4
+ // File-based mode: reads HTML from disk on each request (edits reflect immediately)
3
5
 
4
6
  import { platform } from "../platform/client.ts";
7
+ import { runningProcesses, parseLocalMetrics } from "./tools.ts";
8
+ import { store } from "../state/store.ts";
9
+ import { resolveSafePath, getWorkspaceRoot } from "./workspace.ts";
10
+ import { existsSync } from "fs";
5
11
 
6
- const DASHBOARD_HTML = (strategyId: string) => `<!DOCTYPE html>
12
+ // ── Dashboard HTML ──
13
+
14
+ function buildDashboardHTML(meta: { name: string; strategyId: string; isLocal: boolean }): string {
15
+ const safeId = meta.strategyId.replace(/[^a-zA-Z0-9-]/g, "");
16
+ return `<!DOCTYPE html>
7
17
  <html lang="en">
8
18
  <head>
9
19
  <meta charset="utf-8">
10
20
  <meta name="viewport" content="width=device-width, initial-scale=1">
11
- <title>Horizon — Strategy Dashboard</title>
21
+ <title>Horizon — ${meta.name || "Strategy"}</title>
12
22
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
13
23
  <style>
14
- * { margin: 0; padding: 0; box-sizing: border-box; }
15
- body { font-family: 'SF Mono', 'Fira Code', monospace; background: #1a1a1a; color: #e0e0e0; }
16
- .header { padding: 16px 24px; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 12px; }
17
- .header h1 { font-size: 14px; color: #fab283; letter-spacing: 2px; }
18
- .header .strategy-name { font-size: 13px; color: #ccc; font-weight: normal; }
19
- .header .status { font-size: 12px; color: #7fd88f; margin-left: auto; }
20
- .header .uptime { font-size: 11px; color: #888; }
21
- .grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 16px 24px; }
22
- .metric { background: #212121; border: 1px solid #333; border-radius: 8px; padding: 12px 16px; }
23
- .metric .label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 1px; }
24
- .metric .value { font-size: 20px; font-weight: bold; margin-top: 4px; }
25
- .metric .value.green { color: #7fd88f; }
26
- .metric .value.red { color: #e06c75; }
27
- .metric .value.neutral { color: #e0e0e0; }
28
- .charts { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; padding: 0 24px 16px; }
29
- .chart-box { background: #212121; border: 1px solid #333; border-radius: 8px; padding: 16px; }
30
- .chart-box h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
31
- .logs { padding: 0 24px 16px; }
32
- .logs-box { background: #212121; border: 1px solid #333; border-radius: 8px; padding: 16px; max-height: 200px; overflow-y: auto; }
33
- .logs-box h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
34
- .log-line { font-size: 11px; color: #aaa; padding: 2px 0; font-family: monospace; }
35
- .waiting { display: flex; align-items: center; justify-content: center; height: 120px; color: #666; font-size: 12px; }
36
- .waiting .pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #fab283; margin-right: 8px; animation: pulse 1.5s ease-in-out infinite; }
37
- @keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
24
+ :root {
25
+ --bg: #0d1117; --bg2: #161b22; --bg3: #1c2128;
26
+ --border: #30363d; --border-focus: #4d8ef7;
27
+ --text: #c9d1d9; --text-dim: #636e7b; --text-bright: #f0f6fc;
28
+ --accent: #4d8ef7; --accent-dim: #2557a7;
29
+ --green: #3fb950; --red: #f85149; --yellow: #d29922;
30
+ --radius: 12px;
31
+ }
32
+ * { margin: 0; padding: 0; box-sizing: border-box; }
33
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
34
+
35
+ .header {
36
+ padding: 14px 24px; border-bottom: 1px solid var(--border);
37
+ display: flex; align-items: center; gap: 16px;
38
+ background: var(--bg2);
39
+ }
40
+ .header .logo { font-size: 11px; color: var(--accent); letter-spacing: 3px; font-weight: 600; }
41
+ .header .name { font-size: 13px; color: var(--text-bright); font-weight: 500; }
42
+ .header .badge {
43
+ font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600; letter-spacing: 0.5px;
44
+ }
45
+ .badge.local { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid rgba(63,185,80,0.3); }
46
+ .badge.platform { background: rgba(77,142,247,0.15); color: var(--accent); border: 1px solid rgba(77,142,247,0.3); }
47
+ .header .status { margin-left: auto; font-size: 12px; display: flex; align-items: center; gap: 6px; }
48
+ .header .dot { width: 6px; height: 6px; border-radius: 50%; }
49
+ .dot.live { background: var(--green); box-shadow: 0 0 6px var(--green); }
50
+ .dot.off { background: var(--red); }
51
+ .header .uptime { font-size: 11px; color: var(--text-dim); }
52
+
53
+ .metrics-row {
54
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
55
+ gap: 1px; background: var(--border); margin: 16px 24px; border-radius: var(--radius); overflow: hidden;
56
+ }
57
+ .metric {
58
+ background: var(--bg2); padding: 14px 16px;
59
+ }
60
+ .metric .label { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
61
+ .metric .value { font-size: 22px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
62
+ .metric .sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
63
+
64
+ .grid { display: grid; grid-template-columns: 5fr 2fr; gap: 16px; padding: 0 24px 16px; }
65
+ .card {
66
+ background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
67
+ }
68
+ .card-header {
69
+ padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 11px;
70
+ color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; font-weight: 600;
71
+ }
72
+ .card-body { padding: 16px; }
73
+
74
+ .positions-list { display: flex; flex-direction: column; gap: 8px; }
75
+ .pos-row {
76
+ display: flex; align-items: center; gap: 10px; padding: 8px 0;
77
+ border-bottom: 1px solid var(--border); font-size: 12px;
78
+ }
79
+ .pos-row:last-child { border-bottom: none; }
80
+ .pos-side { font-weight: 600; width: 36px; }
81
+ .pos-side.buy { color: var(--green); }
82
+ .pos-side.sell { color: var(--red); }
83
+ .pos-market { flex: 1; color: var(--text); }
84
+ .pos-info { color: var(--text-dim); font-variant-numeric: tabular-nums; }
85
+ .pos-pnl { font-weight: 600; min-width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
86
+
87
+ .logs-card { margin: 0 24px 16px; }
88
+ .log-scroll { max-height: 180px; overflow-y: auto; padding: 12px 16px; }
89
+ .log-line { font-size: 11px; color: var(--text-dim); padding: 1px 0; font-family: 'SF Mono', 'Fira Code', monospace; white-space: pre-wrap; word-break: break-all; }
90
+
91
+ .empty { padding: 24px; text-align: center; color: var(--text-dim); font-size: 12px; }
92
+ .pulse { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: 6px; animation: pulse 1.5s ease-in-out infinite; }
93
+ @keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
94
+
95
+ .green { color: var(--green); }
96
+ .red { color: var(--red); }
97
+ .yellow { color: var(--yellow); }
38
98
  </style>
39
99
  </head>
40
100
  <body>
41
101
  <div class="header">
42
- <h1>H O R I Z O N</h1>
43
- <span class="strategy-name" id="strategyName"></span>
44
- <span class="status" id="status">● connecting...</span>
102
+ <span class="logo">HORIZON</span>
103
+ <span class="name" id="stratName">${meta.name || safeId}</span>
104
+ <span class="badge ${meta.isLocal ? "local" : "platform"}">${meta.isLocal ? "LOCAL" : "PLATFORM"}</span>
105
+ <div class="status">
106
+ <span class="dot" id="dot"></span>
107
+ <span id="statusText">connecting...</span>
108
+ </div>
45
109
  <span class="uptime" id="uptime"></span>
46
110
  </div>
47
- <div class="grid" id="metrics"></div>
48
- <div class="charts">
49
- <div class="chart-box">
50
- <h3>Equity Curve</h3>
51
- <div id="equityWaiting" class="waiting"><span class="pulse"></span>Waiting for metrics...</div>
52
- <canvas id="equity" style="display:none"></canvas>
111
+
112
+ <div class="metrics-row" id="metricsRow"></div>
113
+
114
+ <div class="grid">
115
+ <div class="card">
116
+ <div class="card-header">Equity Curve</div>
117
+ <div class="card-body">
118
+ <div id="chartEmpty" class="empty"><span class="pulse"></span>Waiting for data...</div>
119
+ <canvas id="equity" style="display:none" height="220"></canvas>
120
+ </div>
121
+ </div>
122
+ <div class="card">
123
+ <div class="card-header">Positions</div>
124
+ <div class="card-body positions-list" id="positions">
125
+ <div class="empty"><span class="pulse"></span>No positions</div>
126
+ </div>
53
127
  </div>
54
- <div class="chart-box"><h3>Positions</h3><div id="positions"><div class="waiting"><span class="pulse"></span>Waiting for data...</div></div></div>
55
128
  </div>
56
- <div class="logs"><div class="logs-box"><h3>Logs</h3><div id="logs"><div class="waiting"><span class="pulse"></span>Waiting for logs...</div></div></div></div>
57
- <script>
58
- const SID = "${strategyId.replace(/[^a-zA-Z0-9-]/g, "")}";
59
- const POLL_INTERVAL_S = 30; // metrics are polled every 30s
60
- let equityChart = null;
61
- let prevHistLen = 0;
62
- let strategyStartedAt = null;
63
-
64
- // --- Fetch strategy metadata once on load ---
65
- (async function loadStrategy() {
66
- try {
67
- const res = await fetch("/api/strategy");
68
- if (!res.ok) return;
69
- const data = await res.json();
70
- const name = data.name || data.strategy_name || data.title || SID;
71
- document.getElementById("strategyName").textContent = "/ " + name;
72
- // Try to find a start timestamp for uptime calculation
73
- strategyStartedAt = data.started_at || data.created_at || data.deployed_at || null;
74
- if (strategyStartedAt) updateUptime();
75
- } catch { /* non-critical */ }
76
- })();
77
129
 
78
- function updateUptime() {
79
- if (!strategyStartedAt) return;
80
- const start = new Date(strategyStartedAt).getTime();
81
- const now = Date.now();
82
- const diffS = Math.floor((now - start) / 1000);
83
- if (diffS < 0) return;
84
- const h = Math.floor(diffS / 3600);
85
- const m = Math.floor((diffS % 3600) / 60);
86
- const s = diffS % 60;
87
- let str = "";
88
- if (h > 0) str += h + "h ";
89
- if (m > 0 || h > 0) str += m + "m ";
90
- str += s + "s";
91
- document.getElementById("uptime").textContent = "up " + str;
92
- }
93
- setInterval(updateUptime, 1000);
130
+ <div class="card logs-card">
131
+ <div class="card-header">Logs</div>
132
+ <div class="log-scroll" id="logs">
133
+ <div class="empty"><span class="pulse"></span>Waiting for logs...</div>
134
+ </div>
135
+ </div>
94
136
 
95
- function pnlColor(val) {
96
- const n = parseFloat(val);
97
- if (isNaN(n) || n === 0) return "neutral";
98
- return n > 0 ? "green" : "red";
99
- }
137
+ <script>
138
+ const SID = "${safeId}";
139
+ const IS_LOCAL = ${meta.isLocal};
140
+ let chart = null, prevLen = 0, startedAt = Date.now();
100
141
 
101
- function formatPnl(val) {
102
- const n = parseFloat(val);
142
+ function fmtPnl(v) {
143
+ const n = parseFloat(v);
103
144
  if (isNaN(n)) return "$0.00";
104
- const sign = n >= 0 ? "+$" : "-$";
105
- return sign + Math.abs(n).toFixed(2);
145
+ return (n >= 0 ? "+$" : "-$") + Math.abs(n).toFixed(2);
106
146
  }
147
+ function pnlCls(v) { const n = parseFloat(v); return isNaN(n) || n === 0 ? "" : n > 0 ? "green" : "red"; }
107
148
 
108
- function makeTimeLabels(count) {
109
- // Each data point is ~POLL_INTERVAL_S apart, most recent is "now"
149
+ function timeLabels(count, interval) {
110
150
  const labels = [];
111
151
  for (let i = 0; i < count; i++) {
112
- const secsAgo = (count - 1 - i) * POLL_INTERVAL_S;
152
+ const secsAgo = (count - 1 - i) * interval;
113
153
  if (secsAgo === 0) { labels.push("now"); continue; }
114
154
  const m = Math.floor(secsAgo / 60);
115
- if (m < 1) { labels.push(secsAgo + "s"); }
116
- else if (m < 60) { labels.push(m + "m"); }
117
- else { labels.push(Math.floor(m / 60) + "h" + (m % 60) + "m"); }
155
+ labels.push(m < 1 ? secsAgo + "s" : m < 60 ? m + "m" : Math.floor(m/60) + "h" + (m%60) + "m");
118
156
  }
119
157
  return labels;
120
158
  }
121
159
 
122
- async function refresh() {
160
+ function updateUptime() {
161
+ const s = Math.floor((Date.now() - startedAt) / 1000);
162
+ const h = Math.floor(s / 3600), m = Math.floor((s%3600)/60);
163
+ document.getElementById("uptime").textContent = (h > 0 ? h+"h " : "") + m + "m " + (s%60) + "s";
164
+ }
165
+ setInterval(updateUptime, 1000);
166
+
167
+ async function fetchData(path) {
168
+ // Try local endpoints first, fall back to platform
169
+ if (IS_LOCAL) {
170
+ try {
171
+ const r = await fetch("/api/local" + path);
172
+ if (r.ok) return await r.json();
173
+ } catch {}
174
+ }
123
175
  try {
124
- const [mRes, lRes] = await Promise.all([
125
- fetch("/api/metrics").then(r => r.json()),
126
- fetch("/api/logs").then(r => r.json()),
127
- ]);
128
- document.getElementById("status").textContent = "● live";
129
- document.getElementById("status").style.color = "#7fd88f";
130
- const m = mRes.latest || {};
131
-
132
- // --- Metrics grid with proper PnL coloring ---
133
- const pnlVal = m.total_pnl ?? 0;
134
- const grid = document.getElementById("metrics");
135
- grid.innerHTML = [
136
- ["Total P&L", formatPnl(pnlVal), pnlColor(pnlVal)],
137
- ["Win Rate", ((m.win_rate||0)*100).toFixed(1)+"%", (m.win_rate||0) >= 0.5 ? "green" : (m.win_rate||0) > 0 ? "neutral" : "red"],
138
- ["Trades", m.total_trades ?? 0, "neutral"],
139
- ["Max DD", ((m.max_drawdown_pct||0)).toFixed(1)+"%", "red"],
140
- ].map(([l,v,c]) =>
141
- '<div class="metric"><div class="label">'+l+'</div><div class="value '+c+'">'+v+'</div></div>'
142
- ).join("");
143
-
144
- // --- Equity chart with timestamp X-axis, no animation, incremental updates ---
145
- const hist = (mRes.pnl_history||[]).map(h => h.total_pnl);
146
- if (hist.length === 0) {
147
- // Show waiting state
148
- document.getElementById("equityWaiting").style.display = "flex";
149
- document.getElementById("equity").style.display = "none";
150
- } else {
151
- document.getElementById("equityWaiting").style.display = "none";
152
- document.getElementById("equity").style.display = "block";
153
- const labels = makeTimeLabels(hist.length);
154
-
155
- if (!equityChart) {
156
- equityChart = new Chart(document.getElementById("equity"), {
157
- type: "line",
158
- data: {
159
- labels: labels,
160
- datasets: [{
161
- data: hist,
162
- borderColor: "#fab283",
163
- borderWidth: 1.5,
164
- fill: false,
165
- pointRadius: 0,
166
- tension: 0.1,
167
- }],
168
- },
169
- options: {
170
- animation: false,
171
- responsive: true,
172
- maintainAspectRatio: true,
173
- plugins: { legend: { display: false } },
174
- scales: {
175
- x: {
176
- ticks: { color: "#666", font: { size: 10 }, maxTicksLimit: 8 },
177
- grid: { color: "#2a2a2a" },
178
- },
179
- y: {
180
- ticks: { color: "#888", callback: function(v) { return "$" + v.toFixed(2); } },
181
- grid: { color: "#333" },
182
- },
183
- },
176
+ const r = await fetch("/api" + path);
177
+ if (r.ok) return await r.json();
178
+ } catch {}
179
+ return null;
180
+ }
181
+
182
+ async function refresh() {
183
+ const m = await fetchData("/metrics");
184
+ const l = await fetchData("/logs");
185
+
186
+ if (!m) {
187
+ document.getElementById("dot").className = "dot off";
188
+ document.getElementById("statusText").textContent = "disconnected";
189
+ return;
190
+ }
191
+
192
+ document.getElementById("dot").className = "dot live";
193
+ document.getElementById("statusText").textContent = "live";
194
+ if (m.uptime) startedAt = Date.now() - m.uptime * 1000;
195
+
196
+ // Metrics bar
197
+ const pnl = m.pnl ?? 0;
198
+ const row = document.getElementById("metricsRow");
199
+ row.innerHTML = [
200
+ ["P&L", fmtPnl(pnl), pnlCls(pnl), m.rpnl != null ? "Realized " + fmtPnl(m.rpnl) : ""],
201
+ ["Win Rate", ((m.win_rate||0)*100).toFixed(1)+"%", (m.win_rate||0) >= 0.5 ? "green" : "", "Trades: " + (m.trades||0)],
202
+ ["Sharpe", (m.sharpe||0).toFixed(2), (m.sharpe||0) > 1 ? "green" : (m.sharpe||0) > 0 ? "" : "red", ""],
203
+ ["Max DD", (m.max_dd||0).toFixed(1)+"%", (m.max_dd||0) > 5 ? "red" : "yellow", ""],
204
+ ["Exposure", "$"+(m.exposure||0).toFixed(2), "", "Positions: " + (m.positions||0)],
205
+ ["Orders", m.orders||0, "", m.kill ? "KILL SWITCH" : ""],
206
+ ].map(([label, value, cls, sub]) =>
207
+ '<div class="metric"><div class="label">'+label+'</div><div class="value '+(cls||"")+'">'+value+'</div>'+(sub?'<div class="sub">'+sub+'</div>':'')+'</div>'
208
+ ).join("");
209
+
210
+ // Equity chart
211
+ const hist = m.hist || [];
212
+ if (hist.length > 0) {
213
+ document.getElementById("chartEmpty").style.display = "none";
214
+ document.getElementById("equity").style.display = "block";
215
+ const labels = timeLabels(hist.length, 5);
216
+
217
+ if (!chart) {
218
+ chart = new Chart(document.getElementById("equity"), {
219
+ type: "line",
220
+ data: {
221
+ labels,
222
+ datasets: [{
223
+ data: hist, borderColor: "rgba(77,142,247,0.8)", borderWidth: 1.5,
224
+ fill: { target: "origin", above: "rgba(77,142,247,0.06)", below: "rgba(248,81,73,0.06)" },
225
+ pointRadius: 0, tension: 0.2,
226
+ }],
227
+ },
228
+ options: {
229
+ animation: false, responsive: true, maintainAspectRatio: false,
230
+ plugins: { legend: { display: false } },
231
+ scales: {
232
+ x: { ticks: { color: "#636e7b", font: { size: 10 }, maxTicksLimit: 8 }, grid: { color: "#21262d" } },
233
+ y: { ticks: { color: "#636e7b", callback: v => "$"+v.toFixed(2) }, grid: { color: "#21262d" } },
184
234
  },
185
- });
186
- prevHistLen = hist.length;
187
- } else if (hist.length !== prevHistLen) {
188
- // Incremental update push new points, shift old if over 60
189
- const newPoints = hist.length - prevHistLen;
190
- if (newPoints > 0 && newPoints < hist.length) {
191
- for (let i = hist.length - newPoints; i < hist.length; i++) {
192
- equityChart.data.labels.push(labels[i]);
193
- equityChart.data.datasets[0].data.push(hist[i]);
194
- }
195
- // Cap at 60 data points
196
- while (equityChart.data.labels.length > 60) {
197
- equityChart.data.labels.shift();
198
- equityChart.data.datasets[0].data.shift();
199
- }
200
- // Recompute time labels after shift
201
- const currentLen = equityChart.data.labels.length;
202
- equityChart.data.labels = makeTimeLabels(currentLen);
203
- } else {
204
- // Full reset (data shrank or jumped)
205
- equityChart.data.labels = labels;
206
- equityChart.data.datasets[0].data = hist;
207
- }
208
- prevHistLen = hist.length;
209
- equityChart.update("none"); // "none" mode skips animation
210
- }
235
+ },
236
+ });
237
+ prevLen = hist.length;
238
+ } else if (hist.length !== prevLen) {
239
+ chart.data.labels = timeLabels(hist.length, 5);
240
+ chart.data.datasets[0].data = hist;
241
+ prevLen = hist.length;
242
+ chart.update("none");
211
243
  }
244
+ }
245
+
246
+ // Positions
247
+ const pos = m.pos || [];
248
+ document.getElementById("positions").innerHTML = pos.length === 0
249
+ ? '<div class="empty">No open positions</div>'
250
+ : pos.map(p => {
251
+ const side = (p.side||"").includes("Yes") || (p.side||"").includes("Buy") ? "buy" : "sell";
252
+ const pnlVal = (p.rpnl||0) + (p.upnl||0);
253
+ return '<div class="pos-row">'
254
+ + '<span class="pos-side '+side+'">'+(side==="buy"?"BUY":"SELL")+'</span>'
255
+ + '<span class="pos-market">'+(p.id||"")+'</span>'
256
+ + '<span class="pos-info">'+p.sz+' @ '+Number(p.entry).toFixed(3)+'</span>'
257
+ + '<span class="pos-pnl '+pnlCls(pnlVal)+'">'+fmtPnl(pnlVal)+'</span>'
258
+ + '</div>';
259
+ }).join("");
212
260
 
213
- // --- Positions ---
214
- const pos = m.positions || [];
215
- document.getElementById("positions").innerHTML = pos.length === 0
216
- ? '<div style="color:#888;font-size:12px">No open positions</div>'
217
- : pos.map(p =>
218
- '<div style="font-size:12px;padding:4px 0;border-bottom:1px solid #333">'
219
- + '<span style="color:'+(p.side==="BUY"?"#7fd88f":"#e06c75")+'">'+p.side+'</span> '
220
- + p.slug+' <span style="color:#888">'+p.size+'@'+Number(p.avg_entry_price).toFixed(2)+'</span>'
221
- + '</div>'
222
- ).join("");
223
-
224
- // --- Logs ---
225
- const logs = lRes.logs || lRes || [];
226
- const logArr = Array.isArray(logs) ? logs : [];
227
- document.getElementById("logs").innerHTML = logArr.length === 0
228
- ? '<div class="waiting"><span class="pulse"></span>Waiting for logs...</div>'
229
- : logArr.slice(-20).map(l =>
230
- '<div class="log-line">'+(l.message||l.text||JSON.stringify(l))+'</div>'
231
- ).join("");
232
-
233
- } catch {
234
- document.getElementById("status").textContent = "● disconnected";
235
- document.getElementById("status").style.color = "#e06c75";
261
+ // Logs
262
+ if (l) {
263
+ const lines = Array.isArray(l) ? l : (l.logs || l.lines || []);
264
+ if (lines.length > 0) {
265
+ document.getElementById("logs").innerHTML = lines.slice(-30).map(line => {
266
+ const text = typeof line === "string" ? line : (line.message || line.text || JSON.stringify(line));
267
+ return '<div class="log-line">'+text.replace(/</g,"&lt;")+'</div>';
268
+ }).join("");
269
+ document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
270
+ }
236
271
  }
237
272
  }
273
+
238
274
  refresh();
239
- setInterval(refresh, 3000);
275
+ setInterval(refresh, ${meta.isLocal ? 3000 : 10000});
240
276
  </script>
241
277
  </body>
242
278
  </html>`;
279
+ }
280
+
281
+ // ── Dashboard Server ──
282
+
283
+ // Error-catching script injected before </head> in file-based dashboards
284
+ const ERROR_CAPTURE_SCRIPT = `<script>
285
+ (function(){
286
+ var _hzErrors = [];
287
+ window.onerror = function(msg, src, line, col, err) {
288
+ _hzErrors.push({type:"error", message:String(msg), source:src, line:line, col:col, stack:err?err.stack:null, ts:Date.now()});
289
+ if (_hzErrors.length > 50) _hzErrors.shift();
290
+ try { fetch("/api/error",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_hzErrors[_hzErrors.length-1])}); } catch(e){}
291
+ };
292
+ window.addEventListener("unhandledrejection", function(ev) {
293
+ var msg = ev.reason ? (ev.reason.message || String(ev.reason)) : "Unhandled promise rejection";
294
+ _hzErrors.push({type:"unhandledrejection", message:msg, stack:ev.reason?ev.reason.stack:null, ts:Date.now()});
295
+ if (_hzErrors.length > 50) _hzErrors.shift();
296
+ try { fetch("/api/error",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_hzErrors[_hzErrors.length-1])}); } catch(e){}
297
+ });
298
+ })();
299
+ </script>`;
300
+
301
+ function injectErrorScript(html: string): string {
302
+ // Inject before </head> if present, otherwise before </body>, otherwise prepend
303
+ if (html.includes("</head>")) {
304
+ return html.replace("</head>", ERROR_CAPTURE_SCRIPT + "\n</head>");
305
+ } else if (html.includes("</body>")) {
306
+ return html.replace("</body>", ERROR_CAPTURE_SCRIPT + "\n</body>");
307
+ }
308
+ return ERROR_CAPTURE_SCRIPT + "\n" + html;
309
+ }
243
310
 
244
311
  export class DashboardServer {
245
312
  private server: ReturnType<typeof Bun.serve> | null = null;
246
313
  private _port = 0;
247
314
  private _strategyId = "";
315
+ private _isLocal = false;
316
+ private _capturedErrors: any[] = [];
248
317
 
249
318
  get port(): number { return this._port; }
250
319
  get url(): string { return `http://localhost:${this._port}`; }
251
320
  get running(): boolean { return this.server !== null; }
321
+ get capturedErrors(): any[] { return this._capturedErrors; }
252
322
 
253
- start(strategyId: string, port = 0): string {
323
+ start(strategyId: string, port = 0, isLocal = false): string {
254
324
  if (this.server) this.stop();
255
325
 
256
326
  this._strategyId = strategyId;
327
+ this._isLocal = isLocal;
328
+
329
+ const draft = store.getActiveSession()?.strategyDraft;
330
+ const name = draft?.name ?? strategyId;
257
331
 
258
332
  this.server = Bun.serve({
259
333
  port: port || 0,
260
- hostname: "127.0.0.1", // localhost only — not accessible from network
334
+ hostname: "127.0.0.1",
261
335
  fetch: async (req) => {
262
336
  const url = new URL(req.url);
263
337
 
338
+ // ── Local process metrics ──
339
+ if (url.pathname === "/api/local/metrics") {
340
+ const metrics = this.getLocalMetrics();
341
+ if (metrics) return Response.json(metrics);
342
+ return Response.json({ error: "No local process" }, { status: 404 });
343
+ }
344
+
345
+ // ── Local process logs ──
346
+ if (url.pathname === "/api/local/logs") {
347
+ const logs = this.getLocalLogs();
348
+ return Response.json(logs);
349
+ }
350
+
351
+ // ── Platform metrics (deployed strategies) ──
264
352
  if (url.pathname === "/api/metrics") {
265
353
  try {
266
- const data = await platform.getMetrics(strategyId, 20);
267
- return Response.json(data);
268
- } catch (err) {
269
- return Response.json({ error: String(err) }, { status: 500 });
354
+ const data: any = await platform.getMetrics(strategyId, 20);
355
+ // Normalize to flat format for the dashboard
356
+ const latest: any = data?.latest ?? {};
357
+ return Response.json({
358
+ pnl: latest.total_pnl ?? 0,
359
+ rpnl: latest.realized_pnl ?? 0,
360
+ upnl: latest.unrealized_pnl ?? 0,
361
+ orders: latest.open_order_count ?? 0,
362
+ positions: latest.position_count ?? 0,
363
+ trades: latest.total_trades ?? 0,
364
+ win_rate: latest.win_rate ?? 0,
365
+ sharpe: latest.sharpe_ratio ?? 0,
366
+ max_dd: latest.max_drawdown_pct ?? 0,
367
+ exposure: latest.total_exposure ?? 0,
368
+ kill: false,
369
+ pos: (latest.positions ?? []).map((p: any) => ({
370
+ id: p.slug ?? p.market_id, side: p.side, sz: p.size,
371
+ entry: p.avg_entry_price, rpnl: p.realized_pnl ?? 0, upnl: p.unrealized_pnl ?? 0,
372
+ })),
373
+ hist: (data?.pnl_history ?? []).map((h: any) => h.total_pnl ?? 0),
374
+ });
375
+ } catch {
376
+ return Response.json({ error: "Platform unavailable" }, { status: 502 });
270
377
  }
271
378
  }
272
379
 
380
+ // ── Platform logs ──
273
381
  if (url.pathname === "/api/logs") {
274
382
  try {
275
383
  const data = await platform.getLogs(strategyId, 50);
276
384
  return Response.json(data);
277
- } catch (err) {
278
- return Response.json({ error: String(err) }, { status: 500 });
385
+ } catch {
386
+ return Response.json([], { status: 502 });
279
387
  }
280
388
  }
281
389
 
390
+ // ── Strategy metadata ──
282
391
  if (url.pathname === "/api/strategy") {
283
- try {
284
- const data = await platform.getStrategy(strategyId);
285
- return Response.json(data);
286
- } catch (err) {
287
- return Response.json({ error: String(err) }, { status: 500 });
288
- }
392
+ return Response.json({ name, strategy_id: strategyId, is_local: this._isLocal });
289
393
  }
290
394
 
291
- // Serve dashboard HTML
292
- return new Response(DASHBOARD_HTML(strategyId), {
395
+ // ── Serve dashboard HTML ──
396
+ return new Response(buildDashboardHTML({ name, strategyId, isLocal: this._isLocal }), {
293
397
  headers: { "Content-Type": "text/html" },
294
398
  });
295
399
  },
@@ -299,11 +403,149 @@ export class DashboardServer {
299
403
  return this.url;
300
404
  }
301
405
 
406
+ /** Get metrics from the latest running local process */
407
+ private getLocalMetrics(): any | null {
408
+ for (const [, m] of runningProcesses) {
409
+ if (m.proc.exitCode === null) {
410
+ const metrics = parseLocalMetrics(m);
411
+ if (metrics) return metrics;
412
+ }
413
+ }
414
+ return null;
415
+ }
416
+
417
+ /** Get logs from local processes, filtered */
418
+ private getLocalLogs(): string[] {
419
+ const lines: string[] = [];
420
+ for (const [, m] of runningProcesses) {
421
+ lines.push(...m.stdout.slice(-30));
422
+ const stderr = m.stderr.filter((l: string) => !l.startsWith("__HZ_METRICS__")).slice(-10);
423
+ if (stderr.length > 0) lines.push("--- stderr ---", ...stderr);
424
+ }
425
+ return lines;
426
+ }
427
+
428
+ /** Start dashboard from a file in the workspace. Re-reads on every request (live reload). */
429
+ startFromFile(filePath: string, strategyId?: string, port = 0): string {
430
+ if (this.server) this.stop();
431
+
432
+ const absolutePath = resolveSafePath(filePath);
433
+ if (!existsSync(absolutePath)) {
434
+ throw new Error(`Dashboard file not found: ${filePath}`);
435
+ }
436
+
437
+ this._strategyId = strategyId ?? "local";
438
+ this._isLocal = true;
439
+ this._capturedErrors = [];
440
+
441
+ const sid = this._strategyId;
442
+ const hasLocal = [...runningProcesses.values()].some(m => m.proc.exitCode === null);
443
+
444
+ this.server = Bun.serve({
445
+ port: port || 0,
446
+ hostname: "127.0.0.1",
447
+ fetch: async (req) => {
448
+ const url = new URL(req.url);
449
+
450
+ // ── Error capture endpoint ──
451
+ if (url.pathname === "/api/error" && req.method === "POST") {
452
+ try {
453
+ const error = await req.json();
454
+ this._capturedErrors.push(error);
455
+ if (this._capturedErrors.length > 50) this._capturedErrors.shift();
456
+ } catch {}
457
+ return Response.json({ ok: true });
458
+ }
459
+
460
+ // ── Return captured errors ──
461
+ if (url.pathname === "/api/errors") {
462
+ return Response.json(this._capturedErrors.slice(-10));
463
+ }
464
+
465
+ // ── Local process metrics ──
466
+ if (url.pathname === "/api/local/metrics") {
467
+ const metrics = this.getLocalMetrics();
468
+ if (metrics) return Response.json(metrics);
469
+ return Response.json({ error: "No local process" }, { status: 404 });
470
+ }
471
+
472
+ // ── Local process logs ──
473
+ if (url.pathname === "/api/local/logs") {
474
+ return Response.json(this.getLocalLogs());
475
+ }
476
+
477
+ // ── Platform metrics ──
478
+ if (url.pathname === "/api/metrics" && sid !== "local") {
479
+ try {
480
+ const data = await platform.getMetrics(sid, 20);
481
+ const latest: any = data?.latest ?? {};
482
+ return Response.json({
483
+ pnl: latest.total_pnl ?? 0, rpnl: latest.realized_pnl ?? 0,
484
+ upnl: latest.unrealized_pnl ?? 0, orders: latest.open_order_count ?? 0,
485
+ positions: latest.position_count ?? 0, trades: latest.total_trades ?? 0,
486
+ win_rate: latest.win_rate ?? 0, sharpe: latest.sharpe_ratio ?? 0,
487
+ max_dd: latest.max_drawdown_pct ?? 0, exposure: latest.total_exposure ?? 0,
488
+ });
489
+ } catch {
490
+ return Response.json({ error: "Platform unavailable" }, { status: 502 });
491
+ }
492
+ }
493
+
494
+ // ── Platform logs ──
495
+ if (url.pathname === "/api/logs" && sid !== "local") {
496
+ try { return Response.json(await platform.getLogs(sid, 50)); }
497
+ catch { return Response.json([], { status: 502 }); }
498
+ }
499
+
500
+ // ── Strategy metadata ──
501
+ if (url.pathname === "/api/strategy") {
502
+ const draft = store.getActiveSession()?.strategyDraft;
503
+ return Response.json(draft
504
+ ? { name: draft.name, code: draft.code, params: draft.params, riskConfig: draft.riskConfig }
505
+ : { error: "No strategy loaded" });
506
+ }
507
+
508
+ // ── Local process logs (for custom dashboard compatibility) ──
509
+ if (url.pathname === "/api/local-logs") {
510
+ const allLogs: Record<string, { stdout: string[]; stderr: string[]; alive: boolean }> = {};
511
+ for (const [pid, managed] of runningProcesses) {
512
+ allLogs[pid] = { stdout: managed.stdout.slice(-50), stderr: managed.stderr.slice(-20), alive: managed.proc.exitCode === null };
513
+ }
514
+ return Response.json(allLogs);
515
+ }
516
+
517
+ // ── Serve HTML from file (re-read each request for live editing) ──
518
+ try {
519
+ const file = Bun.file(absolutePath);
520
+ const html = await file.text();
521
+ const injected = injectErrorScript(html);
522
+ return new Response(injected, {
523
+ headers: { "Content-Type": "text/html" },
524
+ });
525
+ } catch (e: any) {
526
+ return new Response(`<pre>Error reading dashboard file: ${e.message}</pre>`, {
527
+ status: 500,
528
+ headers: { "Content-Type": "text/html" },
529
+ });
530
+ }
531
+ },
532
+ });
533
+
534
+ this._port = this.server.port ?? 0;
535
+ return this.url;
536
+ }
537
+
538
+ /** Clear captured JS errors */
539
+ clearErrors(): void {
540
+ this._capturedErrors = [];
541
+ }
542
+
302
543
  stop(): void {
303
544
  if (this.server) {
304
545
  this.server.stop();
305
546
  this.server = null;
306
547
  this._port = 0;
548
+ this._capturedErrors = [];
307
549
  }
308
550
  }
309
551
  }