@webda/debug 4.0.0-beta.5 → 4.0.0-beta.7
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/README.md +5 -6
- package/lib/debugservice.service.d.ts +134 -23
- package/lib/debugservice.service.js +417 -73
- package/lib/debugservice.service.js.map +1 -1
- package/lib/introspection.d.ts +17 -0
- package/lib/introspection.js +52 -6
- package/lib/introspection.js.map +1 -1
- package/lib/security.d.ts +155 -0
- package/lib/security.js +217 -0
- package/lib/security.js.map +1 -0
- package/lib/tui/client.d.ts +4 -1
- package/lib/tui/client.js +10 -3
- package/lib/tui/client.js.map +1 -1
- package/lib/tui/tui.d.ts +2 -1
- package/lib/tui/tui.js +6 -3
- package/lib/tui/tui.js.map +1 -1
- package/package.json +13 -8
- package/webda.module.json +129 -0
- package/webui/assets/favicon-CVPPk1M-.svg +1 -0
- package/webui/assets/index-BHmmE2oC.css +1 -0
- package/webui/assets/index-RYFIlyiZ.js +51 -0
- package/webui/index.html +13 -20
- package/webui/app.js +0 -177
- package/webui/components/logs.js +0 -128
- package/webui/components/model-graph.js +0 -292
- package/webui/components/models.js +0 -213
- package/webui/components/operations.js +0 -315
- package/webui/components/requests.js +0 -264
- package/webui/components/schema-form.js +0 -268
- package/webui/components/services.js +0 -248
- package/webui/logo.svg +0 -1
- package/webui/styles.css +0 -685
- package/webui/vendor/htm.js +0 -1
- package/webui/vendor/preact-hooks.js +0 -2
- package/webui/vendor/preact.js +0 -2
- package/webui/webda.png +0 -0
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { h } from "preact";
|
|
2
|
-
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
3
|
-
import htm from "htm";
|
|
4
|
-
|
|
5
|
-
const html = htm.bind(h);
|
|
6
|
-
|
|
7
|
-
function statusClass(code) {
|
|
8
|
-
if (!code) return "status-pending";
|
|
9
|
-
if (code < 300) return "status-2xx";
|
|
10
|
-
if (code < 400) return "status-3xx";
|
|
11
|
-
if (code < 500) return "status-4xx";
|
|
12
|
-
return "status-5xx";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function methodBadge(method) {
|
|
16
|
-
if (!method) return "";
|
|
17
|
-
const m = method.toUpperCase();
|
|
18
|
-
return html`<span class="badge method-${m.toLowerCase()}">${m}</span>`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function formatTime(ts) {
|
|
22
|
-
if (!ts) return "-";
|
|
23
|
-
const d = new Date(ts);
|
|
24
|
-
return d.toLocaleTimeString();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function formatBytes(n) {
|
|
28
|
-
if (n == null) return "-";
|
|
29
|
-
if (n < 1024) return `${n} B`;
|
|
30
|
-
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
31
|
-
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Render a request/response body in the most useful format we can:
|
|
36
|
-
* - empty -> grey placeholder
|
|
37
|
-
* - text -> preformatted block (pretty-printed JSON when possible)
|
|
38
|
-
* - text-truncated -> same plus a [truncated] notice
|
|
39
|
-
* - binary -> "Binary, N bytes (preview: 0xHEX)"
|
|
40
|
-
*/
|
|
41
|
-
function BodyView({ body }) {
|
|
42
|
-
if (!body) return html`<div style="color:var(--text-muted);font-style:italic">(not captured)</div>`;
|
|
43
|
-
if (body.kind === "empty") {
|
|
44
|
-
return html`<div style="color:var(--text-muted);font-style:italic">(empty)</div>`;
|
|
45
|
-
}
|
|
46
|
-
if (body.kind === "binary") {
|
|
47
|
-
return html`
|
|
48
|
-
<div style="color:var(--text-muted);font-family:monospace;font-size:13px">
|
|
49
|
-
Binary, ${formatBytes(body.size)} (preview: 0x${body.preview || ""})
|
|
50
|
-
</div>
|
|
51
|
-
`;
|
|
52
|
-
}
|
|
53
|
-
// text or text-truncated
|
|
54
|
-
let display = body.content || "";
|
|
55
|
-
// Try to pretty-print JSON
|
|
56
|
-
try {
|
|
57
|
-
const parsed = JSON.parse(display);
|
|
58
|
-
display = JSON.stringify(parsed, null, 2);
|
|
59
|
-
} catch {
|
|
60
|
-
// not JSON — leave as-is
|
|
61
|
-
}
|
|
62
|
-
return html`
|
|
63
|
-
<pre class="mono" style="background:var(--bg-secondary);padding:0.75rem;border-radius:4px;overflow:auto;max-height:400px;font-size:12px;white-space:pre-wrap;word-break:break-all">${display}</pre>
|
|
64
|
-
${body.kind === "text-truncated"
|
|
65
|
-
? html`<div style="color:var(--text-muted);font-size:12px;margin-top:0.25rem">[truncated — total ${formatBytes(body.size)}]</div>`
|
|
66
|
-
: ""}
|
|
67
|
-
`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Render a flat list of headers as a small two-column table.
|
|
72
|
-
*/
|
|
73
|
-
function HeadersView({ headers }) {
|
|
74
|
-
const keys = headers ? Object.keys(headers) : [];
|
|
75
|
-
if (keys.length === 0) {
|
|
76
|
-
return html`<div style="color:var(--text-muted);font-style:italic">(none)</div>`;
|
|
77
|
-
}
|
|
78
|
-
return html`
|
|
79
|
-
<table style="width:100%;font-size:12px">
|
|
80
|
-
<tbody>
|
|
81
|
-
${keys.sort().map(
|
|
82
|
-
k => html`
|
|
83
|
-
<tr key=${k}>
|
|
84
|
-
<td class="mono" style="color:var(--text-muted);padding:2px 8px 2px 0;vertical-align:top;white-space:nowrap">${k}</td>
|
|
85
|
-
<td class="mono" style="word-break:break-all">${headers[k]}</td>
|
|
86
|
-
</tr>
|
|
87
|
-
`
|
|
88
|
-
)}
|
|
89
|
-
</tbody>
|
|
90
|
-
</table>
|
|
91
|
-
`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Detail panel for a single request: status line, headers, bodies, error.
|
|
96
|
-
* Loads the full entry from /api/requests/:id when `id` changes.
|
|
97
|
-
*/
|
|
98
|
-
function RequestDetail({ id, onClose }) {
|
|
99
|
-
const [entry, setEntry] = useState(null);
|
|
100
|
-
const [error, setError] = useState(null);
|
|
101
|
-
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
if (!id) {
|
|
104
|
-
setEntry(null);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
let cancelled = false;
|
|
108
|
-
setError(null);
|
|
109
|
-
fetch(`/api/requests/${encodeURIComponent(id)}`)
|
|
110
|
-
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
111
|
-
.then(data => {
|
|
112
|
-
if (!cancelled) setEntry(data);
|
|
113
|
-
})
|
|
114
|
-
.catch(err => {
|
|
115
|
-
if (!cancelled) setError(err.message);
|
|
116
|
-
});
|
|
117
|
-
return () => {
|
|
118
|
-
cancelled = true;
|
|
119
|
-
};
|
|
120
|
-
}, [id]);
|
|
121
|
-
|
|
122
|
-
if (!id) return null;
|
|
123
|
-
if (error) {
|
|
124
|
-
return html`
|
|
125
|
-
<div style="padding:1rem;border:1px solid var(--border);border-radius:4px;margin-top:1rem;background:var(--bg-secondary)">
|
|
126
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
|
127
|
-
<strong>Failed to load request detail</strong>
|
|
128
|
-
<button onClick=${onClose} style="background:transparent;border:1px solid var(--border);color:var(--text-primary);padding:2px 8px;border-radius:4px;cursor:pointer">Close</button>
|
|
129
|
-
</div>
|
|
130
|
-
<div style="color:var(--text-muted)">${error}</div>
|
|
131
|
-
</div>
|
|
132
|
-
`;
|
|
133
|
-
}
|
|
134
|
-
if (!entry) {
|
|
135
|
-
return html`
|
|
136
|
-
<div style="padding:1rem;color:var(--text-muted)">Loading request detail...</div>
|
|
137
|
-
`;
|
|
138
|
-
}
|
|
139
|
-
return html`
|
|
140
|
-
<div style="padding:1rem;border:1px solid var(--border);border-radius:4px;margin-top:1rem;background:var(--bg-secondary)">
|
|
141
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
|
|
142
|
-
<div>
|
|
143
|
-
${methodBadge(entry.method)}
|
|
144
|
-
<span class="mono" style="margin-left:0.5rem">${entry.url}</span>
|
|
145
|
-
</div>
|
|
146
|
-
<button onClick=${onClose} style="background:transparent;border:1px solid var(--border);color:var(--text-primary);padding:2px 8px;border-radius:4px;cursor:pointer">Close</button>
|
|
147
|
-
</div>
|
|
148
|
-
<div style="display:flex;gap:1.5rem;font-size:12px;color:var(--text-muted);margin-bottom:1rem">
|
|
149
|
-
<div>Time: ${formatTime(entry.timestamp)}</div>
|
|
150
|
-
<div>Status: <span class="mono ${statusClass(entry.statusCode)}" style="font-weight:600">${entry.statusCode != null ? entry.statusCode : "pending"}</span></div>
|
|
151
|
-
<div>Duration: ${entry.duration != null ? `${entry.duration}ms` : "-"}</div>
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
${entry.error
|
|
155
|
-
? html`
|
|
156
|
-
<div style="margin-bottom:1rem;padding:0.5rem 0.75rem;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:4px">
|
|
157
|
-
<div style="color:#ef4444;font-weight:600;margin-bottom:0.25rem">Error</div>
|
|
158
|
-
<div class="mono" style="font-size:12px">${entry.error.message}</div>
|
|
159
|
-
${entry.error.stack
|
|
160
|
-
? html`<pre class="mono" style="font-size:11px;color:var(--text-muted);margin-top:0.5rem;white-space:pre-wrap">${entry.error.stack}</pre>`
|
|
161
|
-
: ""}
|
|
162
|
-
</div>
|
|
163
|
-
`
|
|
164
|
-
: ""}
|
|
165
|
-
|
|
166
|
-
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
|
167
|
-
<div>
|
|
168
|
-
<h4 style="margin:0 0 0.5rem 0;font-size:13px">Request Headers</h4>
|
|
169
|
-
<${HeadersView} headers=${entry.requestHeaders} />
|
|
170
|
-
<h4 style="margin:1rem 0 0.5rem 0;font-size:13px">Request Body</h4>
|
|
171
|
-
<${BodyView} body=${entry.requestBody} />
|
|
172
|
-
</div>
|
|
173
|
-
<div>
|
|
174
|
-
<h4 style="margin:0 0 0.5rem 0;font-size:13px">Response Headers</h4>
|
|
175
|
-
<${HeadersView} headers=${entry.responseHeaders} />
|
|
176
|
-
<h4 style="margin:1rem 0 0.5rem 0;font-size:13px">Response Body</h4>
|
|
177
|
-
<${BodyView} body=${entry.responseBody} />
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
</div>
|
|
181
|
-
`;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function RequestsPanel({ data, wsEvents }) {
|
|
185
|
-
const historical = data || [];
|
|
186
|
-
const [selectedId, setSelectedId] = useState(null);
|
|
187
|
-
|
|
188
|
-
// Merge websocket events with historical data
|
|
189
|
-
// wsEvents are newest-first; historical may be oldest-first or newest-first
|
|
190
|
-
const entries = useMemo(() => {
|
|
191
|
-
// Build a map of all requests by ID
|
|
192
|
-
const map = new Map();
|
|
193
|
-
|
|
194
|
-
// Add historical entries
|
|
195
|
-
for (const entry of historical) {
|
|
196
|
-
if (entry.id) map.set(entry.id, { ...entry });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Apply websocket events (they update existing or add new)
|
|
200
|
-
for (const evt of [...wsEvents].reverse()) {
|
|
201
|
-
if (!evt.id) continue;
|
|
202
|
-
const existing = map.get(evt.id) || {};
|
|
203
|
-
|
|
204
|
-
if (evt.type === "request") {
|
|
205
|
-
map.set(evt.id, { ...existing, id: evt.id, method: evt.method, url: evt.url, timestamp: evt.timestamp });
|
|
206
|
-
} else if (evt.type === "result") {
|
|
207
|
-
map.set(evt.id, { ...existing, id: evt.id, statusCode: evt.statusCode, duration: evt.duration });
|
|
208
|
-
} else if (evt.type === "404") {
|
|
209
|
-
map.set(evt.id, { ...existing, id: evt.id, method: evt.method, url: evt.url, statusCode: 404 });
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Sort by timestamp, newest first
|
|
214
|
-
return Array.from(map.values()).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
215
|
-
}, [historical, wsEvents]);
|
|
216
|
-
|
|
217
|
-
return html`
|
|
218
|
-
<div>
|
|
219
|
-
<div style="margin-bottom: 0.75rem; color: var(--text-muted); font-size: 0.8125rem;">
|
|
220
|
-
${entries.length} request${entries.length !== 1 ? "s" : ""} recorded${selectedId ? " — click a row to inspect" : ""}
|
|
221
|
-
</div>
|
|
222
|
-
<div class="table-container">
|
|
223
|
-
<table>
|
|
224
|
-
<thead>
|
|
225
|
-
<tr>
|
|
226
|
-
<th>Time</th>
|
|
227
|
-
<th>Method</th>
|
|
228
|
-
<th>URL</th>
|
|
229
|
-
<th>Status</th>
|
|
230
|
-
<th>Duration</th>
|
|
231
|
-
</tr>
|
|
232
|
-
</thead>
|
|
233
|
-
<tbody>
|
|
234
|
-
${entries.map(
|
|
235
|
-
(r) => html`
|
|
236
|
-
<tr
|
|
237
|
-
key=${r.id}
|
|
238
|
-
onClick=${() => setSelectedId(r.id === selectedId ? null : r.id)}
|
|
239
|
-
style="cursor:pointer;${r.id === selectedId ? "background:var(--bg-tertiary)" : ""}"
|
|
240
|
-
>
|
|
241
|
-
<td style="white-space: nowrap;">${formatTime(r.timestamp)}</td>
|
|
242
|
-
<td>${methodBadge(r.method)}</td>
|
|
243
|
-
<td class="mono" style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${r.url || "-"}</td>
|
|
244
|
-
<td>
|
|
245
|
-
${r.statusCode != null
|
|
246
|
-
? html`<span class="mono ${statusClass(r.statusCode)}" style="font-weight: 600;">${r.statusCode}</span>`
|
|
247
|
-
: html`<span class="status-pending">pending...</span>`}
|
|
248
|
-
</td>
|
|
249
|
-
<td class="mono" style="color: var(--text-muted);">
|
|
250
|
-
${r.duration != null ? `${r.duration}ms` : "-"}
|
|
251
|
-
</td>
|
|
252
|
-
</tr>
|
|
253
|
-
`
|
|
254
|
-
)}
|
|
255
|
-
${entries.length === 0 && html`
|
|
256
|
-
<tr><td colspan="5" style="color: var(--text-muted); text-align: center; padding: 2rem;">No requests yet. Requests will appear here in real-time.</td></tr>
|
|
257
|
-
`}
|
|
258
|
-
</tbody>
|
|
259
|
-
</table>
|
|
260
|
-
</div>
|
|
261
|
-
${selectedId ? html`<${RequestDetail} id=${selectedId} onClose=${() => setSelectedId(null)} />` : ""}
|
|
262
|
-
</div>
|
|
263
|
-
`;
|
|
264
|
-
}
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import { h } from "preact";
|
|
2
|
-
import { useState } from "preact/hooks";
|
|
3
|
-
import htm from "htm";
|
|
4
|
-
|
|
5
|
-
const html = htm.bind(h);
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Resolve a $ref in a schema against a definitions map.
|
|
9
|
-
* Merges the referenced schema with any sibling properties (description, etc).
|
|
10
|
-
*/
|
|
11
|
-
function resolveRef(prop, definitions) {
|
|
12
|
-
if (!prop) return prop;
|
|
13
|
-
if (prop.$ref) {
|
|
14
|
-
const refName = prop.$ref.replace("#/definitions/", "");
|
|
15
|
-
const resolved = definitions?.[refName] || definitions?.[decodeURIComponent(refName)];
|
|
16
|
-
if (resolved) {
|
|
17
|
-
// Merge: sibling properties (like description) override the ref
|
|
18
|
-
const siblings = Object.fromEntries(Object.entries(prop).filter(([k]) => k !== "$ref"));
|
|
19
|
-
return { ...resolved, ...siblings, definitions: { ...resolved.definitions, ...definitions } };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return { ...prop, definitions: { ...prop.definitions, ...definitions } };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Collapsible section wrapper.
|
|
27
|
-
*/
|
|
28
|
-
function Collapsible({ title, badge, defaultOpen, children }) {
|
|
29
|
-
const [open, setOpen] = useState(defaultOpen ?? true);
|
|
30
|
-
return html`
|
|
31
|
-
<div class="sf-collapsible">
|
|
32
|
-
<div class="sf-collapsible-header" onClick=${() => setOpen(!open)}>
|
|
33
|
-
<span class="sf-chevron ${open ? "sf-open" : ""}">\u25B6</span>
|
|
34
|
-
<span class="sf-collapsible-title">${title}</span>
|
|
35
|
-
${badge && html`<span class="sf-badge">${badge}</span>`}
|
|
36
|
-
</div>
|
|
37
|
-
${open && html`<div class="sf-collapsible-body">${children}</div>`}
|
|
38
|
-
</div>
|
|
39
|
-
`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Render a single field based on its schema type.
|
|
44
|
-
*/
|
|
45
|
-
function SchemaField({ name, prop, value, onChangeValue, readOnly, definitions, path }) {
|
|
46
|
-
const resolved = resolveRef(prop, definitions);
|
|
47
|
-
if (!resolved) return null;
|
|
48
|
-
const defs = resolved.definitions || definitions;
|
|
49
|
-
|
|
50
|
-
// Nested object with properties → recursive form
|
|
51
|
-
if (resolved.type === "object" && resolved.properties) {
|
|
52
|
-
const objValue = value || {};
|
|
53
|
-
return html`
|
|
54
|
-
<${Collapsible} title=${name} badge="object" defaultOpen=${Object.keys(objValue).length > 0}>
|
|
55
|
-
<${SchemaFields}
|
|
56
|
-
schema=${resolved}
|
|
57
|
-
definitions=${defs}
|
|
58
|
-
values=${objValue}
|
|
59
|
-
onChange=${v => onChangeValue(v)}
|
|
60
|
-
readOnly=${readOnly}
|
|
61
|
-
path=${path}
|
|
62
|
-
/>
|
|
63
|
-
<//>
|
|
64
|
-
`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Array field
|
|
68
|
-
if (resolved.type === "array") {
|
|
69
|
-
return html`<${ArrayField}
|
|
70
|
-
name=${name} prop=${resolved} value=${value} onChangeValue=${onChangeValue}
|
|
71
|
-
readOnly=${readOnly} definitions=${defs} path=${path}
|
|
72
|
-
/>`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Enum → select
|
|
76
|
-
if (resolved.enum) {
|
|
77
|
-
return html`
|
|
78
|
-
<select class="sf-input" value=${value ?? ""} disabled=${readOnly}
|
|
79
|
-
onChange=${e => onChangeValue(e.target.value || undefined)}>
|
|
80
|
-
<option value="">Select...</option>
|
|
81
|
-
${resolved.enum.map(v => html`<option key=${v} value=${v}>${v}</option>`)}
|
|
82
|
-
</select>
|
|
83
|
-
`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Boolean → toggle-style select
|
|
87
|
-
if (resolved.type === "boolean") {
|
|
88
|
-
return html`
|
|
89
|
-
<select class="sf-input" value=${value == null ? "" : String(value)} disabled=${readOnly}
|
|
90
|
-
onChange=${e => onChangeValue(e.target.value === "" ? undefined : e.target.value === "true")}>
|
|
91
|
-
<option value="">-</option>
|
|
92
|
-
<option value="true">true</option>
|
|
93
|
-
<option value="false">false</option>
|
|
94
|
-
</select>
|
|
95
|
-
`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Number / integer
|
|
99
|
-
if (resolved.type === "number" || resolved.type === "integer") {
|
|
100
|
-
return html`
|
|
101
|
-
<input class="sf-input" type="number" value=${value ?? ""}
|
|
102
|
-
step=${resolved.type === "integer" ? "1" : "any"}
|
|
103
|
-
min=${resolved.minimum} max=${resolved.maximum} readOnly=${readOnly}
|
|
104
|
-
onInput=${e => {
|
|
105
|
-
const v = e.target.value;
|
|
106
|
-
onChangeValue(v === "" ? undefined : resolved.type === "integer" ? parseInt(v) : parseFloat(v));
|
|
107
|
-
}}
|
|
108
|
-
placeholder=${resolved.default != null ? `Default: ${resolved.default}` : name} />
|
|
109
|
-
`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Object without properties (map) or unknown → JSON textarea
|
|
113
|
-
if (resolved.type === "object") {
|
|
114
|
-
return html`
|
|
115
|
-
<textarea class="sf-input sf-textarea" rows="3"
|
|
116
|
-
value=${typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : value ?? ""}
|
|
117
|
-
readOnly=${readOnly}
|
|
118
|
-
onInput=${e => { try { onChangeValue(JSON.parse(e.target.value)); } catch {} }}
|
|
119
|
-
placeholder="JSON object" />
|
|
120
|
-
`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// String (default)
|
|
124
|
-
const inputType = resolved.format === "email" ? "email"
|
|
125
|
-
: resolved.format === "uri" || resolved.format === "url" ? "url"
|
|
126
|
-
: resolved.format === "password" ? "password"
|
|
127
|
-
: "text";
|
|
128
|
-
|
|
129
|
-
return html`
|
|
130
|
-
<input class="sf-input" type=${inputType}
|
|
131
|
-
value=${value ?? ""} readOnly=${readOnly}
|
|
132
|
-
onInput=${e => onChangeValue(e.target.value || undefined)}
|
|
133
|
-
placeholder=${resolved.default != null ? `Default: ${resolved.default}` : (resolved.pattern ? `Pattern: ${resolved.pattern}` : name)}
|
|
134
|
-
minlength=${resolved.minLength} maxlength=${resolved.maxLength} />
|
|
135
|
-
`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Render an array field with add/remove controls.
|
|
140
|
-
*/
|
|
141
|
-
function ArrayField({ name, prop, value, onChangeValue, readOnly, definitions, path }) {
|
|
142
|
-
const items = Array.isArray(value) ? value : [];
|
|
143
|
-
const itemSchema = resolveRef(prop.items, definitions) || { type: "string" };
|
|
144
|
-
const isSimple = itemSchema.type === "string" || itemSchema.type === "number" || itemSchema.type === "integer";
|
|
145
|
-
|
|
146
|
-
return html`
|
|
147
|
-
<${Collapsible} title=${name} badge=${`array[${items.length}]`} defaultOpen=${items.length > 0}>
|
|
148
|
-
<div class="sf-array">
|
|
149
|
-
${items.map((item, i) => html`
|
|
150
|
-
<div class="sf-array-item" key=${i}>
|
|
151
|
-
<div class="sf-array-item-content">
|
|
152
|
-
${isSimple
|
|
153
|
-
? html`<input class="sf-input" type=${itemSchema.type === "string" ? "text" : "number"}
|
|
154
|
-
value=${item ?? ""} readOnly=${readOnly}
|
|
155
|
-
onInput=${e => {
|
|
156
|
-
const v = e.target.value;
|
|
157
|
-
const newItems = [...items];
|
|
158
|
-
newItems[i] = itemSchema.type === "string" ? v : (itemSchema.type === "integer" ? parseInt(v) : parseFloat(v));
|
|
159
|
-
onChangeValue(newItems);
|
|
160
|
-
}}
|
|
161
|
-
placeholder=${`Item ${i + 1}`} />`
|
|
162
|
-
: itemSchema.type === "object" && itemSchema.properties
|
|
163
|
-
? html`<${SchemaFields}
|
|
164
|
-
schema=${itemSchema} definitions=${definitions}
|
|
165
|
-
values=${item || {}}
|
|
166
|
-
onChange=${v => { const newItems = [...items]; newItems[i] = v; onChangeValue(newItems); }}
|
|
167
|
-
readOnly=${readOnly} path=${[...path, i]}
|
|
168
|
-
/>`
|
|
169
|
-
: html`<textarea class="sf-input sf-textarea" rows="2"
|
|
170
|
-
value=${typeof item === "object" ? JSON.stringify(item, null, 2) : String(item ?? "")}
|
|
171
|
-
readOnly=${readOnly}
|
|
172
|
-
onInput=${e => { try { const newItems = [...items]; newItems[i] = JSON.parse(e.target.value); onChangeValue(newItems); } catch {} }}
|
|
173
|
-
placeholder="JSON" />`
|
|
174
|
-
}
|
|
175
|
-
</div>
|
|
176
|
-
${!readOnly && html`
|
|
177
|
-
<button class="sf-btn sf-btn-remove" onClick=${() => { const newItems = items.filter((_, j) => j !== i); onChangeValue(newItems.length ? newItems : undefined); }}
|
|
178
|
-
title="Remove item">\u00D7</button>
|
|
179
|
-
`}
|
|
180
|
-
</div>
|
|
181
|
-
`)}
|
|
182
|
-
${!readOnly && html`
|
|
183
|
-
<button class="sf-btn sf-btn-add" onClick=${() => {
|
|
184
|
-
const empty = isSimple ? (itemSchema.type === "string" ? "" : 0) : {};
|
|
185
|
-
onChangeValue([...items, empty]);
|
|
186
|
-
}}>+ Add item</button>
|
|
187
|
-
`}
|
|
188
|
-
</div>
|
|
189
|
-
<//>
|
|
190
|
-
`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Render all fields for a schema's properties.
|
|
195
|
-
*/
|
|
196
|
-
function SchemaFields({ schema, definitions, values, onChange, readOnly, path }) {
|
|
197
|
-
const defs = { ...definitions, ...schema.definitions };
|
|
198
|
-
const required = new Set(schema.required || []);
|
|
199
|
-
const props = schema.properties || {};
|
|
200
|
-
|
|
201
|
-
return html`
|
|
202
|
-
<div class="sf-fields">
|
|
203
|
-
${Object.entries(props).map(([name, prop]) => {
|
|
204
|
-
const resolved = resolveRef(prop, defs);
|
|
205
|
-
if (!resolved) return null;
|
|
206
|
-
const isReq = required.has(name);
|
|
207
|
-
const hasValue = values[name] !== undefined && values[name] !== null && values[name] !== "";
|
|
208
|
-
const fieldPath = [...(path || []), name];
|
|
209
|
-
|
|
210
|
-
return html`
|
|
211
|
-
<div class="sf-field ${hasValue ? "sf-has-value" : ""}" key=${name}>
|
|
212
|
-
<div class="sf-label-row">
|
|
213
|
-
<label class="sf-label">
|
|
214
|
-
${name}
|
|
215
|
-
${isReq && html`<span class="sf-required">*</span>`}
|
|
216
|
-
</label>
|
|
217
|
-
<span class="sf-type">${resolved.type || "ref"}${resolved.format ? `:${resolved.format}` : ""}</span>
|
|
218
|
-
</div>
|
|
219
|
-
${resolved.description && html`<div class="sf-description">${resolved.description}</div>`}
|
|
220
|
-
<${SchemaField}
|
|
221
|
-
name=${name} prop=${prop} value=${values[name]} definitions=${defs}
|
|
222
|
-
readOnly=${readOnly} path=${fieldPath}
|
|
223
|
-
onChangeValue=${v => {
|
|
224
|
-
const next = { ...values };
|
|
225
|
-
if (v === undefined || v === null || v === "") delete next[name];
|
|
226
|
-
else next[name] = v;
|
|
227
|
-
onChange(next);
|
|
228
|
-
}}
|
|
229
|
-
/>
|
|
230
|
-
${resolved.default !== undefined && !hasValue && html`
|
|
231
|
-
<div class="sf-default">Default: <code>${JSON.stringify(resolved.default)}</code></div>
|
|
232
|
-
`}
|
|
233
|
-
</div>
|
|
234
|
-
`;
|
|
235
|
-
})}
|
|
236
|
-
</div>
|
|
237
|
-
`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Top-level SchemaForm component.
|
|
242
|
-
*
|
|
243
|
-
* @param {object} props
|
|
244
|
-
* @param {object} props.schema - JSON Schema object
|
|
245
|
-
* @param {object} props.values - current form values
|
|
246
|
-
* @param {function} props.onChange - called with updated values
|
|
247
|
-
* @param {boolean} props.readOnly - disable all inputs
|
|
248
|
-
* @param {object} props.definitions - external definitions map for $ref resolution
|
|
249
|
-
*/
|
|
250
|
-
export function SchemaForm({ schema, values, onChange, readOnly, definitions }) {
|
|
251
|
-
if (!schema?.properties) {
|
|
252
|
-
return html`<div class="sf-empty">No fields defined in schema</div>`;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const allDefs = { ...definitions, ...schema.definitions };
|
|
256
|
-
|
|
257
|
-
return html`
|
|
258
|
-
<div class="sf-root">
|
|
259
|
-
${schema.title && html`<div class="sf-title">${schema.title}</div>`}
|
|
260
|
-
${schema.description && html`<div class="sf-schema-desc">${schema.description}</div>`}
|
|
261
|
-
<${SchemaFields}
|
|
262
|
-
schema=${schema} definitions=${allDefs}
|
|
263
|
-
values=${values || {}} onChange=${onChange}
|
|
264
|
-
readOnly=${readOnly} path=${[]}
|
|
265
|
-
/>
|
|
266
|
-
</div>
|
|
267
|
-
`;
|
|
268
|
-
}
|