4bnode 4.1.9 → 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.
- package/add-bonjour.js +29 -1
- package/add-email.js +33 -15
- package/add-serialport.js +133 -41
- package/index.js +3 -3
- package/lib/codegen.js +1442 -36
- package/lib/codegen.test.js +233 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +116 -7
- package/skeleton/.4bnode/assets/js/components/CommandPalette.js +165 -0
- package/skeleton/.4bnode/assets/js/components/SerialWorkspace.js +634 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +64 -64
- package/skeleton/.4bnode/assets/js/lib.js +76 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +113 -53
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +240 -403
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +86 -8
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +37 -19
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1 -1
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +5 -5
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +154 -3
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +455 -139
- package/skeleton/.4bnode/codegen.js +1442 -36
- package/skeleton/.4bnode/dev-api.js +568 -95
- package/skeleton/.4bnode/ui.html +1241 -240
- package/skeleton/.env.example +23 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +8 -0
- package/skeleton/public/index.html +345 -237
- package/skeleton/src/discovery.js +128 -6
- package/skeleton/src/middleware/apiKey.js +63 -20
|
@@ -1,435 +1,272 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
|
9
|
-
const
|
|
10
|
-
const dbOk = data.mongoConfigured;
|
|
11
|
-
const
|
|
12
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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="
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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=${{
|
|
164
|
-
<div
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
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="
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
html`<div
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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="
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
}
|