@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
package/webui/index.html
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
|
-
<!
|
|
1
|
+
<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
</script>
|
|
17
|
-
</head>
|
|
18
|
-
<body>
|
|
19
|
-
<div id="app"></div>
|
|
20
|
-
<script type="module" src="/app.js"></script>
|
|
21
|
-
</body>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="robots" content="noindex" />
|
|
7
|
+
<title>Webda Debug Dashboard</title>
|
|
8
|
+
<link rel="icon" href="./assets/favicon-CVPPk1M-.svg" type="image/svg+xml" />
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-RYFIlyiZ.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BHmmE2oC.css">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="app"></div>
|
|
14
|
+
</body>
|
|
22
15
|
</html>
|
package/webui/app.js
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { h, render } from "preact";
|
|
2
|
-
import { useState, useEffect, useRef, useCallback } from "preact/hooks";
|
|
3
|
-
import htm from "htm";
|
|
4
|
-
|
|
5
|
-
import { ModelsPanel } from "./components/models.js";
|
|
6
|
-
import { ServicesPanel } from "./components/services.js";
|
|
7
|
-
import { OperationsPanel } from "./components/operations.js";
|
|
8
|
-
import { RequestsPanel } from "./components/requests.js";
|
|
9
|
-
import { LogsPanel } from "./components/logs.js";
|
|
10
|
-
|
|
11
|
-
const html = htm.bind(h);
|
|
12
|
-
|
|
13
|
-
const TABS = [
|
|
14
|
-
{ id: "logs", label: "Logs", color: "#f7992c" },
|
|
15
|
-
{ id: "models", label: "Models", color: "#81bf6b" },
|
|
16
|
-
{ id: "services", label: "Services", color: "#6b8fd4" },
|
|
17
|
-
{ id: "operations", label: "Operations", color: "#f4f4f4" },
|
|
18
|
-
{ id: "requests", label: "Requests", color: "#6b8fd4" }
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Fetch JSON from the debug API.
|
|
23
|
-
* @param {string} path
|
|
24
|
-
* @returns {Promise<any>}
|
|
25
|
-
*/
|
|
26
|
-
async function fetchApi(path) {
|
|
27
|
-
const res = await fetch(path);
|
|
28
|
-
return res.json();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function App() {
|
|
32
|
-
const [tab, setTab] = useState("logs");
|
|
33
|
-
const [wsConnected, setWsConnected] = useState(false);
|
|
34
|
-
const [wsEvents, setWsEvents] = useState([]);
|
|
35
|
-
const [dataVersion, setDataVersion] = useState(0);
|
|
36
|
-
const [cache, setCache] = useState({});
|
|
37
|
-
const wsRef = useRef(null);
|
|
38
|
-
const reconnectTimer = useRef(null);
|
|
39
|
-
|
|
40
|
-
// Load all data upfront and on rebuild (restart event)
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
Promise.all([
|
|
43
|
-
fetchApi("/api/info").catch(() => null),
|
|
44
|
-
fetchApi("/api/models").catch(() => []),
|
|
45
|
-
fetchApi("/api/services").catch(() => []),
|
|
46
|
-
fetchApi("/api/operations").catch(() => []),
|
|
47
|
-
fetchApi("/api/config").catch(() => {}),
|
|
48
|
-
fetchApi("/api/requests").catch(() => []),
|
|
49
|
-
fetchApi("/api/logs").catch(() => [])
|
|
50
|
-
]).then(([info, models, services, operations, config, requests, logs]) => {
|
|
51
|
-
setCache({ info, models, services, operations, config, requests, logs });
|
|
52
|
-
});
|
|
53
|
-
}, [dataVersion]);
|
|
54
|
-
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
const onKeyDown = (e) => {
|
|
57
|
-
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return;
|
|
58
|
-
if (e.key === "ArrowLeft") {
|
|
59
|
-
e.preventDefault();
|
|
60
|
-
setTab((prev) => {
|
|
61
|
-
const idx = TABS.findIndex((t) => t.id === prev);
|
|
62
|
-
return TABS[(idx - 1 + TABS.length) % TABS.length].id;
|
|
63
|
-
});
|
|
64
|
-
} else if (e.key === "ArrowRight") {
|
|
65
|
-
e.preventDefault();
|
|
66
|
-
setTab((prev) => {
|
|
67
|
-
const idx = TABS.findIndex((t) => t.id === prev);
|
|
68
|
-
return TABS[(idx + 1) % TABS.length].id;
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
window.addEventListener("keydown", onKeyDown);
|
|
73
|
-
return () => window.removeEventListener("keydown", onKeyDown);
|
|
74
|
-
}, []);
|
|
75
|
-
|
|
76
|
-
const connectWs = useCallback(() => {
|
|
77
|
-
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
|
78
|
-
|
|
79
|
-
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
80
|
-
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
|
81
|
-
wsRef.current = ws;
|
|
82
|
-
|
|
83
|
-
ws.addEventListener("open", () => {
|
|
84
|
-
setWsConnected(true);
|
|
85
|
-
if (reconnectTimer.current) {
|
|
86
|
-
clearTimeout(reconnectTimer.current);
|
|
87
|
-
reconnectTimer.current = null;
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
ws.addEventListener("message", (e) => {
|
|
92
|
-
try {
|
|
93
|
-
const data = JSON.parse(e.data);
|
|
94
|
-
if (data.type === "restart") {
|
|
95
|
-
// Trigger data refetch across all panels
|
|
96
|
-
setDataVersion((v) => v + 1);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
// Dispatch custom event so individual panels can listen
|
|
100
|
-
window.dispatchEvent(new CustomEvent("debug-ws-event", { detail: data }));
|
|
101
|
-
setWsEvents((prev) => [data, ...prev].slice(0, 500));
|
|
102
|
-
} catch {
|
|
103
|
-
// ignore parse errors
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
ws.addEventListener("close", () => {
|
|
108
|
-
setWsConnected(false);
|
|
109
|
-
wsRef.current = null;
|
|
110
|
-
// Auto-reconnect after 2 seconds
|
|
111
|
-
reconnectTimer.current = setTimeout(connectWs, 2000);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
ws.addEventListener("error", () => {
|
|
115
|
-
ws.close();
|
|
116
|
-
});
|
|
117
|
-
}, []);
|
|
118
|
-
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
connectWs();
|
|
121
|
-
return () => {
|
|
122
|
-
if (wsRef.current) wsRef.current.close();
|
|
123
|
-
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
124
|
-
};
|
|
125
|
-
}, [connectWs]);
|
|
126
|
-
|
|
127
|
-
const renderPanel = () => {
|
|
128
|
-
switch (tab) {
|
|
129
|
-
case "models":
|
|
130
|
-
return html`<${ModelsPanel} data=${cache.models} fetchApi=${fetchApi} dataVersion=${dataVersion} />`;
|
|
131
|
-
case "services":
|
|
132
|
-
return html`<${ServicesPanel} data=${cache.services} config=${cache.config} fetchApi=${fetchApi} dataVersion=${dataVersion} />`;
|
|
133
|
-
case "operations":
|
|
134
|
-
return html`<${OperationsPanel} data=${cache.operations} fetchApi=${fetchApi} dataVersion=${dataVersion} />`;
|
|
135
|
-
case "requests":
|
|
136
|
-
return html`<${RequestsPanel} data=${cache.requests} fetchApi=${fetchApi} dataVersion=${dataVersion} wsEvents=${wsEvents} />`;
|
|
137
|
-
case "logs":
|
|
138
|
-
return html`<${LogsPanel} data=${cache.logs} fetchApi=${fetchApi} dataVersion=${dataVersion} />`;
|
|
139
|
-
default:
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
return html`
|
|
145
|
-
<div class="header ${wsConnected ? "" : "disconnected"}">
|
|
146
|
-
<div class="header-logo">
|
|
147
|
-
<img src="/logo.svg" alt="Webda" />
|
|
148
|
-
<div class="header-title">web<span>da</span> debug</div>
|
|
149
|
-
${cache.info && html`<div class="header-app-info">
|
|
150
|
-
<div class="header-app-name">${cache.info.package?.name || "unknown"}</div>
|
|
151
|
-
<div class="header-cwd">${(cache.info.workingDirectory || "").replace(/^\/(Users|home)\/[^/]+\//, "~/")}</div>
|
|
152
|
-
</div>`}
|
|
153
|
-
</div>
|
|
154
|
-
<nav class="nav-tabs">
|
|
155
|
-
${TABS.map(
|
|
156
|
-
(t) => html`
|
|
157
|
-
<button
|
|
158
|
-
key=${t.id}
|
|
159
|
-
class="nav-tab ${tab === t.id ? "active" : ""}"
|
|
160
|
-
style="${tab === t.id ? `color:${t.color};border-bottom-color:${t.color}` : ""}"
|
|
161
|
-
onClick=${() => setTab(t.id)}
|
|
162
|
-
>
|
|
163
|
-
${t.label}
|
|
164
|
-
</button>
|
|
165
|
-
`
|
|
166
|
-
)}
|
|
167
|
-
</nav>
|
|
168
|
-
<div class="ws-indicator">
|
|
169
|
-
<span class="ws-dot ${wsConnected ? "" : "disconnected"}"></span>
|
|
170
|
-
<span>${wsConnected ? "Connected" : "Reconnecting..."}</span>
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
<div class="content">${renderPanel()}</div>
|
|
174
|
-
`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
render(html`<${App} />`, document.getElementById("app"));
|
package/webui/components/logs.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { h } from "preact";
|
|
2
|
-
import { useState, useEffect, useRef, useMemo } from "preact/hooks";
|
|
3
|
-
import htm from "htm";
|
|
4
|
-
|
|
5
|
-
const html = htm.bind(h);
|
|
6
|
-
|
|
7
|
-
const LEVEL_COLORS = {
|
|
8
|
-
ERROR: "#ef4444",
|
|
9
|
-
WARN: "#eab308",
|
|
10
|
-
INFO: "#22c55e",
|
|
11
|
-
DEBUG: "#6b7280",
|
|
12
|
-
TRACE: "#4b5563"
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const LEVEL_ORDER = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
|
|
16
|
-
|
|
17
|
-
function formatTime(ts) {
|
|
18
|
-
if (!ts) return "-";
|
|
19
|
-
return new Date(ts).toISOString().substring(11, 23);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function LogsPanel({ data }) {
|
|
23
|
-
const [liveEntries, setLiveEntries] = useState([]);
|
|
24
|
-
const [search, setSearch] = useState("");
|
|
25
|
-
const [autoScroll, setAutoScroll] = useState(true);
|
|
26
|
-
const [levelFilter, setLevelFilter] = useState(() => localStorage.getItem("webda-log-level") || "INFO");
|
|
27
|
-
const listRef = useRef(null);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// Merge cached + live, deduplicate by id
|
|
31
|
-
const allEntries = useMemo(() => {
|
|
32
|
-
const seen = new Set();
|
|
33
|
-
const merged = [];
|
|
34
|
-
for (const e of liveEntries) {
|
|
35
|
-
if (!seen.has(e.id)) { seen.add(e.id); merged.push(e); }
|
|
36
|
-
}
|
|
37
|
-
for (const e of (data || [])) {
|
|
38
|
-
if (!seen.has(e.id)) { seen.add(e.id); merged.push(e); }
|
|
39
|
-
}
|
|
40
|
-
return merged;
|
|
41
|
-
}, [data, liveEntries]);
|
|
42
|
-
|
|
43
|
-
const entries = search
|
|
44
|
-
? allEntries.filter(e => e.message?.toLowerCase().includes(search.toLowerCase()) || e.level?.toLowerCase().includes(search.toLowerCase()))
|
|
45
|
-
: allEntries;
|
|
46
|
-
|
|
47
|
-
// Listen for live log events via custom event dispatched from WebSocket handler
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
const handler = (event) => {
|
|
50
|
-
if (event.detail?.type === "log") {
|
|
51
|
-
setLiveEntries((prev) => [event.detail, ...prev].slice(0, 2000));
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
window.addEventListener("debug-ws-event", handler);
|
|
55
|
-
return () => window.removeEventListener("debug-ws-event", handler);
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (autoScroll && listRef.current) {
|
|
60
|
-
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
61
|
-
}
|
|
62
|
-
}, [entries, autoScroll]);
|
|
63
|
-
|
|
64
|
-
const minLevel = LEVEL_ORDER.indexOf(levelFilter);
|
|
65
|
-
const filtered = minLevel <= 0 ? entries : entries.filter((e) => LEVEL_ORDER.indexOf(e.level) >= minLevel);
|
|
66
|
-
|
|
67
|
-
return html`
|
|
68
|
-
<div>
|
|
69
|
-
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
|
|
70
|
-
<input
|
|
71
|
-
type="text"
|
|
72
|
-
placeholder="Search logs..."
|
|
73
|
-
value=${search}
|
|
74
|
-
onInput=${(e) => setSearch(e.target.value)}
|
|
75
|
-
style="flex:1;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);border-radius:4px;font-family:monospace"
|
|
76
|
-
/>
|
|
77
|
-
<select
|
|
78
|
-
value=${levelFilter}
|
|
79
|
-
onChange=${(e) => { setLevelFilter(e.target.value); localStorage.setItem("webda-log-level", e.target.value); }}
|
|
80
|
-
style="padding:6px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);border-radius:4px"
|
|
81
|
-
>
|
|
82
|
-
<option value="ERROR">ERROR</option>
|
|
83
|
-
<option value="WARN">WARN+</option>
|
|
84
|
-
<option value="INFO">INFO+</option>
|
|
85
|
-
<option value="DEBUG">DEBUG+</option>
|
|
86
|
-
<option value="TRACE">TRACE+</option>
|
|
87
|
-
</select>
|
|
88
|
-
<label style="display:flex;align-items:center;gap:4px;color:var(--text-muted);font-size:12px">
|
|
89
|
-
<input type="checkbox" checked=${autoScroll} onChange=${(e) => setAutoScroll(e.target.checked)} />
|
|
90
|
-
Auto-scroll
|
|
91
|
-
</label>
|
|
92
|
-
</div>
|
|
93
|
-
<div style="margin-bottom:0.75rem;color:var(--text-muted);font-size:0.8125rem">
|
|
94
|
-
${filtered.length} log${filtered.length !== 1 ? "s" : ""}
|
|
95
|
-
</div>
|
|
96
|
-
<div ref=${listRef} class="table-container" style="overflow-y:auto;max-height:calc(100vh - 200px)">
|
|
97
|
-
<table>
|
|
98
|
-
<thead>
|
|
99
|
-
<tr>
|
|
100
|
-
<th style="width:120px">Time</th>
|
|
101
|
-
<th style="width:70px">Level</th>
|
|
102
|
-
<th>Message</th>
|
|
103
|
-
</tr>
|
|
104
|
-
</thead>
|
|
105
|
-
<tbody>
|
|
106
|
-
${filtered.map(
|
|
107
|
-
(entry) => html`
|
|
108
|
-
<tr key=${entry.id}>
|
|
109
|
-
<td class="mono" style="white-space:nowrap;color:var(--text-muted);font-size:12px">${formatTime(entry.timestamp)}</td>
|
|
110
|
-
<td><span class="badge" style="background:${LEVEL_COLORS[entry.level] || "#666"}">${entry.level}</span></td>
|
|
111
|
-
<td class="mono" style="font-size:13px;white-space:pre-wrap;word-break:break-all">${entry.message}</td>
|
|
112
|
-
</tr>
|
|
113
|
-
`
|
|
114
|
-
)}
|
|
115
|
-
${filtered.length === 0 &&
|
|
116
|
-
html`
|
|
117
|
-
<tr>
|
|
118
|
-
<td colspan="3" style="color:var(--text-muted);text-align:center;padding:2rem">
|
|
119
|
-
No logs${search ? " matching search" : " yet"}. Logs will appear here in real-time.
|
|
120
|
-
</td>
|
|
121
|
-
</tr>
|
|
122
|
-
`}
|
|
123
|
-
</tbody>
|
|
124
|
-
</table>
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
`;
|
|
128
|
-
}
|
|
@@ -1,292 +0,0 @@
|
|
|
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
|
-
}
|