@webda/debug 4.0.0-beta.3

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.
Files changed (66) hide show
  1. package/LICENSE +166 -0
  2. package/README.md +95 -0
  3. package/lib/bodycapture.d.ts +66 -0
  4. package/lib/bodycapture.js +162 -0
  5. package/lib/bodycapture.js.map +1 -0
  6. package/lib/debugservice.service.d.ts +166 -0
  7. package/lib/debugservice.service.js +609 -0
  8. package/lib/debugservice.service.js.map +1 -0
  9. package/lib/index.d.ts +7 -0
  10. package/lib/index.js +7 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/introspection.d.ts +115 -0
  13. package/lib/introspection.js +260 -0
  14. package/lib/introspection.js.map +1 -0
  15. package/lib/logbuffer.d.ts +75 -0
  16. package/lib/logbuffer.js +98 -0
  17. package/lib/logbuffer.js.map +1 -0
  18. package/lib/requestlog.d.ts +181 -0
  19. package/lib/requestlog.js +153 -0
  20. package/lib/requestlog.js.map +1 -0
  21. package/lib/tui/client.d.ts +165 -0
  22. package/lib/tui/client.js +220 -0
  23. package/lib/tui/client.js.map +1 -0
  24. package/lib/tui/panels/config.d.ts +79 -0
  25. package/lib/tui/panels/config.js +291 -0
  26. package/lib/tui/panels/config.js.map +1 -0
  27. package/lib/tui/panels/logs.d.ts +106 -0
  28. package/lib/tui/panels/logs.js +342 -0
  29. package/lib/tui/panels/logs.js.map +1 -0
  30. package/lib/tui/panels/models.d.ts +47 -0
  31. package/lib/tui/panels/models.js +193 -0
  32. package/lib/tui/panels/models.js.map +1 -0
  33. package/lib/tui/panels/operations.d.ts +55 -0
  34. package/lib/tui/panels/operations.js +241 -0
  35. package/lib/tui/panels/operations.js.map +1 -0
  36. package/lib/tui/panels/panel.d.ts +55 -0
  37. package/lib/tui/panels/panel.js +31 -0
  38. package/lib/tui/panels/panel.js.map +1 -0
  39. package/lib/tui/panels/requests.d.ts +156 -0
  40. package/lib/tui/panels/requests.js +512 -0
  41. package/lib/tui/panels/requests.js.map +1 -0
  42. package/lib/tui/panels/routes.d.ts +45 -0
  43. package/lib/tui/panels/routes.js +154 -0
  44. package/lib/tui/panels/routes.js.map +1 -0
  45. package/lib/tui/panels/services.d.ts +49 -0
  46. package/lib/tui/panels/services.js +266 -0
  47. package/lib/tui/panels/services.js.map +1 -0
  48. package/lib/tui/tui.d.ts +58 -0
  49. package/lib/tui/tui.js +236 -0
  50. package/lib/tui/tui.js.map +1 -0
  51. package/package.json +60 -0
  52. package/webui/app.js +177 -0
  53. package/webui/components/logs.js +128 -0
  54. package/webui/components/model-graph.js +292 -0
  55. package/webui/components/models.js +213 -0
  56. package/webui/components/operations.js +315 -0
  57. package/webui/components/requests.js +264 -0
  58. package/webui/components/schema-form.js +268 -0
  59. package/webui/components/services.js +248 -0
  60. package/webui/index.html +22 -0
  61. package/webui/logo.svg +1 -0
  62. package/webui/styles.css +685 -0
  63. package/webui/vendor/htm.js +1 -0
  64. package/webui/vendor/preact-hooks.js +2 -0
  65. package/webui/vendor/preact.js +2 -0
  66. package/webui/webda.png +0 -0
