4bnode 4.2.0 → 4.2.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,435 +1,272 @@
1
- import React from "react";
2
- import { LuZap, LuRocket } from "react-icons/lu";
3
- import { html, Icons } from "../lib.js";
1
+ import React, { useState, useRef, useEffect } from "react";
2
+ import {
3
+ LuActivity, LuTimer, LuCpu, LuGauge, LuCheck, LuArrowRight, LuPlus, LuExternalLink, LuBookOpen,
4
+ } from "react-icons/lu";
5
+ import { html, Icons, formatBytes, formatDuration, timeAgo } from "../lib.js";
4
6
  import { useApp } from "../context.js";
5
7
 
8
+ function statusClass(code) {
9
+ if (code >= 500) return "err";
10
+ if (code >= 400) return "warn";
11
+ return "ok";
12
+ }
13
+
14
+ // Requests per minute over the last 30 minutes. Single series → no legend;
15
+ // the card title names it. Hover a column for the exact numbers.
16
+ function TrafficChart({ series }) {
17
+ const [hover, setHover] = useState(null);
18
+ const [W, setW] = useState(600);
19
+ const boxRef = useRef(null);
20
+ useEffect(() => {
21
+ const el = boxRef.current;
22
+ if (!el) return;
23
+ const ro = new ResizeObserver(() => setW(Math.max(200, el.clientWidth)));
24
+ ro.observe(el);
25
+ return () => ro.disconnect();
26
+ }, []);
27
+ const H = 168, padL = 28, padB = 20, padT = 6;
28
+ const max = Math.max(4, ...series.map((s) => s.count));
29
+ const niceMax = Math.ceil(max / 4) * 4;
30
+ const plotW = W - padL, plotH = H - padB - padT;
31
+ const slot = plotW / series.length;
32
+ const barW = Math.max(2, slot - 2); // 2px surface gap between bars
33
+ const y = (v) => padT + plotH - (v / niceMax) * plotH;
34
+ const ticks = [0, niceMax / 2, niceMax];
35
+ const fmtTime = (t) => new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
36
+
37
+ return html`<div className="ov-chart" ref=${boxRef} onMouseLeave=${() => setHover(null)}>
38
+ <svg viewBox=${`0 0 ${W} ${H}`} role="img"
39
+ aria-label=${`Requests per minute, last 30 minutes. Peak ${max}.`}>
40
+ ${ticks.map((t) => html`<g key=${t}>
41
+ <line className="grid" x1=${padL} x2=${W} y1=${y(t)} y2=${y(t)} />
42
+ <text className="axis" x=${padL - 6} y=${y(t) + 3.5} textAnchor="end">${t}</text>
43
+ </g>`)}
44
+ ${series.map((s, i) => {
45
+ const h = s.count ? Math.max(2, plotH - (y(s.count) - padT)) : 0;
46
+ const x = padL + i * slot + 1;
47
+ const r = Math.min(4, barW / 2, h);
48
+ // Rounded data-end, square baseline.
49
+ const d = h
50
+ ? `M${x},${padT + plotH} V${padT + plotH - h + r} Q${x},${padT + plotH - h} ${x + r},${padT + plotH - h} H${x + barW - r} Q${x + barW},${padT + plotH - h} ${x + barW},${padT + plotH - h + r} V${padT + plotH} Z`
51
+ : "";
52
+ return html`<g key=${s.t}>
53
+ ${h > 0 && html`<path d=${d} className=${`bar${hover !== null && hover !== i ? " dim" : ""}`} />`}
54
+ <rect x=${padL + i * slot} y=${padT} width=${slot} height=${plotH} fill="transparent"
55
+ onMouseEnter=${() => setHover(i)} />
56
+ </g>`;
57
+ })}
58
+ <text className="axis" x=${padL} y=${H - 4}>${fmtTime(series[0].t)}</text>
59
+ <text className="axis" x=${W} y=${H - 4} textAnchor="end">now</text>
60
+ </svg>
61
+ ${hover !== null && html`<div className="ov-tip"
62
+ style=${{ left: `${((padL + (hover + 0.5) * slot) / W) * 100}%`, top: `${(y(series[hover].count) / H) * 100}%` }}>
63
+ <div className="muted">${fmtTime(series[hover].t)}</div>
64
+ <div><b>${series[hover].count}</b> requests</div>
65
+ ${series[hover].errors > 0 && html`<div><b>${series[hover].errors}</b> <span className="muted">4xx/5xx</span></div>`}
66
+ </div>`}
67
+ </div>`;
68
+ }
69
+
70
+ function Kpi({ icon, label, value, unit, sub }) {
71
+ return html`<div className="ov-kpi">
72
+ <div className="ov-kpi-label"><${icon} size=${13} /> ${label}</div>
73
+ <div className="ov-kpi-value">${value}${unit && html`<small>${unit}</small>`}</div>
74
+ <div className="ov-kpi-sub">${sub}</div>
75
+ </div>`;
76
+ }
77
+
6
78
  export function DashboardPage() {
7
- const { data, setPage, status } = useApp();
8
- const modelCount = data.models?.length || 0;
9
- const routeCount = data.routes?.length || 0;
10
- const dbOk = data.mongoConfigured;
11
- const totalFields =
12
- data.models?.reduce((sum, m) => sum + (m.fields?.length || 0), 0) ||
13
- 0;
14
- const featuresActive = [
15
- status?.socketConfigured,
16
- status?.websocketConfigured,
17
- status?.serialportConfigured,
18
- ].filter(Boolean);
79
+ const { data, setPage, status, health } = useApp();
80
+ const models = data.models || [];
81
+ const routes = data.routes || [];
82
+ const dbOk = data.mongoConfigured || !!status.sqlProvider;
83
+ const m = health?.metrics;
84
+ const sec = status.security || {};
19
85
 
20
86
  const steps = [
21
- {
22
- label: "Database",
23
- done: dbOk,
24
- page: "database",
25
- icon: Icons.database,
26
- },
27
- {
28
- label: "Schemas",
29
- done: modelCount > 0,
30
- page: "schemas",
31
- icon: Icons.schema,
32
- },
33
- {
34
- label: "Routes",
35
- done: routeCount > 0,
36
- page: "routes",
37
- icon: Icons.route,
38
- },
87
+ { label: "Connect a database", done: dbOk, page: "database" },
88
+ { label: "Define a schema", done: models.length > 0, page: "schemas" },
89
+ { label: "Create an API route", done: routes.length > 0, page: "routes" },
90
+ { label: "Generate API docs", done: !!status.docsConfigured, page: "docs" },
91
+ { label: "Harden security", done: !!(sec.helmet && sec.rateLimit && sec.errorHandler), page: "security" },
92
+ ];
93
+ const doneCount = steps.filter((s) => s.done).length;
94
+ const nextStep = steps.find((s) => !s.done);
95
+
96
+ const features = [
97
+ { label: "Socket.IO", on: status.socketConfigured, page: "realtime" },
98
+ { label: "WebSocket", on: status.websocketConfigured, page: "realtime" },
99
+ { label: "SerialPort", on: status.serialportConfigured, page: "serialport" },
100
+ { label: "Email", on: status.mailConfigured, page: "mail" },
101
+ { label: "API Docs", on: status.docsConfigured, page: "docs" },
102
+ { label: "Helmet", on: sec.helmet, page: "security" },
103
+ { label: "Rate limiting", on: sec.rateLimit, page: "security" },
104
+ { label: "Validation", on: sec.validation, page: "security" },
39
105
  ];
40
- const firstIncomplete = steps.findIndex((s) => !s.done);
41
- const allDone = steps.every((s) => s.done);
42
106
 
43
- const nextAction = !dbOk
44
- ? {
45
- label: "Connect Database",
46
- page: "database",
47
- desc: "Set up your database connection to get started",
48
- }
49
- : modelCount === 0
50
- ? {
51
- label: "Create First Schema",
52
- page: "schemas",
53
- desc: "Define your data models to structure your application",
54
- }
55
- : routeCount === 0
56
- ? {
57
- label: "Create First Route",
58
- page: "routes",
59
- desc: "Add API endpoints to expose your data",
60
- }
61
- : null;
107
+ const errorRate = m && m.total ? ((m.errors / m.total) * 100).toFixed(1) : null;
108
+ const heapPct = health ? Math.round((health.memory.heapUsed / health.memory.heapTotal) * 100) : 0;
62
109
 
63
110
  return html`<div>
64
- <div className="pipeline">
65
- ${steps.map(
66
- (step, i) =>
67
- html`<${React.Fragment} key=${i}>
68
- ${i > 0 &&
69
- html`<div
70
- className=${`pipeline-connector ${steps[i - 1].done ? "done" : ""}`}
71
- />`}
72
- <div
73
- className="pipeline-step"
74
- onClick=${() => setPage(step.page)}
75
- style=${{ cursor: "pointer" }}
76
- >
77
- <div
78
- className=${`pipeline-dot ${step.done ? "done" : i === firstIncomplete ? "current" : ""}`}
79
- >
80
- ${step.done ? "\u2713" : i + 1}
81
- </div>
82
- <div
83
- className=${`pipeline-label ${step.done ? "done" : i === firstIncomplete ? "current" : ""}`}
84
- >
85
- ${step.label}
86
- </div>
87
- </div>
88
- <//>`,
89
- )}
111
+ <div className="page-head">
112
+ <div>
113
+ <h1>${status.appName || "Your project"}</h1>
114
+ <div className="page-meta">
115
+ <span>Express API</span>
116
+ ${health && html`<span>Node ${health.node}</span>`}
117
+ ${health && html`<span>Port ${health.port}</span>`}
118
+ ${health && html`<span>Up ${formatDuration(health.uptime)}</span>`}
119
+ ${health && html`<span>${health.env}</span>`}
120
+ </div>
121
+ </div>
122
+ <div className="page-actions">
123
+ ${status.docsConfigured && html`<a className="btn btn-secondary" href="/docs" target="_blank" rel="noopener">
124
+ <${LuBookOpen} size=${14} /> Docs
125
+ </a>`}
126
+ <a className="btn btn-secondary" href="/" target="_blank" rel="noopener"><${LuExternalLink} size=${14} /> Open app</a>
127
+ <button className="btn btn-primary" onClick=${() => setPage("routes")}><${LuPlus} size=${14} /> New route</button>
128
+ </div>
90
129
  </div>
91
130
 
92
- ${nextAction &&
93
- html`<div
94
- className="card mb-6"
95
- style=${{
96
- borderColor: "var(--accent)",
97
- borderWidth: "1px",
98
- borderStyle: "solid",
99
- background:
100
- "linear-gradient(135deg, rgba(124,58,237,0.06), rgba(124,58,237,0.02))",
101
- }}
102
- >
103
- <div className="flex items-center gap-4">
104
- <div
105
- style=${{
106
- width: "44px",
107
- height: "44px",
108
- borderRadius: "12px",
109
- background: "var(--accent-glow)",
110
- display: "flex",
111
- alignItems: "center",
112
- justifyContent: "center",
113
- flexShrink: 0,
114
- }}
115
- >
116
- <${LuZap} size=${20} color="var(--accent-light)" />
131
+ <div className="ov-kpis">
132
+ <${Kpi} icon=${LuActivity} label="Requests / min"
133
+ value=${m?.enabled ? m.rpm : "—"}
134
+ sub=${m?.enabled ? `${m.total.toLocaleString()} since start` : "Metrics not wired"} />
135
+ <${Kpi} icon=${LuTimer} label="Latency p95"
136
+ value=${m?.p95 != null ? m.p95 : "—"} unit=${m?.p95 != null ? "ms" : ""}
137
+ sub=${m?.p50 != null ? `p50 ${m.p50} ms` : "No traffic yet"} />
138
+ <${Kpi} icon=${LuGauge} label="Error rate"
139
+ value=${errorRate != null ? errorRate : "—"} unit=${errorRate != null ? "%" : ""}
140
+ sub=${m?.enabled ? `${m.errors} server errors (5xx)` : "—"} />
141
+ <${Kpi} icon=${LuCpu} label="Memory"
142
+ value=${health ? formatBytes(health.memory.rss) : "—"}
143
+ sub=${health ? `Heap ${formatBytes(health.memory.heapUsed)} · ${heapPct}%` : "Server unreachable"} />
144
+ </div>
145
+
146
+ <div className="ov-row">
147
+ <div className="ov-card">
148
+ <div className="ov-card-head">
149
+ <span className="ov-card-title">Requests per minute</span>
150
+ <span className="ov-num">Last 30 min</span>
117
151
  </div>
118
- <div style=${{ flex: 1 }}>
119
- <div
120
- style=${{
121
- fontWeight: 600,
122
- fontSize: "14px",
123
- marginBottom: "2px",
124
- }}
125
- >
126
- Next Step: ${nextAction.label}
127
- </div>
128
- <div className="text-sm text-muted">${nextAction.desc}</div>
152
+ <div className="ov-card-body">
153
+ ${!health
154
+ ? html`<div className="ov-empty">Waiting for the server…</div>`
155
+ : !m.enabled
156
+ ? html`<div className="ov-empty">
157
+ Live traffic needs the metrics middleware. In <code>index.js</code>, after importing the dashboard, add:<br />
158
+ <code>if (devApi.requestMetrics) app.use(devApi.requestMetrics);</code>
159
+ </div>`
160
+ : html`<${TrafficChart} series=${m.series} />`}
129
161
  </div>
130
- <button
131
- className="btn btn-primary"
132
- onClick=${() => setPage(nextAction.page)}
133
- >
134
- ${nextAction.label}
135
- </button>
136
162
  </div>
137
- </div>`}
138
- ${allDone &&
139
- html`<div
140
- className="card mb-6"
141
- style=${{
142
- borderColor: "rgba(34,197,94,0.3)",
143
- background:
144
- "linear-gradient(135deg, rgba(34,197,94,0.06), rgba(34,197,94,0.02))",
145
- }}
146
- >
147
- <div className="flex items-center gap-4">
148
- <div
149
- style=${{
150
- width: "44px",
151
- height: "44px",
152
- borderRadius: "12px",
153
- background: "var(--success-glow)",
154
- display: "flex",
155
- alignItems: "center",
156
- justifyContent: "center",
157
- flexShrink: 0,
158
- fontSize: "20px",
159
- }}
160
- >
161
- \u2713
163
+
164
+ <div className="ov-card">
165
+ <div className="ov-card-head">
166
+ <span className="ov-card-title">Setup</span>
167
+ <span className="ov-num">${doneCount} of ${steps.length}</span>
162
168
  </div>
163
- <div style=${{ flex: 1 }}>
164
- <div
165
- style=${{
166
- fontWeight: 600,
167
- fontSize: "14px",
168
- marginBottom: "2px",
169
- color: "var(--success)",
170
- }}
171
- >
172
- Project Ready
173
- </div>
174
- <div className="text-sm text-muted">
175
- Database, schemas, and routes are configured. Add features or
176
- refine your API.
177
- </div>
169
+ <div style=${{ padding: "12px 16px 4px" }}>
170
+ <div className="ov-progress"><span style=${{ width: `${(doneCount / steps.length) * 100}%` }} /></div>
171
+ </div>
172
+ <div className="ov-list">
173
+ ${steps.map((s) => html`<div key=${s.label} className="ov-list-row clickable" onClick=${() => setPage(s.page)}>
174
+ <span className=${`ov-check${s.done ? " done" : ""}`}>${s.done && html`<${LuCheck} size=${10} strokeWidth=${3} />`}</span>
175
+ <span style=${{ flex: 1, color: s.done ? "var(--text-muted)" : "var(--text)", textDecoration: s.done ? "line-through" : "none" }}>${s.label}</span>
176
+ ${s === nextStep && html`<span className="badge badge-info">Next</span>`}
177
+ <${LuArrowRight} size=${13} color="var(--text-muted)" />
178
+ </div>`)}
178
179
  </div>
179
180
  </div>
180
- </div>`}
181
+ </div>
181
182
 