@@ -0,0 +1,292 @@
1
+ import { h } from "preact";
2
+ import { useMemo, useRef, useState, useEffect } from "preact/hooks";
3
+ import htm from "htm";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ const COLORS = {
8
+ node: "#0f3460",
9
+ nodeBorder: "#4557a4",
10
+ nodeText: "#e0e0e0",
11
+ selected: "#f7992c",
12
+ inheritance: "#8892a4",
13
+ link: "#4557a4",
14
+ query: "#81bf6b",
15
+ parent: "#a855f7",
16
+ binary: "#f7992c",
17
+ map: "#6b8fd4"
18
+ };
19
+
20
+ /**
21
+ * Compute the leaf count (width in columns) of a tree rooted at nodeId.
22
+ */
23
+ function treeWidth(nodeId, childMap) {
24
+ const kids = childMap[nodeId] || [];
25
+ if (kids.length === 0) return 1;
26
+ return kids.reduce((sum, k) => sum + treeWidth(k, childMap), 0);
27
+ }
28
+
29
+ /**
30
+ * Compute the max depth of a tree rooted at nodeId.
31
+ */
32
+ function treeDepth(nodeId, childMap) {
33
+ const kids = childMap[nodeId] || [];
34
+ if (kids.length === 0) return 1;
35
+ return 1 + Math.max(...kids.map(k => treeDepth(k, childMap)));
36
+ }
37
+
38
+ /**
39
+ * Build the graph layout: compute grid positions for trees, wrap rows to fit container.
40
+ */
41
+ function buildGraph(models, selectedId, containerW) {
42
+ const byId = {};
43
+ models.forEach(m => { byId[m.id] = m; });
44
+
45
+ // Build inheritance tree
46
+ const childMap = {};
47
+ const roots = [];
48
+ models.forEach(m => {
49
+ const ancestors = m.metadata?.Ancestors || [];
50
+ const parentId = ancestors.length > 0 && byId[ancestors[0]] ? ancestors[0] : null;
51
+ if (parentId) {
52
+ childMap[parentId] ??= [];
53
+ childMap[parentId].push(m.id);
54
+ } else {
55
+ roots.push(m.id);
56
+ }
57
+ });
58
+
59
+ // Pick a reasonable node size
60
+ const MARGIN = 20;
61
+ const nodeW = Math.max(80, Math.min(140, (containerW - MARGIN * 2) / 6));
62
+ const nodeH = Math.max(26, Math.min(36, nodeW * 0.25));
63
+ const gapX = Math.max(12, nodeW * 0.2);
64
+ const gapY = Math.max(16, nodeH * 0.6);
65
+ const cellW = nodeW + gapX;
66
+ const cellH = nodeH + gapY;
67
+ const maxCols = Math.max(1, Math.floor((containerW - MARGIN * 2 + gapX) / cellW));
68
+
69
+ // Wrap root trees into bands that fit within maxCols
70
+ const bands = []; // each band: { roots: [...], colOffset: 0, rowOffset: 0, width, depth }
71
+ let curBandRoots = [];
72
+ let curBandCols = 0;
73
+
74
+ roots.forEach(r => {
75
+ const w = treeWidth(r, childMap);
76
+ if (curBandCols > 0 && curBandCols + w > maxCols) {
77
+ bands.push(curBandRoots);
78
+ curBandRoots = [];
79
+ curBandCols = 0;
80
+ }
81
+ curBandRoots.push({ id: r, colStart: curBandCols, width: w });
82
+ curBandCols += w;
83
+ });
84
+ if (curBandRoots.length) bands.push(curBandRoots);
85
+
86
+ // Layout each band
87
+ const grid = {};
88
+ let bandRowOffset = 0;
89
+
90
+ bands.forEach(band => {
91
+ let bandMaxDepth = 0;
92
+ band.forEach(entry => {
93
+ const d = treeDepth(entry.id, childMap);
94
+ bandMaxDepth = Math.max(bandMaxDepth, d);
95
+
96
+ let nextCol = entry.colStart;
97
+ function layoutTree(nodeId, depth) {
98
+ const kids = childMap[nodeId] || [];
99
+ if (kids.length === 0) {
100
+ grid[nodeId] = { col: nextCol, row: bandRowOffset + depth };
101
+ nextCol++;
102
+ return;
103
+ }
104
+ kids.forEach(kid => layoutTree(kid, depth + 1));
105
+ const first = grid[kids[0]];
106
+ const last = grid[kids[kids.length - 1]];
107
+ grid[nodeId] = { col: (first.col + last.col) / 2, row: bandRowOffset + depth };
108
+ }
109
+ layoutTree(entry.id, 0);
110
+ });
111
+ bandRowOffset += bandMaxDepth;
112
+ });
113
+
114
+ // Place orphans
115
+ let orphanCol = 0;
116
+ models.forEach(m => {
117
+ if (!grid[m.id]) {
118
+ grid[m.id] = { col: orphanCol, row: bandRowOffset };
119
+ orphanCol++;
120
+ }
121
+ });
122
+
123
+ // Convert to pixel positions
124
+ const nodes = models.map(m => {
125
+ const g = grid[m.id];
126
+ return {
127
+ id: m.id,
128
+ shortName: m.id.split("/").pop(),
129
+ x: MARGIN + g.col * cellW,
130
+ y: MARGIN + g.row * cellH,
131
+ w: nodeW,
132
+ h: nodeH,
133
+ model: m,
134
+ isSelected: m.id === selectedId
135
+ };
136
+ });
137
+
138
+ const nodeMap = {};
139
+ nodes.forEach(n => { nodeMap[n.id] = n; });
140
+
141
+ // Build edges
142
+ const edges = [];
143
+ models.forEach(m => {
144
+ const src = nodeMap[m.id];
145
+ if (!src) return;
146
+ const relations = m.relations || {};
147
+ const ancestors = m.metadata?.Ancestors || [];
148
+ if (ancestors[0] && nodeMap[ancestors[0]]) {
149
+ edges.push({ from: nodeMap[ancestors[0]], to: src, type: "inheritance" });
150
+ }
151
+ if (relations.parent && nodeMap[relations.parent.model]) {
152
+ edges.push({ from: src, to: nodeMap[relations.parent.model], type: "parent", label: relations.parent.attribute });
153
+ }
154
+ (relations.links || []).forEach(link => {
155
+ if (nodeMap[link.model]) edges.push({ from: src, to: nodeMap[link.model], type: "link", label: link.attribute });
156
+ });
157
+ (relations.queries || []).forEach(q => {
158
+ if (nodeMap[q.model]) edges.push({ from: src, to: nodeMap[q.model], type: "query", label: q.attribute });
159
+ });
160
+ (relations.maps || []).forEach(map => {
161
+ if (nodeMap[map.model]) edges.push({ from: src, to: nodeMap[map.model], type: "map", label: map.attribute });
162
+ });
163
+ });
164
+
165
+ const maxX = Math.max(...nodes.map(n => n.x + nodeW), 200) + MARGIN;
166
+ const maxY = Math.max(...nodes.map(n => n.y + nodeH), 100) + MARGIN;
167
+
168
+ return { nodes, edges, width: maxX, height: maxY };
169
+ }
170
+
171
+ function edgeColor(type) {
172
+ return COLORS[type] || COLORS.link;
173
+ }
174
+
175
+ function GraphEdge({ edge }) {
176
+ const { from, to, type, label } = edge;
177
+ const fromCx = from.x + from.w / 2;
178
+ const fromCy = from.y + from.h / 2;
179
+ const toCx = to.x + to.w / 2;
180
+ const toCy = to.y + to.h / 2;
181
+ const dx = toCx - fromCx;
182
+ const dy = toCy - fromCy;
183
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
184
+ const nx = dx / dist;
185
+ const ny = dy / dist;
186
+
187
+ // Attach to node border (simplified: use half-width/height in direction)
188
+ const x1 = fromCx + nx * (from.w / 2) * (Math.abs(nx) > Math.abs(ny) ? 1 : Math.abs(nx / ny)) ;
189
+ const y1 = fromCy + ny * (from.h / 2) * (Math.abs(ny) > Math.abs(nx) ? 1 : Math.abs(ny / nx));
190
+ const x2 = toCx - nx * (to.w / 2) * (Math.abs(nx) > Math.abs(ny) ? 1 : Math.abs(nx / ny));
191
+ const y2 = toCy - ny * (to.h / 2) * (Math.abs(ny) > Math.abs(nx) ? 1 : Math.abs(ny / nx));
192
+ const mx = (x1 + x2) / 2;
193
+ const my = (y1 + y2) / 2;
194
+ const color = edgeColor(type);
195
+ const dashed = type === "inheritance" ? "4,3" : "none";
196
+ const fontSize = Math.max(7, Math.min(9, from.w / 18));
197
+
198
+ return html`
199
+ <g>
200
+ <line x1=${x1} y1=${y1} x2=${x2} y2=${y2}
201
+ stroke=${color} stroke-width="1.5" stroke-dasharray=${dashed}
202
+ marker-end="url(#arrow-${type})" />
203
+ ${label && html`
204
+ <text x=${mx} y=${my - 4} text-anchor="middle"
205
+ fill=${color} font-size=${fontSize} font-family="SF Mono, Fira Code, monospace">${label}</text>
206
+ `}
207
+ </g>
208
+ `;
209
+ }
210
+
211
+ function GraphNode({ node, onClick }) {
212
+ const hasBinaries = (node.model.relations?.binaries || []).length > 0;
213
+ const fontSize = Math.max(8, Math.min(11, node.w / 14));
214
+ // Truncate name if too long for node width
215
+ const maxChars = Math.floor(node.w / (fontSize * 0.6));
216
+ const displayName = node.shortName.length > maxChars ? node.shortName.slice(0, maxChars - 1) + "\u2026" : node.shortName;
217
+
218
+ return html`
219
+ <g onClick=${() => onClick(node.id)} style="cursor:pointer">
220
+ <rect x=${node.x} y=${node.y} width=${node.w} height=${node.h} rx="4"
221
+ fill=${node.isSelected ? "#1a2744" : COLORS.node}
222
+ stroke=${node.isSelected ? COLORS.selected : COLORS.nodeBorder}
223
+ stroke-width=${node.isSelected ? "2" : "1"} />
224
+ <text x=${node.x + node.w / 2} y=${node.y + node.h / 2 + fontSize * 0.35}
225
+ text-anchor="middle" fill=${node.isSelected ? COLORS.selected : COLORS.nodeText}
226
+ font-size=${fontSize} font-weight=${node.isSelected ? "600" : "400"}
227
+ font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif">
228
+ ${displayName}
229
+ </text>
230
+ ${hasBinaries && html`
231
+ <circle cx=${node.x + node.w - 6} cy=${node.y + 6} r="3.5"
232
+ fill=${COLORS.binary} opacity="0.8" />
233
+ `}
234
+ </g>
235
+ `;
236
+ }
237
+
238
+ export function ModelGraph({ models, selectedId, onSelect }) {
239
+ const containerRef = useRef(null);
240
+ const [size, setSize] = useState({ w: 800, h: 500 });
241
+
242
+ useEffect(() => {
243
+ const el = containerRef.current;
244
+ if (!el) return;
245
+ const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
246
+ update();
247
+ const ro = new ResizeObserver(update);
248
+ ro.observe(el);
249
+ return () => ro.disconnect();
250
+ }, []);
251
+
252
+ const graph = useMemo(() => buildGraph(models, selectedId, size.w), [models, selectedId, size.w]);
253
+
254
+ const legend = [
255
+ { color: COLORS.inheritance, label: "Extends", dashed: true },
256
+ { color: COLORS.parent, label: "Parent" },
257
+ { color: COLORS.link, label: "Link" },
258
+ { color: COLORS.query, label: "Query" },
259
+ { color: COLORS.map, label: "Map" },
260
+ { color: COLORS.binary, label: "Binary", circle: true }
261
+ ];
262
+
263
+ return html`
264
+ <div style="display:flex;flex-direction:column;height:100%">
265
+ <div style="display:flex;gap:1rem;margin-bottom:0.5rem;flex-wrap:wrap;align-items:center;flex-shrink:0">
266
+ ${legend.map(l => html`
267
+ <div key=${l.label} style="display:flex;align-items:center;gap:0.3rem;font-size:0.7rem;color:var(--text-muted)">
268
+ ${l.circle
269
+ ? html`<span style="width:8px;height:8px;border-radius:50%;background:${l.color};display:inline-block"></span>`
270
+ : html`<span style="width:16px;height:0;border-top:2px ${l.dashed ? "dashed" : "solid"} ${l.color};display:inline-block"></span>`
271
+ }
272
+ ${l.label}
273
+ </div>
274
+ `)}
275
+ </div>
276
+ <div ref=${containerRef} style="flex:1;overflow:auto;border:1px solid var(--border);border-radius:4px;background:var(--bg);min-height:200px">
277
+ <svg width=${graph.width} height=${graph.height} style="display:block">
278
+ <defs>
279
+ ${["inheritance", "parent", "link", "query", "map"].map(type => html`
280
+ <marker key=${type} id="arrow-${type}" viewBox="0 0 10 10" refX="9" refY="5"
281
+ markerWidth="6" markerHeight="6" orient="auto-start-reverse">
282
+ <path d="M 0 0 L 10 5 L 0 10 z" fill=${edgeColor(type)} />
283
+ </marker>
284
+ `)}
285
+ </defs>
286
+ ${graph.edges.map((e, i) => html`<${GraphEdge} key=${i} edge=${e} />`)}
287
+ ${graph.nodes.map(n => html`<${GraphNode} key=${n.id} node=${n} onClick=${onSelect} />`)}
288
+ </svg>
289
+ </div>
290
+ </div>
291
+ `;
292
+ }
@@ -0,0 +1,213 @@
1
+ import { h } from "preact";
2
+ import { useState } from "preact/hooks";
3
+ import htm from "htm";
4
+ import { ModelGraph } from "./model-graph.js";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ export function ModelsPanel({ data }) {
9
+ const [selected, setSelected] = useState("__graph");
10
+ const [filter, setFilter] = useState("");
11
+ const models = data || [];
12
+ const detail = selected && selected !== "__graph" ? models.find((m) => m.id === selected) || null : null;
13
+
14
+ const filtered = models.filter(
15
+ (m) => (m.id || m.name || "").toLowerCase().includes(filter.toLowerCase())
16
+ );
17
+
18
+ return html`
19
+ <div class="split-panel">
20
+ <div class="split-left">
21
+ <input
22
+ class="search-input"
23
+ placeholder="Filter models..."
24
+ value=${filter}
25
+ onInput=${(e) => setFilter(e.target.value)}
26
+ />
27
+ <div
28
+ class="list-item ${selected === "__graph" ? "active" : ""}"
29
+ onClick=${() => setSelected("__graph")}
30
+ style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);margin-bottom:0.5rem;padding-bottom:0.625rem"
31
+ >
32
+ <span style="font-weight:500">Model Graph</span>
33
+ <span class="badge badge-muted">${models.length}</span>
34
+ </div>
35
+ ${filtered.map(
36
+ (m) => html`
37
+ <div
38
+ key=${m.id || m.name}
39
+ class="list-item ${selected === (m.id || m.name) ? "active" : ""}"
40
+ onClick=${() => setSelected(m.id || m.name)}
41
+ >
42
+ ${m.id || m.name}
43
+ </div>
44
+ `
45
+ )}
46
+ ${filtered.length === 0 && html`<div style="color: var(--text-muted); padding: 0.5rem;">No models found</div>`}
47
+ </div>
48
+ <div class="split-right">
49
+ ${selected === "__graph"
50
+ ? html`<${ModelGraph} models=${models} selectedId=${null} onSelect=${id => setSelected(id)} />`
51
+ : detail ? html`<${ModelDetail} model=${detail} onSelect=${id => setSelected(id)} />`
52
+ : html`
53
+ <div style="color: var(--text-muted); padding: 2rem; text-align: center;">
54
+ Select a model to view details
55
+ </div>
56
+ `}
57
+ </div>
58
+ </div>
59
+ `;
60
+ }
61
+
62
+ function SchemaTab({ schemas }) {
63
+ const [activeTab, setActiveTab] = useState("Input");
64
+ const tabs = ["Input", "Output", "Stored"].filter((t) => schemas?.[t]);
65
+
66
+ if (tabs.length === 0) {
67
+ return html`<div style="color: var(--text-muted); padding: 0.5rem;">No schemas available</div>`;
68
+ }
69
+
70
+ // Default to first available tab if active isn't available
71
+ const current = tabs.includes(activeTab) ? activeTab : tabs[0];
72
+
73
+ return html`
74
+ <div>
75
+ <div style="display: flex; gap: 0; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border);">
76
+ ${tabs.map(
77
+ (tab) => html`
78
+ <button
79
+ key=${tab}
80
+ onClick=${() => setActiveTab(tab)}
81
+ style="
82
+ padding: 6px 16px;
83
+ background: ${current === tab ? "var(--bg-tertiary)" : "transparent"};
84
+ color: ${current === tab ? "var(--accent)" : "var(--text-muted)"};
85
+ border: none;
86
+ border-bottom: 2px solid ${current === tab ? "var(--accent)" : "transparent"};
87
+ cursor: pointer;
88
+ font-size: 0.875rem;
89
+ font-weight: ${current === tab ? "600" : "400"};
90
+ "
91
+ >
92
+ ${tab}
93
+ </button>
94
+ `
95
+ )}
96
+ </div>
97
+ <pre class="mono" style="
98
+ background: var(--bg-secondary);
99
+ padding: 1rem;
100
+ border-radius: 4px;
101
+ overflow: auto;
102
+ max-height: 400px;
103
+ font-size: 0.8125rem;
104
+ line-height: 1.5;
105
+ ">${JSON.stringify(schemas[current], null, 2)}</pre>
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ function ModelLink({ id, onSelect }) {
111
+ return html`<a href="#" class="mono" style="color:var(--accent);text-decoration:none" onClick=${e => { e.preventDefault(); onSelect(id); }}>${id.split("/").pop()}</a>`;
112
+ }
113
+
114
+ function RelationsTable({ relations, onSelect }) {
115
+ const rows = [];
116
+ if (relations.parent) {
117
+ rows.push({ name: relations.parent.attribute, type: "parent", target: relations.parent.model });
118
+ }
119
+ (relations.links || []).forEach(l => rows.push({ name: l.attribute, type: l.type || "link", target: l.model }));
120
+ (relations.queries || []).forEach(q => rows.push({ name: q.attribute, type: "query", target: q.model }));
121
+ (relations.maps || []).forEach(m => rows.push({ name: m.attribute, type: "map", target: m.model }));
122
+ (relations.children || []).forEach(c => rows.push({ name: "", type: "child", target: c }));
123
+ (relations.binaries || []).forEach(b => rows.push({ name: b.attribute, type: `binary (${b.cardinality})`, target: "" }));
124
+ (relations.behaviors || []).forEach(b => rows.push({ name: b.attribute, type: "behavior", target: b.behavior }));
125
+ if (rows.length === 0) return null;
126
+ return html`
127
+ <div class="detail-section">
128
+ <h3>Relations</h3>
129
+ <div class="table-container">
130
+ <table>
131
+ <thead><tr><th>Attribute</th><th>Type</th><th>Target</th></tr></thead>
132
+ <tbody>
133
+ ${rows.map((r, i) => html`
134
+ <tr key=${i}>
135
+ <td class="mono">${r.name || "-"}</td>
136
+ <td><span class="badge ${r.type === "parent" ? "badge-purple" : r.type === "child" ? "badge-purple" : r.type === "query" ? "badge-green" : r.type.startsWith("binary") ? "badge-orange" : r.type === "behavior" ? "badge-orange" : "badge-blue"}">${r.type}</span></td>
137
+ <td>${r.target ? html`<${ModelLink} id=${r.target} onSelect=${onSelect} />` : "-"}</td>
138
+ </tr>
139
+ `)}
140
+ </tbody>
141
+ </table>
142
+ </div>
143
+ </div>
144
+ `;
145
+ }
146
+
147
+ function ModelDetail({ model, onSelect }) {
148
+ const ancestors = model.metadata?.Ancestors || [];
149
+ const subclasses = model.metadata?.Subclasses || [];
150
+
151
+ return html`
152
+ <div>
153
+ <h2 style="margin-bottom:0.5rem;color:var(--accent)">${model.id?.split("/").pop()}</h2>
154
+ <div class="mono" style="font-size:0.75rem;color:var(--text-muted);margin-bottom:1rem">${model.id}</div>
155
+
156
+ <div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center">
157
+ ${model.store && html`
158
+ <div style="background:var(--bg-tertiary);padding:0.375rem 0.625rem;border-radius:4px;font-size:0.8125rem">
159
+ <span style="color:var(--text-muted)">Store: </span>
160
+ <span class="mono">${model.store}</span>
161
+ ${model.storeType && html`<span style="color:var(--text-muted)"> (${model.storeType})</span>`}
162
+ </div>
163
+ `}
164
+ ${model.plural && html`
165
+ <div style="background:var(--bg-tertiary);padding:0.375rem 0.625rem;border-radius:4px;font-size:0.8125rem">
166
+ <span style="color:var(--text-muted)">Plural: </span>
167
+ <span class="mono">${model.plural}</span>
168
+ </div>
169
+ `}
170
+ </div>
171
+
172
+ ${ancestors.length > 0 && html`
173
+ <div class="detail-section">
174
+ <h3>Inheritance</h3>
175
+ <div style="display:flex;align-items:center;gap:0.375rem;flex-wrap:wrap;font-size:0.8125rem">
176
+ ${[...ancestors].reverse().map(a => html`
177
+ <${ModelLink} key=${a} id=${a} onSelect=${onSelect} />
178
+ <span style="color:var(--text-muted)">\u2192</span>
179
+ `)}
180
+ <span class="mono" style="font-weight:600;color:var(--accent)">${model.id?.split("/").pop()}</span>
181
+ ${subclasses.length > 0 && html`
182
+ <span style="color:var(--text-muted)">\u2192</span>
183
+ ${subclasses.map((s, i) => html`
184
+ <${ModelLink} key=${s} id=${s} onSelect=${onSelect} />
185
+ ${i < subclasses.length - 1 && html`<span style="color:var(--text-muted)">,</span>`}
186
+ `)}
187
+ `}
188
+ </div>
189
+ </div>
190
+ `}
191
+
192
+ <${RelationsTable} relations=${model.relations || {}} onSelect=${onSelect} />
193
+
194
+ ${model.actions && model.actions.length > 0 && html`
195
+ <div class="detail-section">
196
+ <h3>Actions</h3>
197
+ <div style="display:flex;gap:0.375rem;flex-wrap:wrap">
198
+ ${model.actions.map(a => html`
199
+ <span key=${typeof a === "string" ? a : a.name} class="badge badge-blue">${typeof a === "string" ? a : a.name}</span>
200
+ `)}
201
+ </div>
202
+ </div>
203
+ `}
204
+
205
+ ${model.schemas && html`
206
+ <div class="detail-section">
207
+ <h3>Schemas</h3>
208
+ <${SchemaTab} schemas=${model.schemas} />
209
+ </div>
210
+ `}
211
+ </div>
212
+ `;
213
+ }