182
- <div
183
- className="stats-grid"
184
- style=${{ gridTemplateColumns: "repeat(4, 1fr)" }}
185
- >
186
- <div
187
- className="stat-card"
188
- onClick=${() => setPage("database")}
189
- style=${{ cursor: "pointer" }}
190
- >
191
- <div
192
- className="flex items-center gap-2"
193
- style=${{ marginBottom: "10px" }}
194
- >
195
- <span className="stat-label" style=${{ margin: 0 }}
196
- >Database</span
197
- >
198
- <span
199
- className=${`stat-badge ${dbOk ? "success" : "warning"}`}
200
- style=${{ marginLeft: "auto" }}
201
- >${dbOk ? "Connected" : "Pending"}</span
202
- >
183
+ <div className="ov-row even">
184
+ <div className="ov-card">
185
+ <div className="ov-card-head">
186
+ <span className="ov-card-title">Recent requests</span>
187
+ ${m?.enabled && html`<span className="ov-num"><span className="status-dot on" style=${{ display: "inline-block", marginRight: 6 }} />Live</span>`}
203
188
  </div>
204
- <span
205
- className="stat-value"
206
- style=${{ fontSize: "18px", fontWeight: 600 }}
207
- >${dbOk ? data.dbType || "MongoDB" : "\u2014"}</span
208
- >
209
- </div>
210
- <div
211
- className="stat-card"
212
- onClick=${() => setPage("schemas")}
213
- style=${{ cursor: "pointer" }}
214
- >
215
- <span className="stat-label">Models</span>
216
- <div className="flex items-center gap-2">
217
- <span className="stat-value">${modelCount}</span>
218
- <span
219
- className="text-sm text-muted"
220
- style=${{ marginTop: "6px" }}
221
- >${totalFields} field${totalFields !== 1 ? "s" : ""}</span
222
- >
189
+ <div className="ov-list">
190
+ ${!m?.recent?.length
191
+ ? html`<div className="ov-empty">No API traffic yet. Send one from the <a href="#/api-tester" style=${{ color: "var(--accent-light)" }}>API Tester</a>.</div>`
192
+ : m.recent.slice(0, 8).map((r, i) => html`<div key=${r.ts + "-" + i} className="ov-list-row">
193
+ <span className="ov-method"><span className=${`route-method ${r.method.toLowerCase()}`}>${r.method}</span></span>
194
+ <span className="ov-path" title=${r.path}>${r.path}</span>
195
+ <span className=${`ov-status ${statusClass(r.status)}`}>${r.status}</span>
196
+ <span className="ov-num" style=${{ width: 54, textAlign: "right" }}>${r.ms} ms</span>
197
+ <span className="ov-num" style=${{ width: 60, textAlign: "right" }}>${timeAgo(r.ts)}</span>
198
+ </div>`)}
223
199
  </div>
224
200
  </div>
225
- <div
226
- className="stat-card"
227
- onClick=${() => setPage("routes")}
228
- style=${{ cursor: "pointer" }}
229
- >
230
- <span className="stat-label">Routes</span>
231
- <span className="stat-value">${routeCount}</span>
232
- </div>
233
- <div className="stat-card">
234
- <span className="stat-label">Features</span>
235
- <span className="stat-value">${featuresActive.length}</span>
236
- <div
237
- className="flex gap-1"
238
- style=${{ flexWrap: "wrap", marginTop: "4px" }}
239
- >
240
- ${status?.socketConfigured &&
241
- html`<span className="stat-badge info">Socket.io</span>`}
242
- ${status?.websocketConfigured &&
243
- html`<span className="stat-badge info">WebSocket</span>`}
244
- ${status?.serialportConfigured &&
245
- html`<span className="stat-badge info">Serial</span>`}
246
- ${featuresActive.length === 0 &&
247
- html`<span className="stat-badge info">None yet</span>`}
201
+
202
+ <div className="ov-card">
203
+ <div className="ov-card-head">
204
+ <span className="ov-card-title">Project</span>
205
+ <button className="btn btn-ghost btn-sm" onClick=${() => setPage("schemas")} disabled=${!data.mongoConfigured}>Schemas</button>
206
+ </div>
207
+ <div className="ov-list">
208
+ <div className="ov-list-row clickable" onClick=${() => setPage("database")}>
209
+ <span className="sidebar-icon">${Icons.database}</span>
210
+ <span style=${{ flex: 1 }}>Database</span>
211
+ <span className=${`badge ${dbOk ? "badge-success" : "badge-muted"}`}>
212
+ ${dbOk ? (status.sqlProvider || data.dbType || "Connected") : "Not connected"}
213
+ </span>
214
+ </div>
215
+ <div className="ov-list-row clickable" onClick=${() => data.mongoConfigured && setPage("schemas")}>
216
+ <span className="sidebar-icon">${Icons.schema}</span>
217
+ <span style=${{ flex: 1 }}>Models</span>
218
+ <span className="ov-num">${models.length} · ${models.reduce((n, x) => n + (x.fields?.length || 0), 0)} fields</span>
219
+ </div>
220
+ <div className="ov-list-row clickable" onClick=${() => setPage("routes")}>
221
+ <span className="sidebar-icon">${Icons.route}</span>
222
+ <span style=${{ flex: 1 }}>Route files</span>
223
+ <span className="ov-num">${routes.length}</span>
224
+ </div>
225
+ <div style=${{ padding: "12px 16px", display: "flex", flexWrap: "wrap", gap: 6, borderTop: "1px solid var(--border)" }}>
226
+ ${features.map((f) => html`<button key=${f.label} onClick=${() => setPage(f.page)}
227
+ className=${`badge ${f.on ? "badge-success" : "badge-muted"}`} style=${{ border: "none", cursor: "pointer" }}>
228
+ ${f.on ? "●" : "○"} ${f.label}
229
+ </button>`)}
230
+ </div>
248
231
  </div>
249
232
  </div>
250
233
  </div>
251
234
 
252
- ${(modelCount > 0 || routeCount > 0) &&
253
- html`<div className="grid-2">
254
- ${modelCount > 0 &&
255
- html`<div className="card">
256
- <div className="flex items-center justify-between mb-3">
257
- <div
258
- className="card-title"
259
- style=${{ margin: 0, fontSize: "13px" }}
260
- >
261
- Models
262
- </div>
263
- <button
264
- className="btn btn-ghost btn-sm"
265
- onClick=${() => setPage("schemas")}
266
- style=${{ fontSize: "11px" }}
267
- >
268
- View All
269
- </button>
235
+ <div className="ov-row even">
236
+ <div className="ov-card">
237
+ <div className="ov-card-head">
238
+ <span className="ov-card-title">Recent errors</span>
239
+ <button className="btn btn-ghost btn-sm" onClick=${() => setPage("logs")}>View logs</button>
270
240
  </div>
271
- ${data.models.slice(0, 5).map(
272
- (m) =>
273
- html`<div
274
- key=${m.name}
275
- className="flex items-center gap-3"
276
- style=${{
277
- padding: "8px 0",
278
- borderBottom: "1px solid rgba(255,255,255,0.04)",
279
- }}
280
- >
281
- <div
282
- style=${{
283
- width: "28px",
284
- height: "28px",
285
- borderRadius: "6px",
286
- background: "var(--accent-glow)",
287
- display: "flex",
288
- alignItems: "center",
289
- justifyContent: "center",
290
- flexShrink: 0,
291
- }}
292
- >
293
- <span
294
- style=${{
295
- fontSize: "11px",
296
- fontWeight: 700,
297
- color: "var(--accent-light)",
298
- }}
299
- >${m.name.charAt(0).toUpperCase()}</span
300
- >
301
- </div>
302
- <div style=${{ flex: 1, minWidth: 0 }}>
303
- <div style=${{ fontWeight: 500, fontSize: "13px" }}>
304
- ${m.name}
305
- </div>
306
- <div
307
- className="text-sm text-muted"
308
- style=${{ fontSize: "11px" }}
309
- >
310
- ${m.fields?.length || 0} fields
311
- </div>
312
- </div>
313
- <div className="flex gap-1">
314
- ${(m.fields || []).filter((f) => f.required).length > 0 &&
315
- html`<span
316
- style=${{
317
- fontSize: "9px",
318
- padding: "2px 6px",
319
- borderRadius: "4px",
320
- background: "var(--success-glow)",
321
- color: "var(--success)",
322
- fontWeight: 600,
323
- }}
324
- >${(m.fields || []).filter((f) => f.required).length}
325
- REQ</span
326
- >`}
327
- ${(m.fields || []).filter((f) => f.unique).length > 0 &&
328
- html`<span
329
- style=${{
330
- fontSize: "9px",
331
- padding: "2px 6px",
332
- borderRadius: "4px",
333
- background: "var(--warning-glow)",
334
- color: "var(--warning)",
335
- fontWeight: 600,
336
- }}
337
- >${(m.fields || []).filter((f) => f.unique).length}
338
- UNQ</span
339
- >`}
340
- </div>
341
- </div>`,
342
- )}
343
- ${modelCount > 5 &&
344
- html`<div
345
- className="text-sm text-muted"
346
- style=${{ padding: "8px 0", textAlign: "center" }}
347
- >
348
- +${modelCount - 5} more
349
- </div>`}
350
- </div>`}
351
- ${routeCount > 0 &&
352
- html`<div className="card">
353
- <div className="flex items-center justify-between mb-3">
354
- <div
355
- className="card-title"
356
- style=${{ margin: 0, fontSize: "13px" }}
357
- >
358
- Routes
359
- </div>
360
- <button
361
- className="btn btn-ghost btn-sm"
362
- onClick=${() => setPage("routes")}
363
- style=${{ fontSize: "11px" }}
364
- >
365
- View All
366
- </button>
241
+ <div className="ov-list">
242
+ ${!health?.recentErrors?.length
243
+ ? html`<div className="ov-empty">No errors logged. Nice.</div>`
244
+ : health.recentErrors.map((e) => html`<div key=${e.id} className="ov-err" title=${e.message}>
245
+ <span>${timeAgo(new Date(e.timestamp).getTime())}</span>${e.message.split("\n")[0]}
246
+ </div>`)}
367
247
  </div>
368
- ${data.routes.slice(0, 5).map((r) => {
369
- const rName = r.replace(".js", "");
370
- return html`<div
371
- key=${r}
372
- className="flex items-center gap-3"
373
- style=${{
374
- padding: "8px 0",
375
- borderBottom: "1px solid rgba(255,255,255,0.04)",
376
- }}
377
- >
378
- <div
379
- style=${{
380
- width: "28px",
381
- height: "28px",
382
- borderRadius: "6px",
383
- background: "rgba(59,130,246,0.1)",
384
- display: "flex",
385
- alignItems: "center",
386
- justifyContent: "center",
387
- flexShrink: 0,
388
- }}
389
- >
390
- <span
391
- style=${{
392
- fontSize: "11px",
393
- fontWeight: 700,
394
- color: "#60a5fa",
395
- }}
396
- >${rName.charAt(0).toUpperCase()}</span
397
- >
398
- </div>
399
- <div style=${{ flex: 1, minWidth: 0 }}>
400
- <div style=${{ fontWeight: 500, fontSize: "13px" }}>
401
- ${rName}
248
+ </div>
249
+
250
+ <div className="ov-card">
251
+ <div className="ov-card-head">
252
+ <span className="ov-card-title">Process</span>
253
+ ${health && html`<span className="ov-num">pid ${health.pid}</span>`}
254
+ </div>
255
+ ${health
256
+ ? html`<div>
257
+ <div className="ov-meter">
258
+ <div className="ov-meter-top"><span>Heap</span><span>${formatBytes(health.memory.heapUsed)} / ${formatBytes(health.memory.heapTotal)}</span></div>
259
+ <div className="ov-progress"><span style=${{ width: `${heapPct}%` }} /></div>
402
260
  </div>
403
- <div
404
- className="text-sm text-muted"
405
- style=${{
406
- fontSize: "11px",
407
- fontFamily: "'SF Mono', monospace",
408
- }}
409
- >
410
- src/routes/${r}
261
+ <div className="ov-meter">
262
+ <div className="ov-meter-top"><span>CPU load (1m)</span><span>${health.load.toFixed(2)} / ${health.cpus} cores</span></div>
263
+ <div className="ov-progress"><span style=${{ width: `${Math.min(100, (health.load / health.cpus) * 100)}%` }} /></div>
411
264
  </div>
412
- </div>
413
- </div>`;
414
- })}
415
- ${routeCount > 5 &&
416
- html`<div
417
- className="text-sm text-muted"
418
- style=${{ padding: "8px 0", textAlign: "center" }}
419
- >
420
- +${routeCount - 5} more
421
- </div>`}
422
- </div>`}
423
- </div>`}
424
- ${modelCount === 0 &&
425
- routeCount === 0 &&
426
- !nextAction &&
427
- html`<div className="card">
428
- <div className="empty-state">
429
- <div className="empty-icon"><${LuRocket} size=${36} /></div>
430
- <h3>Ready to Build</h3>
431
- <p>Start by creating schemas and routes for your API</p>
265
+ <div className="ov-list-row"><span style=${{ flex: 1, color: "var(--text-muted)" }}>Platform</span><span>${health.platform}</span></div>
266
+ <div className="ov-list-row"><span style=${{ flex: 1, color: "var(--text-muted)" }}>Started</span><span>${new Date(health.startedAt).toLocaleString()}</span></div>
267
+ </div>`
268
+ : html`<div className="ov-empty">Server unreachable — it may be restarting.</div>`}
432
269
  </div>
433
- </div>`}
270
+ </div>
434
271
  </div>`;
435
272
  }