@atolis-hq/corum 0.1.0 → 0.1.6
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 +188 -223
- package/dist/src/bin/corum.js +40 -39
- package/package.json +40 -36
- package/web/app.jsx +668 -668
- package/web/index.html +41 -41
- package/web/nav.js +141 -141
- package/web/primitives.jsx +583 -583
- package/web/router.js +49 -49
- package/web/style.css +827 -827
- package/dist/src/cli.js +0 -20
- package/dist/src/openapi-to-api-endpoints.js +0 -240
package/web/app.jsx
CHANGED
|
@@ -1,668 +1,668 @@
|
|
|
1
|
-
/* Main app: router, nav shell, data loading, and node pages. */
|
|
2
|
-
|
|
3
|
-
const { useState, useEffect, useCallback } = React;
|
|
4
|
-
const {
|
|
5
|
-
navigate,
|
|
6
|
-
BrandMark,
|
|
7
|
-
Icon,
|
|
8
|
-
StateTag,
|
|
9
|
-
StabilityTag,
|
|
10
|
-
TemplateBadge,
|
|
11
|
-
PropertiesTable,
|
|
12
|
-
SchemaCard,
|
|
13
|
-
} = window.CorumPrimitives;
|
|
14
|
-
const { buildNavTree, buildOverlayIndicatorIds } = window.CorumNav;
|
|
15
|
-
const { parseRoute, buildRoute } = window.CorumRouter;
|
|
16
|
-
|
|
17
|
-
function displayName(id) {
|
|
18
|
-
const parts = id.split('.');
|
|
19
|
-
return parts[parts.length - 1];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function anchorIdForNode(nodeId) {
|
|
23
|
-
return `node-anchor-${encodeURIComponent(nodeId)}`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function templateDisplayName(template) {
|
|
27
|
-
return template?.ui?.displayName ?? template?.name ?? '';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function summarizeBranchFailure(result) {
|
|
31
|
-
const firstDiagnostic = result?.diagnostics?.[0];
|
|
32
|
-
if (firstDiagnostic) {
|
|
33
|
-
return `${firstDiagnostic.file}: ${firstDiagnostic.message}`;
|
|
34
|
-
}
|
|
35
|
-
return result?.error ?? 'Branch failed to load';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function TopBar() {
|
|
39
|
-
return (
|
|
40
|
-
<div className="topbar">
|
|
41
|
-
<div className="brand">
|
|
42
|
-
<BrandMark size={22} color="#fff" />
|
|
43
|
-
<span>corum</span>
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function NavRail({ activeSection, onSection }) {
|
|
50
|
-
const items = [
|
|
51
|
-
{ id: 'dashboard', icon: 'grip', label: 'Dashboard' },
|
|
52
|
-
{ id: 'components', icon: 'circle-nodes', label: 'Models' },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<div className="nav-rail">
|
|
57
|
-
{items.map(item => (
|
|
58
|
-
<button
|
|
59
|
-
key={item.id}
|
|
60
|
-
className={`nav-rail-item${activeSection === item.id ? ' active' : ''}`}
|
|
61
|
-
onClick={() => onSection(item.id)}
|
|
62
|
-
title={item.label}
|
|
63
|
-
type="button"
|
|
64
|
-
>
|
|
65
|
-
<Icon name={item.icon} size={16} />
|
|
66
|
-
<span>{item.label}</span>
|
|
67
|
-
</button>
|
|
68
|
-
))}
|
|
69
|
-
</div>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function NavTree({ navTree, templates, activeNodeId, onNode, overlayIndicatorIds }) {
|
|
74
|
-
const sortedComponents = [...navTree.keys()].sort((a, b) => a.localeCompare(b));
|
|
75
|
-
const [openComponent, setOpenComponent] = useState();
|
|
76
|
-
const templateMap = new Map(templates.map(template => [template.name, template]));
|
|
77
|
-
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (openComponent === undefined) {
|
|
80
|
-
setOpenComponent(sortedComponents[0] ?? null);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (openComponent !== null && !navTree.has(openComponent)) {
|
|
84
|
-
setOpenComponent(sortedComponents[0] ?? null);
|
|
85
|
-
}
|
|
86
|
-
}, [navTree, openComponent, sortedComponents]);
|
|
87
|
-
|
|
88
|
-
function toggleComponent(component) {
|
|
89
|
-
setOpenComponent(prev => prev === component ? null : component);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (navTree.size === 0) {
|
|
93
|
-
return <div className="nav-tree"><div className="empty-state">No graph nodes loaded.</div></div>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
<div className="nav-tree">
|
|
98
|
-
{sortedComponents.map(component => {
|
|
99
|
-
const entries = navTree.get(component);
|
|
100
|
-
return (
|
|
101
|
-
<div key={component}>
|
|
102
|
-
<div className="nav-section-head" onClick={() => toggleComponent(component)}>
|
|
103
|
-
<span>{component}</span>
|
|
104
|
-
<Icon name={openComponent === component ? 'chevron-down' : 'chevron-right'} size={12} />
|
|
105
|
-
</div>
|
|
106
|
-
{openComponent === component && entries.map(entry => {
|
|
107
|
-
if (entry.kind === 'group') {
|
|
108
|
-
return (
|
|
109
|
-
<div key={entry.groupTemplateName}>
|
|
110
|
-
<div className="nav-template-head">
|
|
111
|
-
{entry.icon && (
|
|
112
|
-
<i
|
|
113
|
-
className={`fa-solid fa-${entry.icon}`}
|
|
114
|
-
style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }}
|
|
115
|
-
/>
|
|
116
|
-
)}
|
|
117
|
-
<span>{entry.label}</span>
|
|
118
|
-
</div>
|
|
119
|
-
{entry.children.map(child => (
|
|
120
|
-
<div key={child.templateName}>
|
|
121
|
-
<div className="nav-subtype-head">
|
|
122
|
-
{child.icon && (
|
|
123
|
-
<i
|
|
124
|
-
className={`fa-solid fa-${child.icon}`}
|
|
125
|
-
style={{ fontSize: 11, width: 14, textAlign: 'center', flexShrink: 0 }}
|
|
126
|
-
/>
|
|
127
|
-
)}
|
|
128
|
-
<span>{child.label}</span>
|
|
129
|
-
</div>
|
|
130
|
-
{child.nodes.map(node => {
|
|
131
|
-
const isActive = node.id === activeNodeId;
|
|
132
|
-
return (
|
|
133
|
-
<div
|
|
134
|
-
key={node.id}
|
|
135
|
-
className={`nav-node-item${isActive ? ' active' : ''}`}
|
|
136
|
-
onClick={() => onNode(node.id)}
|
|
137
|
-
title={node.id}
|
|
138
|
-
style={isActive ? { '--nav-node-active-bg': child.colour } : undefined}
|
|
139
|
-
>
|
|
140
|
-
{displayName(node.id)}
|
|
141
|
-
{overlayIndicatorIds && overlayIndicatorIds.has(node.id) && (
|
|
142
|
-
<span className="signal-dots">
|
|
143
|
-
<span className="signal-dot signal-dot-0" />
|
|
144
|
-
</span>
|
|
145
|
-
)}
|
|
146
|
-
</div>
|
|
147
|
-
);
|
|
148
|
-
})}
|
|
149
|
-
</div>
|
|
150
|
-
))}
|
|
151
|
-
</div>
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const template = templateMap.get(entry.templateName);
|
|
156
|
-
const colour = template?.ui?.colour ?? 'var(--ink-4)';
|
|
157
|
-
return (
|
|
158
|
-
<div key={entry.templateName}>
|
|
159
|
-
<div className="nav-template-head">
|
|
160
|
-
<i
|
|
161
|
-
className={`fa-solid fa-${template?.ui?.icon ?? 'circle'}`}
|
|
162
|
-
style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }}
|
|
163
|
-
/>
|
|
164
|
-
<span>{templateDisplayName(template)}</span>
|
|
165
|
-
</div>
|
|
166
|
-
{entry.nodes.map(node => {
|
|
167
|
-
const isActive = node.id === activeNodeId;
|
|
168
|
-
return (
|
|
169
|
-
<div key={node.id}>
|
|
170
|
-
<div
|
|
171
|
-
className={`nav-node-item${isActive ? ' active' : ''}`}
|
|
172
|
-
onClick={() => onNode(node.id)}
|
|
173
|
-
title={node.id}
|
|
174
|
-
style={isActive ? { '--nav-node-active-bg': colour } : undefined}
|
|
175
|
-
>
|
|
176
|
-
{displayName(node.id)}
|
|
177
|
-
</div>
|
|
178
|
-
{(node.navChildren ?? []).map(group => (
|
|
179
|
-
<div className="nav-child-group" key={group.label}>
|
|
180
|
-
<div className="nav-child-head">{group.label}</div>
|
|
181
|
-
{group.nodes.map(child => {
|
|
182
|
-
const childTemplate = templateMap.get(child.template);
|
|
183
|
-
const childColour = childTemplate?.ui?.colour ?? colour;
|
|
184
|
-
const childIsActive = child.id === activeNodeId;
|
|
185
|
-
return (
|
|
186
|
-
<div
|
|
187
|
-
key={child.id}
|
|
188
|
-
className={`nav-node-item nav-node-child${childIsActive ? ' active' : ''}`}
|
|
189
|
-
onClick={() => onNode(child.id)}
|
|
190
|
-
title={child.id}
|
|
191
|
-
style={childIsActive ? { '--nav-node-active-bg': childColour } : undefined}
|
|
192
|
-
>
|
|
193
|
-
{displayName(child.id)}
|
|
194
|
-
{overlayIndicatorIds && overlayIndicatorIds.has(child.id) && (
|
|
195
|
-
<span className="signal-dots">
|
|
196
|
-
<span className="signal-dot signal-dot-0" />
|
|
197
|
-
</span>
|
|
198
|
-
)}
|
|
199
|
-
</div>
|
|
200
|
-
);
|
|
201
|
-
})}
|
|
202
|
-
</div>
|
|
203
|
-
))}
|
|
204
|
-
</div>
|
|
205
|
-
);
|
|
206
|
-
})}
|
|
207
|
-
</div>
|
|
208
|
-
);
|
|
209
|
-
})}
|
|
210
|
-
</div>
|
|
211
|
-
);
|
|
212
|
-
})}
|
|
213
|
-
</div>
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function BranchBar({ branches, branchResults, viewingRef, overlayRefs, overlayMode, onViewingRef, onOverlayRefs, onOverlayMode, onReload }) {
|
|
218
|
-
const { useState: useLocalState, useEffect: useLocalEffect, useRef: useLocalRef } = React;
|
|
219
|
-
const [pickerOpen, setPickerOpen] = useLocalState(false);
|
|
220
|
-
const [comparePickerOpen, setComparePickerOpen] = useLocalState(false);
|
|
221
|
-
const viewingPickerRef = useLocalRef(null);
|
|
222
|
-
const comparePickerRef = useLocalRef(null);
|
|
223
|
-
|
|
224
|
-
useLocalEffect(() => {
|
|
225
|
-
if (!pickerOpen && !comparePickerOpen) return;
|
|
226
|
-
function handleClickOutside(e) {
|
|
227
|
-
if (viewingPickerRef.current && !viewingPickerRef.current.contains(e.target)) {
|
|
228
|
-
setPickerOpen(false);
|
|
229
|
-
}
|
|
230
|
-
if (comparePickerRef.current && !comparePickerRef.current.contains(e.target)) {
|
|
231
|
-
setComparePickerOpen(false);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
document.addEventListener('mousedown', handleClickOutside);
|
|
235
|
-
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
236
|
-
}, [pickerOpen, comparePickerOpen]);
|
|
237
|
-
const failedBranches = branchResults.filter(result => result.status === 'failed');
|
|
238
|
-
const compareableBranches = branches.filter(branch => branch.ref !== viewingRef);
|
|
239
|
-
|
|
240
|
-
const effectiveOverlayRefs = overlayMode === 'consolidated'
|
|
241
|
-
? branches.filter(branch => branch.ref !== viewingRef).map(branch => branch.ref)
|
|
242
|
-
: overlayMode === 'selected'
|
|
243
|
-
? overlayRefs
|
|
244
|
-
: [];
|
|
245
|
-
|
|
246
|
-
const visibleOverlayRefs = effectiveOverlayRefs.slice(0, 3);
|
|
247
|
-
const hiddenCount = effectiveOverlayRefs.length - visibleOverlayRefs.length;
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<div className="branch-bar">
|
|
251
|
-
<span className="branch-label">⎇</span>
|
|
252
|
-
<span className="branch-label">Viewing</span>
|
|
253
|
-
<div ref={viewingPickerRef} style={{ position: 'relative' }}>
|
|
254
|
-
<span
|
|
255
|
-
className="branch-chip viewing"
|
|
256
|
-
onClick={() => { setPickerOpen(open => !open); setComparePickerOpen(false); }}
|
|
257
|
-
title="Switch viewing branch"
|
|
258
|
-
>
|
|
259
|
-
{viewingRef}
|
|
260
|
-
</span>
|
|
261
|
-
{failedBranches.length > 0 && (
|
|
262
|
-
<span className="branch-failed-badge" title={failedBranches.map(summarizeBranchFailure).join('\n')}>
|
|
263
|
-
{failedBranches.length} failed
|
|
264
|
-
</span>
|
|
265
|
-
)}
|
|
266
|
-
{pickerOpen && (
|
|
267
|
-
<div className="branch-picker">
|
|
268
|
-
{branches.map(branch => (
|
|
269
|
-
<div
|
|
270
|
-
key={branch.ref}
|
|
271
|
-
className={`branch-picker-item${branch.ref === viewingRef ? ' active' : ''}`}
|
|
272
|
-
onClick={() => { onViewingRef(branch.ref); setPickerOpen(false); }}
|
|
273
|
-
>
|
|
274
|
-
{branch.ref === viewingRef && <Icon name="check" size={11} />}
|
|
275
|
-
{branch.ref}
|
|
276
|
-
</div>
|
|
277
|
-
))}
|
|
278
|
-
{failedBranches.map(result => (
|
|
279
|
-
<div
|
|
280
|
-
key={result.ref}
|
|
281
|
-
className="branch-picker-item branch-picker-item-disabled"
|
|
282
|
-
title={summarizeBranchFailure(result)}
|
|
283
|
-
>
|
|
284
|
-
<div className="branch-picker-main">{result.ref}</div>
|
|
285
|
-
<div className="branch-picker-error">{summarizeBranchFailure(result)}</div>
|
|
286
|
-
</div>
|
|
287
|
-
))}
|
|
288
|
-
</div>
|
|
289
|
-
)}
|
|
290
|
-
</div>
|
|
291
|
-
{overlayMode === 'selected' && (
|
|
292
|
-
<>
|
|
293
|
-
<span className="branch-label">Compare</span>
|
|
294
|
-
<div ref={comparePickerRef} style={{ position: 'relative' }}>
|
|
295
|
-
<span
|
|
296
|
-
className="branch-chip overlay branch-chip-select"
|
|
297
|
-
onClick={() => { setComparePickerOpen(open => !open); setPickerOpen(false); }}
|
|
298
|
-
title="Select compare branches"
|
|
299
|
-
>
|
|
300
|
-
{overlayRefs.length > 0 ? `${overlayRefs.length} selected` : 'Select branches'}
|
|
301
|
-
</span>
|
|
302
|
-
{comparePickerOpen && (
|
|
303
|
-
<div className="branch-picker">
|
|
304
|
-
{compareableBranches.map(branch => (
|
|
305
|
-
<label key={branch.ref} className="branch-picker-item branch-picker-item-selectable">
|
|
306
|
-
<span className="branch-picker-main">{branch.ref}</span>
|
|
307
|
-
<input
|
|
308
|
-
className="branch-picker-check"
|
|
309
|
-
type="checkbox"
|
|
310
|
-
checked={overlayRefs.includes(branch.ref)}
|
|
311
|
-
onChange={() => onOverlayRefs(overlayRefs.includes(branch.ref)
|
|
312
|
-
? overlayRefs.filter(ref => ref !== branch.ref)
|
|
313
|
-
: [...overlayRefs, branch.ref])}
|
|
314
|
-
/>
|
|
315
|
-
</label>
|
|
316
|
-
))}
|
|
317
|
-
</div>
|
|
318
|
-
)}
|
|
319
|
-
</div>
|
|
320
|
-
</>
|
|
321
|
-
)}
|
|
322
|
-
{overlayMode !== 'single' && effectiveOverlayRefs.length > 0 && (
|
|
323
|
-
<span className="branch-label">overlaid with</span>
|
|
324
|
-
)}
|
|
325
|
-
{overlayMode === 'selected' && visibleOverlayRefs.map(ref => (
|
|
326
|
-
<span key={ref} className="branch-chip overlay">
|
|
327
|
-
{ref}
|
|
328
|
-
<span
|
|
329
|
-
className="branch-chip-remove"
|
|
330
|
-
onClick={() => onOverlayRefs(overlayRefs.filter(item => item !== ref))}
|
|
331
|
-
>x</span>
|
|
332
|
-
</span>
|
|
333
|
-
))}
|
|
334
|
-
{overlayMode === 'consolidated' && visibleOverlayRefs.map(ref => (
|
|
335
|
-
<span key={ref} className="branch-chip overlay">{ref}</span>
|
|
336
|
-
))}
|
|
337
|
-
{hiddenCount > 0 && (
|
|
338
|
-
<span className="branch-chip more">+{hiddenCount} more</span>
|
|
339
|
-
)}
|
|
340
|
-
<div className="branch-bar-spacer" />
|
|
341
|
-
<span className="branch-chip reload" onClick={onReload} title="Reload branches and graph data">
|
|
342
|
-
Reload
|
|
343
|
-
</span>
|
|
344
|
-
<div className="branch-seg">
|
|
345
|
-
{['single', 'selected', 'consolidated'].map(mode => (
|
|
346
|
-
<span
|
|
347
|
-
key={mode}
|
|
348
|
-
className={`branch-seg-item${overlayMode === mode ? ' active' : ''}`}
|
|
349
|
-
onClick={() => onOverlayMode(mode)}
|
|
350
|
-
>
|
|
351
|
-
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
352
|
-
</span>
|
|
353
|
-
))}
|
|
354
|
-
</div>
|
|
355
|
-
</div>
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function DashboardPage() {
|
|
360
|
-
return <div className="content"><h1>Dashboard</h1></div>;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function ComponentsPage() {
|
|
364
|
-
return <div className="content"><h1>Components</h1></div>;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, overlayRefs }) {
|
|
368
|
-
const [cluster, setCluster] = useState(null);
|
|
369
|
-
const [error, setError] = useState(null);
|
|
370
|
-
|
|
371
|
-
useEffect(() => {
|
|
372
|
-
if (!nodeId) return;
|
|
373
|
-
setCluster(null);
|
|
374
|
-
setError(null);
|
|
375
|
-
const refParam = viewingRef ? `&ref=${encodeURIComponent(viewingRef)}` : '';
|
|
376
|
-
const overlayParam = overlayRefs && overlayRefs.length > 0
|
|
377
|
-
? '&' + overlayRefs.map(ref => `overlayRefs=${encodeURIComponent(ref)}`).join('&')
|
|
378
|
-
: '';
|
|
379
|
-
fetch(`/api/cluster?nodeId=${encodeURIComponent(nodeId)}&includeEdges=maps-to${refParam}${overlayParam}`)
|
|
380
|
-
.then(response => response.ok ? response.json() : Promise.reject(response.status))
|
|
381
|
-
.then(setCluster)
|
|
382
|
-
.catch(err => setError(String(err)));
|
|
383
|
-
}, [nodeId, refreshToken, viewingRef, overlayRefs]);
|
|
384
|
-
|
|
385
|
-
if (!nodeId) return <div className="content"><p className="label-sm">No node selected.</p></div>;
|
|
386
|
-
if (error) return <div className="content"><p style={{ color: 'var(--warn)' }}>Error loading node: {error}</p></div>;
|
|
387
|
-
if (!cluster) return <div className="content"><p className="label-sm">Loading...</p></div>;
|
|
388
|
-
|
|
389
|
-
const { root, descendants, includedNodes, edges } = cluster;
|
|
390
|
-
const template = templates.find(item => item.name === root.template);
|
|
391
|
-
const colour = template?.ui?.colour ?? null;
|
|
392
|
-
const nestedSections = new Set((template?.ui?.nav?.nestOwned ?? []).map(item => item.section));
|
|
393
|
-
const Plugin = window.CorumPlugins?.[root.template];
|
|
394
|
-
if (Plugin) return <Plugin node={root} cluster={cluster} template={template} />;
|
|
395
|
-
|
|
396
|
-
const displayChildren = new Map();
|
|
397
|
-
for (const child of descendants) {
|
|
398
|
-
if (child.parentId === root.id && nestedSections.has(child.ownedSection)) continue;
|
|
399
|
-
if (!displayChildren.has(child.template)) displayChildren.set(child.template, []);
|
|
400
|
-
displayChildren.get(child.template).push(child);
|
|
401
|
-
}
|
|
402
|
-
const displayedNodeIds = new Set([
|
|
403
|
-
root.id,
|
|
404
|
-
...Array.from(displayChildren.values()).reduce((all, group) => all.concat(group), []).map(child => child.id),
|
|
405
|
-
]);
|
|
406
|
-
const rootSpecializedTemplates = new Set(['Schema', 'EnumDefinition']);
|
|
407
|
-
const rootSpecializedNodes = rootSpecializedTemplates.has(root.template) ? [[root.template, [root]]] : [];
|
|
408
|
-
const childDisplayEntries = [...displayChildren.entries()]
|
|
409
|
-
.filter(([templateName]) => templateName !== 'Field' && templateName !== 'EnumValue');
|
|
410
|
-
const displayEntries = [...rootSpecializedNodes, ...childDisplayEntries];
|
|
411
|
-
|
|
412
|
-
function handlePropertyNavigate(targetNodeId) {
|
|
413
|
-
if (displayedNodeIds.has(targetNodeId)) {
|
|
414
|
-
document.getElementById(anchorIdForNode(targetNodeId))?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
onNavigate(targetNodeId);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return (
|
|
421
|
-
<div className="content">
|
|
422
|
-
<div id={anchorIdForNode(root.id)} style={{ marginBottom: 18 }}>
|
|
423
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
424
|
-
<h1 style={{ margin: 0 }}>{displayName(root.id)}</h1>
|
|
425
|
-
<TemplateBadge name={templateDisplayName(template)} colour={colour} />
|
|
426
|
-
<StateTag state={root.state} />
|
|
427
|
-
<StabilityTag stability={root.stability} />
|
|
428
|
-
</div>
|
|
429
|
-
<div className="label-sm mono">{root.id}</div>
|
|
430
|
-
</div>
|
|
431
|
-
|
|
432
|
-
<div className="meta-strip">
|
|
433
|
-
{[
|
|
434
|
-
['Component', root.component],
|
|
435
|
-
['State', root.state],
|
|
436
|
-
['Stability', root.stability],
|
|
437
|
-
['Schema version', root.schemaVersion],
|
|
438
|
-
['Last modified', root.lastModifiedAt],
|
|
439
|
-
].map(([label, value]) => (
|
|
440
|
-
<div key={label} className="meta-cell">
|
|
441
|
-
<div className="label-xs">{label}</div>
|
|
442
|
-
<div style={{ fontSize: 12, marginTop: 3 }}>{value}</div>
|
|
443
|
-
</div>
|
|
444
|
-
))}
|
|
445
|
-
</div>
|
|
446
|
-
|
|
447
|
-
{Object.keys(root.properties ?? {}).length > 0 && (
|
|
448
|
-
<div className="card">
|
|
449
|
-
<div className="card-head">Properties</div>
|
|
450
|
-
<div className="card-body">
|
|
451
|
-
<PropertiesTable properties={root.properties} onNavigate={handlePropertyNavigate} />
|
|
452
|
-
</div>
|
|
453
|
-
</div>
|
|
454
|
-
)}
|
|
455
|
-
|
|
456
|
-
{displayEntries.map(([templateName, groupNodes]) => (
|
|
457
|
-
<SchemaCard
|
|
458
|
-
key={templateName}
|
|
459
|
-
title={templateName}
|
|
460
|
-
nodes={groupNodes}
|
|
461
|
-
allNodes={[root, ...descendants, ...includedNodes]}
|
|
462
|
-
edges={edges}
|
|
463
|
-
anchorIdForNode={anchorIdForNode}
|
|
464
|
-
overlayFields={cluster.overlay ? cluster.overlay.fields : null}
|
|
465
|
-
overlayRefs={cluster.overlay ? cluster.overlay.overlayRefs : null}
|
|
466
|
-
/>
|
|
467
|
-
))}
|
|
468
|
-
</div>
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function resolveTemplates(templates) {
|
|
473
|
-
const map = new Map(templates.map(t => [t.name, t]));
|
|
474
|
-
for (const t of templates) {
|
|
475
|
-
const groupName = t.ui?.nav?.navGroup;
|
|
476
|
-
if (!groupName || t.ui?.colour) continue;
|
|
477
|
-
const groupColour = map.get(groupName)?.ui?.colour;
|
|
478
|
-
if (groupColour) {
|
|
479
|
-
t.ui = { ...t.ui, colour: groupColour };
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
return templates;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function App() {
|
|
486
|
-
const [templates, setTemplates] = useState([]);
|
|
487
|
-
const [nodes, setNodes] = useState([]);
|
|
488
|
-
const [loading, setLoading] = useState(true);
|
|
489
|
-
const [error, setError] = useState(null);
|
|
490
|
-
const [route, setRoute] = useState(() => parseRoute(window.location.hash));
|
|
491
|
-
const [refreshToken, setRefreshToken] = useState(0);
|
|
492
|
-
const [gitMode, setGitMode] = useState(false);
|
|
493
|
-
const [branches, setBranches] = useState([]);
|
|
494
|
-
const [branchResults, setBranchResults] = useState([]);
|
|
495
|
-
const [viewingRef, setViewingRef] = useState(null);
|
|
496
|
-
const [overlayRefs, setOverlayRefs] = useState([]);
|
|
497
|
-
const [overlayMode, setOverlayMode] = useState('single');
|
|
498
|
-
const [overlayIndicatorIds, setOverlayIndicatorIds] = useState(new Set());
|
|
499
|
-
|
|
500
|
-
const refreshGraphData = useCallback((targetViewingRef = viewingRef) => {
|
|
501
|
-
setError(null);
|
|
502
|
-
const refParam = targetViewingRef ? `?ref=${encodeURIComponent(targetViewingRef)}` : '';
|
|
503
|
-
return Promise.all([
|
|
504
|
-
fetch(`/api/templates${refParam}`).then(response => response.ok ? response.json() : Promise.reject(response.status)),
|
|
505
|
-
fetch(`/api/nodes${refParam}`).then(response => response.ok ? response.json() : Promise.reject(response.status)),
|
|
506
|
-
])
|
|
507
|
-
.then(([templateData, nodeData]) => {
|
|
508
|
-
setTemplates(resolveTemplates(templateData));
|
|
509
|
-
setNodes(nodeData);
|
|
510
|
-
setRefreshToken(token => token + 1);
|
|
511
|
-
setLoading(false);
|
|
512
|
-
})
|
|
513
|
-
.catch(err => {
|
|
514
|
-
setError(String(err));
|
|
515
|
-
setLoading(false);
|
|
516
|
-
});
|
|
517
|
-
}, [viewingRef]);
|
|
518
|
-
|
|
519
|
-
const refreshBranchState = useCallback(() => {
|
|
520
|
-
return fetch('/api/branches')
|
|
521
|
-
.then(res => {
|
|
522
|
-
if (!res.ok) return null;
|
|
523
|
-
return res.json();
|
|
524
|
-
})
|
|
525
|
-
.then(data => {
|
|
526
|
-
if (!data) return null;
|
|
527
|
-
setGitMode(true);
|
|
528
|
-
setBranches(data.branches || []);
|
|
529
|
-
setBranchResults(data.results || []);
|
|
530
|
-
const urlRef = parseRoute(window.location.hash).branch;
|
|
531
|
-
const validUrlRef = (data.branches || []).find(branch => branch.ref === urlRef);
|
|
532
|
-
const validViewingRef = (data.branches || []).find(branch => branch.ref === viewingRef);
|
|
533
|
-
const nextViewingRef = validUrlRef ? urlRef : (validViewingRef ? viewingRef : data.default);
|
|
534
|
-
setViewingRef(nextViewingRef);
|
|
535
|
-
return { nextViewingRef };
|
|
536
|
-
})
|
|
537
|
-
.catch(() => null);
|
|
538
|
-
}, [viewingRef]);
|
|
539
|
-
|
|
540
|
-
const refreshAllData = useCallback(() => {
|
|
541
|
-
return refreshBranchState()
|
|
542
|
-
.then(result => refreshGraphData(result?.nextViewingRef ?? viewingRef));
|
|
543
|
-
}, [refreshBranchState, refreshGraphData, viewingRef]);
|
|
544
|
-
|
|
545
|
-
useEffect(() => {
|
|
546
|
-
if (!window.EventSource) return;
|
|
547
|
-
const eventSource = new EventSource('/api/events');
|
|
548
|
-
eventSource.addEventListener('graph-reloaded', refreshAllData);
|
|
549
|
-
return () => {
|
|
550
|
-
eventSource.removeEventListener('graph-reloaded', refreshAllData);
|
|
551
|
-
eventSource.close();
|
|
552
|
-
};
|
|
553
|
-
}, [refreshAllData]);
|
|
554
|
-
|
|
555
|
-
useEffect(() => {
|
|
556
|
-
refreshBranchState();
|
|
557
|
-
}, [refreshBranchState]);
|
|
558
|
-
|
|
559
|
-
useEffect(() => {
|
|
560
|
-
if (viewingRef !== null || !gitMode) {
|
|
561
|
-
refreshGraphData();
|
|
562
|
-
}
|
|
563
|
-
}, [viewingRef, gitMode, refreshGraphData]);
|
|
564
|
-
|
|
565
|
-
useEffect(() => {
|
|
566
|
-
const handler = () => setRoute(parseRoute(window.location.hash));
|
|
567
|
-
window.addEventListener('hashchange', handler);
|
|
568
|
-
return () => window.removeEventListener('hashchange', handler);
|
|
569
|
-
}, []);
|
|
570
|
-
|
|
571
|
-
const activeNodeId = route.pathname === '/node' ? route.params.get('id') : null;
|
|
572
|
-
const activeSection = activeNodeId ? 'components' : (route.pathname.slice(1) || 'dashboard');
|
|
573
|
-
const navTree = buildNavTree(nodes, templates);
|
|
574
|
-
const showTree = activeSection === 'components' || activeNodeId;
|
|
575
|
-
const activeOverlayRefs = overlayMode === 'single' ? [] :
|
|
576
|
-
overlayMode === 'consolidated' ? branches.filter(branch => branch.ref !== viewingRef).map(branch => branch.ref) :
|
|
577
|
-
overlayRefs;
|
|
578
|
-
|
|
579
|
-
useEffect(() => {
|
|
580
|
-
setOverlayRefs(prev => prev.filter(ref => ref !== viewingRef && branches.some(branch => branch.ref === ref)));
|
|
581
|
-
}, [viewingRef, branches]);
|
|
582
|
-
|
|
583
|
-
useEffect(() => {
|
|
584
|
-
if (!viewingRef || activeOverlayRefs.length === 0) {
|
|
585
|
-
setOverlayIndicatorIds(new Set());
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
fetch(`/api/overlay/${encodeURIComponent(viewingRef)}`)
|
|
589
|
-
.then(res => res.ok ? res.json() : Promise.reject(res.status))
|
|
590
|
-
.then(data => {
|
|
591
|
-
setOverlayIndicatorIds(buildOverlayIndicatorIds(nodes, templates, data.nodes || [], activeOverlayRefs));
|
|
592
|
-
})
|
|
593
|
-
.catch(() => setOverlayIndicatorIds(new Set()));
|
|
594
|
-
}, [viewingRef, overlayMode, overlayRefs, branches, nodes, templates]);
|
|
595
|
-
|
|
596
|
-
function handleSection(section) {
|
|
597
|
-
navigate(buildRoute({ pathname: `/${section}`, params: {}, branch: viewingRef }));
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function handleNode(nodeId) {
|
|
601
|
-
navigate(buildRoute({ pathname: '/node', params: { id: nodeId }, branch: viewingRef }));
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
let page;
|
|
605
|
-
if (loading) {
|
|
606
|
-
page = <div className="content"><p className="label-sm">Loading graph...</p></div>;
|
|
607
|
-
} else if (error) {
|
|
608
|
-
page = <div className="content"><p style={{ color: 'var(--warn)' }}>Error loading graph: {error}</p></div>;
|
|
609
|
-
} else if (route.pathname === '/dashboard' || route.pathname === '/') {
|
|
610
|
-
page = <DashboardPage />;
|
|
611
|
-
} else if (route.pathname === '/components') {
|
|
612
|
-
page = <ComponentsPage />;
|
|
613
|
-
} else if (route.pathname === '/node') {
|
|
614
|
-
page = (
|
|
615
|
-
<NodePage
|
|
616
|
-
nodeId={activeNodeId}
|
|
617
|
-
templates={templates}
|
|
618
|
-
onNavigate={handleNode}
|
|
619
|
-
refreshToken={refreshToken}
|
|
620
|
-
viewingRef={viewingRef}
|
|
621
|
-
overlayRefs={activeOverlayRefs}
|
|
622
|
-
/>
|
|
623
|
-
);
|
|
624
|
-
} else {
|
|
625
|
-
page = <div className="content"><p className="label-sm">Page not found.</p></div>;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
return (
|
|
629
|
-
<>
|
|
630
|
-
<TopBar />
|
|
631
|
-
{gitMode && (
|
|
632
|
-
<BranchBar
|
|
633
|
-
branches={branches}
|
|
634
|
-
branchResults={branchResults}
|
|
635
|
-
viewingRef={viewingRef}
|
|
636
|
-
overlayRefs={overlayRefs}
|
|
637
|
-
overlayMode={overlayMode}
|
|
638
|
-
onViewingRef={ref => {
|
|
639
|
-
setViewingRef(ref);
|
|
640
|
-
navigate(buildRoute({ pathname: route.pathname, params: route.params, branch: ref }));
|
|
641
|
-
}}
|
|
642
|
-
onOverlayRefs={setOverlayRefs}
|
|
643
|
-
onOverlayMode={setOverlayMode}
|
|
644
|
-
onReload={() => {
|
|
645
|
-
fetch('/api/reload', { method: 'POST' })
|
|
646
|
-
.then(() => refreshAllData())
|
|
647
|
-
.catch(() => refreshAllData());
|
|
648
|
-
}}
|
|
649
|
-
/>
|
|
650
|
-
)}
|
|
651
|
-
<div className="main">
|
|
652
|
-
<NavRail activeSection={activeSection} onSection={handleSection} />
|
|
653
|
-
{showTree && !loading && !error && (
|
|
654
|
-
<NavTree
|
|
655
|
-
navTree={navTree}
|
|
656
|
-
templates={templates}
|
|
657
|
-
activeNodeId={activeNodeId}
|
|
658
|
-
onNode={handleNode}
|
|
659
|
-
overlayIndicatorIds={overlayIndicatorIds}
|
|
660
|
-
/>
|
|
661
|
-
)}
|
|
662
|
-
{page}
|
|
663
|
-
</div>
|
|
664
|
-
</>
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
1
|
+
/* Main app: router, nav shell, data loading, and node pages. */
|
|
2
|
+
|
|
3
|
+
const { useState, useEffect, useCallback } = React;
|
|
4
|
+
const {
|
|
5
|
+
navigate,
|
|
6
|
+
BrandMark,
|
|
7
|
+
Icon,
|
|
8
|
+
StateTag,
|
|
9
|
+
StabilityTag,
|
|
10
|
+
TemplateBadge,
|
|
11
|
+
PropertiesTable,
|
|
12
|
+
SchemaCard,
|
|
13
|
+
} = window.CorumPrimitives;
|
|
14
|
+
const { buildNavTree, buildOverlayIndicatorIds } = window.CorumNav;
|
|
15
|
+
const { parseRoute, buildRoute } = window.CorumRouter;
|
|
16
|
+
|
|
17
|
+
function displayName(id) {
|
|
18
|
+
const parts = id.split('.');
|
|
19
|
+
return parts[parts.length - 1];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function anchorIdForNode(nodeId) {
|
|
23
|
+
return `node-anchor-${encodeURIComponent(nodeId)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function templateDisplayName(template) {
|
|
27
|
+
return template?.ui?.displayName ?? template?.name ?? '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function summarizeBranchFailure(result) {
|
|
31
|
+
const firstDiagnostic = result?.diagnostics?.[0];
|
|
32
|
+
if (firstDiagnostic) {
|
|
33
|
+
return `${firstDiagnostic.file}: ${firstDiagnostic.message}`;
|
|
34
|
+
}
|
|
35
|
+
return result?.error ?? 'Branch failed to load';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TopBar() {
|
|
39
|
+
return (
|
|
40
|
+
<div className="topbar">
|
|
41
|
+
<div className="brand">
|
|
42
|
+
<BrandMark size={22} color="#fff" />
|
|
43
|
+
<span>corum</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function NavRail({ activeSection, onSection }) {
|
|
50
|
+
const items = [
|
|
51
|
+
{ id: 'dashboard', icon: 'grip', label: 'Dashboard' },
|
|
52
|
+
{ id: 'components', icon: 'circle-nodes', label: 'Models' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="nav-rail">
|
|
57
|
+
{items.map(item => (
|
|
58
|
+
<button
|
|
59
|
+
key={item.id}
|
|
60
|
+
className={`nav-rail-item${activeSection === item.id ? ' active' : ''}`}
|
|
61
|
+
onClick={() => onSection(item.id)}
|
|
62
|
+
title={item.label}
|
|
63
|
+
type="button"
|
|
64
|
+
>
|
|
65
|
+
<Icon name={item.icon} size={16} />
|
|
66
|
+
<span>{item.label}</span>
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function NavTree({ navTree, templates, activeNodeId, onNode, overlayIndicatorIds }) {
|
|
74
|
+
const sortedComponents = [...navTree.keys()].sort((a, b) => a.localeCompare(b));
|
|
75
|
+
const [openComponent, setOpenComponent] = useState();
|
|
76
|
+
const templateMap = new Map(templates.map(template => [template.name, template]));
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (openComponent === undefined) {
|
|
80
|
+
setOpenComponent(sortedComponents[0] ?? null);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (openComponent !== null && !navTree.has(openComponent)) {
|
|
84
|
+
setOpenComponent(sortedComponents[0] ?? null);
|
|
85
|
+
}
|
|
86
|
+
}, [navTree, openComponent, sortedComponents]);
|
|
87
|
+
|
|
88
|
+
function toggleComponent(component) {
|
|
89
|
+
setOpenComponent(prev => prev === component ? null : component);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (navTree.size === 0) {
|
|
93
|
+
return <div className="nav-tree"><div className="empty-state">No graph nodes loaded.</div></div>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="nav-tree">
|
|
98
|
+
{sortedComponents.map(component => {
|
|
99
|
+
const entries = navTree.get(component);
|
|
100
|
+
return (
|
|
101
|
+
<div key={component}>
|
|
102
|
+
<div className="nav-section-head" onClick={() => toggleComponent(component)}>
|
|
103
|
+
<span>{component}</span>
|
|
104
|
+
<Icon name={openComponent === component ? 'chevron-down' : 'chevron-right'} size={12} />
|
|
105
|
+
</div>
|
|
106
|
+
{openComponent === component && entries.map(entry => {
|
|
107
|
+
if (entry.kind === 'group') {
|
|
108
|
+
return (
|
|
109
|
+
<div key={entry.groupTemplateName}>
|
|
110
|
+
<div className="nav-template-head">
|
|
111
|
+
{entry.icon && (
|
|
112
|
+
<i
|
|
113
|
+
className={`fa-solid fa-${entry.icon}`}
|
|
114
|
+
style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
<span>{entry.label}</span>
|
|
118
|
+
</div>
|
|
119
|
+
{entry.children.map(child => (
|
|
120
|
+
<div key={child.templateName}>
|
|
121
|
+
<div className="nav-subtype-head">
|
|
122
|
+
{child.icon && (
|
|
123
|
+
<i
|
|
124
|
+
className={`fa-solid fa-${child.icon}`}
|
|
125
|
+
style={{ fontSize: 11, width: 14, textAlign: 'center', flexShrink: 0 }}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
<span>{child.label}</span>
|
|
129
|
+
</div>
|
|
130
|
+
{child.nodes.map(node => {
|
|
131
|
+
const isActive = node.id === activeNodeId;
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
key={node.id}
|
|
135
|
+
className={`nav-node-item${isActive ? ' active' : ''}`}
|
|
136
|
+
onClick={() => onNode(node.id)}
|
|
137
|
+
title={node.id}
|
|
138
|
+
style={isActive ? { '--nav-node-active-bg': child.colour } : undefined}
|
|
139
|
+
>
|
|
140
|
+
{displayName(node.id)}
|
|
141
|
+
{overlayIndicatorIds && overlayIndicatorIds.has(node.id) && (
|
|
142
|
+
<span className="signal-dots">
|
|
143
|
+
<span className="signal-dot signal-dot-0" />
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const template = templateMap.get(entry.templateName);
|
|
156
|
+
const colour = template?.ui?.colour ?? 'var(--ink-4)';
|
|
157
|
+
return (
|
|
158
|
+
<div key={entry.templateName}>
|
|
159
|
+
<div className="nav-template-head">
|
|
160
|
+
<i
|
|
161
|
+
className={`fa-solid fa-${template?.ui?.icon ?? 'circle'}`}
|
|
162
|
+
style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }}
|
|
163
|
+
/>
|
|
164
|
+
<span>{templateDisplayName(template)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
{entry.nodes.map(node => {
|
|
167
|
+
const isActive = node.id === activeNodeId;
|
|
168
|
+
return (
|
|
169
|
+
<div key={node.id}>
|
|
170
|
+
<div
|
|
171
|
+
className={`nav-node-item${isActive ? ' active' : ''}`}
|
|
172
|
+
onClick={() => onNode(node.id)}
|
|
173
|
+
title={node.id}
|
|
174
|
+
style={isActive ? { '--nav-node-active-bg': colour } : undefined}
|
|
175
|
+
>
|
|
176
|
+
{displayName(node.id)}
|
|
177
|
+
</div>
|
|
178
|
+
{(node.navChildren ?? []).map(group => (
|
|
179
|
+
<div className="nav-child-group" key={group.label}>
|
|
180
|
+
<div className="nav-child-head">{group.label}</div>
|
|
181
|
+
{group.nodes.map(child => {
|
|
182
|
+
const childTemplate = templateMap.get(child.template);
|
|
183
|
+
const childColour = childTemplate?.ui?.colour ?? colour;
|
|
184
|
+
const childIsActive = child.id === activeNodeId;
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
key={child.id}
|
|
188
|
+
className={`nav-node-item nav-node-child${childIsActive ? ' active' : ''}`}
|
|
189
|
+
onClick={() => onNode(child.id)}
|
|
190
|
+
title={child.id}
|
|
191
|
+
style={childIsActive ? { '--nav-node-active-bg': childColour } : undefined}
|
|
192
|
+
>
|
|
193
|
+
{displayName(child.id)}
|
|
194
|
+
{overlayIndicatorIds && overlayIndicatorIds.has(child.id) && (
|
|
195
|
+
<span className="signal-dots">
|
|
196
|
+
<span className="signal-dot signal-dot-0" />
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function BranchBar({ branches, branchResults, viewingRef, overlayRefs, overlayMode, onViewingRef, onOverlayRefs, onOverlayMode, onReload }) {
|
|
218
|
+
const { useState: useLocalState, useEffect: useLocalEffect, useRef: useLocalRef } = React;
|
|
219
|
+
const [pickerOpen, setPickerOpen] = useLocalState(false);
|
|
220
|
+
const [comparePickerOpen, setComparePickerOpen] = useLocalState(false);
|
|
221
|
+
const viewingPickerRef = useLocalRef(null);
|
|
222
|
+
const comparePickerRef = useLocalRef(null);
|
|
223
|
+
|
|
224
|
+
useLocalEffect(() => {
|
|
225
|
+
if (!pickerOpen && !comparePickerOpen) return;
|
|
226
|
+
function handleClickOutside(e) {
|
|
227
|
+
if (viewingPickerRef.current && !viewingPickerRef.current.contains(e.target)) {
|
|
228
|
+
setPickerOpen(false);
|
|
229
|
+
}
|
|
230
|
+
if (comparePickerRef.current && !comparePickerRef.current.contains(e.target)) {
|
|
231
|
+
setComparePickerOpen(false);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
235
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
236
|
+
}, [pickerOpen, comparePickerOpen]);
|
|
237
|
+
const failedBranches = branchResults.filter(result => result.status === 'failed');
|
|
238
|
+
const compareableBranches = branches.filter(branch => branch.ref !== viewingRef);
|
|
239
|
+
|
|
240
|
+
const effectiveOverlayRefs = overlayMode === 'consolidated'
|
|
241
|
+
? branches.filter(branch => branch.ref !== viewingRef).map(branch => branch.ref)
|
|
242
|
+
: overlayMode === 'selected'
|
|
243
|
+
? overlayRefs
|
|
244
|
+
: [];
|
|
245
|
+
|
|
246
|
+
const visibleOverlayRefs = effectiveOverlayRefs.slice(0, 3);
|
|
247
|
+
const hiddenCount = effectiveOverlayRefs.length - visibleOverlayRefs.length;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div className="branch-bar">
|
|
251
|
+
<span className="branch-label">⎇</span>
|
|
252
|
+
<span className="branch-label">Viewing</span>
|
|
253
|
+
<div ref={viewingPickerRef} style={{ position: 'relative' }}>
|
|
254
|
+
<span
|
|
255
|
+
className="branch-chip viewing"
|
|
256
|
+
onClick={() => { setPickerOpen(open => !open); setComparePickerOpen(false); }}
|
|
257
|
+
title="Switch viewing branch"
|
|
258
|
+
>
|
|
259
|
+
{viewingRef}
|
|
260
|
+
</span>
|
|
261
|
+
{failedBranches.length > 0 && (
|
|
262
|
+
<span className="branch-failed-badge" title={failedBranches.map(summarizeBranchFailure).join('\n')}>
|
|
263
|
+
{failedBranches.length} failed
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
{pickerOpen && (
|
|
267
|
+
<div className="branch-picker">
|
|
268
|
+
{branches.map(branch => (
|
|
269
|
+
<div
|
|
270
|
+
key={branch.ref}
|
|
271
|
+
className={`branch-picker-item${branch.ref === viewingRef ? ' active' : ''}`}
|
|
272
|
+
onClick={() => { onViewingRef(branch.ref); setPickerOpen(false); }}
|
|
273
|
+
>
|
|
274
|
+
{branch.ref === viewingRef && <Icon name="check" size={11} />}
|
|
275
|
+
{branch.ref}
|
|
276
|
+
</div>
|
|
277
|
+
))}
|
|
278
|
+
{failedBranches.map(result => (
|
|
279
|
+
<div
|
|
280
|
+
key={result.ref}
|
|
281
|
+
className="branch-picker-item branch-picker-item-disabled"
|
|
282
|
+
title={summarizeBranchFailure(result)}
|
|
283
|
+
>
|
|
284
|
+
<div className="branch-picker-main">{result.ref}</div>
|
|
285
|
+
<div className="branch-picker-error">{summarizeBranchFailure(result)}</div>
|
|
286
|
+
</div>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
{overlayMode === 'selected' && (
|
|
292
|
+
<>
|
|
293
|
+
<span className="branch-label">Compare</span>
|
|
294
|
+
<div ref={comparePickerRef} style={{ position: 'relative' }}>
|
|
295
|
+
<span
|
|
296
|
+
className="branch-chip overlay branch-chip-select"
|
|
297
|
+
onClick={() => { setComparePickerOpen(open => !open); setPickerOpen(false); }}
|
|
298
|
+
title="Select compare branches"
|
|
299
|
+
>
|
|
300
|
+
{overlayRefs.length > 0 ? `${overlayRefs.length} selected` : 'Select branches'}
|
|
301
|
+
</span>
|
|
302
|
+
{comparePickerOpen && (
|
|
303
|
+
<div className="branch-picker">
|
|
304
|
+
{compareableBranches.map(branch => (
|
|
305
|
+
<label key={branch.ref} className="branch-picker-item branch-picker-item-selectable">
|
|
306
|
+
<span className="branch-picker-main">{branch.ref}</span>
|
|
307
|
+
<input
|
|
308
|
+
className="branch-picker-check"
|
|
309
|
+
type="checkbox"
|
|
310
|
+
checked={overlayRefs.includes(branch.ref)}
|
|
311
|
+
onChange={() => onOverlayRefs(overlayRefs.includes(branch.ref)
|
|
312
|
+
? overlayRefs.filter(ref => ref !== branch.ref)
|
|
313
|
+
: [...overlayRefs, branch.ref])}
|
|
314
|
+
/>
|
|
315
|
+
</label>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
{overlayMode !== 'single' && effectiveOverlayRefs.length > 0 && (
|
|
323
|
+
<span className="branch-label">overlaid with</span>
|
|
324
|
+
)}
|
|
325
|
+
{overlayMode === 'selected' && visibleOverlayRefs.map(ref => (
|
|
326
|
+
<span key={ref} className="branch-chip overlay">
|
|
327
|
+
{ref}
|
|
328
|
+
<span
|
|
329
|
+
className="branch-chip-remove"
|
|
330
|
+
onClick={() => onOverlayRefs(overlayRefs.filter(item => item !== ref))}
|
|
331
|
+
>x</span>
|
|
332
|
+
</span>
|
|
333
|
+
))}
|
|
334
|
+
{overlayMode === 'consolidated' && visibleOverlayRefs.map(ref => (
|
|
335
|
+
<span key={ref} className="branch-chip overlay">{ref}</span>
|
|
336
|
+
))}
|
|
337
|
+
{hiddenCount > 0 && (
|
|
338
|
+
<span className="branch-chip more">+{hiddenCount} more</span>
|
|
339
|
+
)}
|
|
340
|
+
<div className="branch-bar-spacer" />
|
|
341
|
+
<span className="branch-chip reload" onClick={onReload} title="Reload branches and graph data">
|
|
342
|
+
Reload
|
|
343
|
+
</span>
|
|
344
|
+
<div className="branch-seg">
|
|
345
|
+
{['single', 'selected', 'consolidated'].map(mode => (
|
|
346
|
+
<span
|
|
347
|
+
key={mode}
|
|
348
|
+
className={`branch-seg-item${overlayMode === mode ? ' active' : ''}`}
|
|
349
|
+
onClick={() => onOverlayMode(mode)}
|
|
350
|
+
>
|
|
351
|
+
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
352
|
+
</span>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function DashboardPage() {
|
|
360
|
+
return <div className="content"><h1>Dashboard</h1></div>;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function ComponentsPage() {
|
|
364
|
+
return <div className="content"><h1>Components</h1></div>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, overlayRefs }) {
|
|
368
|
+
const [cluster, setCluster] = useState(null);
|
|
369
|
+
const [error, setError] = useState(null);
|
|
370
|
+
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (!nodeId) return;
|
|
373
|
+
setCluster(null);
|
|
374
|
+
setError(null);
|
|
375
|
+
const refParam = viewingRef ? `&ref=${encodeURIComponent(viewingRef)}` : '';
|
|
376
|
+
const overlayParam = overlayRefs && overlayRefs.length > 0
|
|
377
|
+
? '&' + overlayRefs.map(ref => `overlayRefs=${encodeURIComponent(ref)}`).join('&')
|
|
378
|
+
: '';
|
|
379
|
+
fetch(`/api/cluster?nodeId=${encodeURIComponent(nodeId)}&includeEdges=maps-to${refParam}${overlayParam}`)
|
|
380
|
+
.then(response => response.ok ? response.json() : Promise.reject(response.status))
|
|
381
|
+
.then(setCluster)
|
|
382
|
+
.catch(err => setError(String(err)));
|
|
383
|
+
}, [nodeId, refreshToken, viewingRef, overlayRefs]);
|
|
384
|
+
|
|
385
|
+
if (!nodeId) return <div className="content"><p className="label-sm">No node selected.</p></div>;
|
|
386
|
+
if (error) return <div className="content"><p style={{ color: 'var(--warn)' }}>Error loading node: {error}</p></div>;
|
|
387
|
+
if (!cluster) return <div className="content"><p className="label-sm">Loading...</p></div>;
|
|
388
|
+
|
|
389
|
+
const { root, descendants, includedNodes, edges } = cluster;
|
|
390
|
+
const template = templates.find(item => item.name === root.template);
|
|
391
|
+
const colour = template?.ui?.colour ?? null;
|
|
392
|
+
const nestedSections = new Set((template?.ui?.nav?.nestOwned ?? []).map(item => item.section));
|
|
393
|
+
const Plugin = window.CorumPlugins?.[root.template];
|
|
394
|
+
if (Plugin) return <Plugin node={root} cluster={cluster} template={template} />;
|
|
395
|
+
|
|
396
|
+
const displayChildren = new Map();
|
|
397
|
+
for (const child of descendants) {
|
|
398
|
+
if (child.parentId === root.id && nestedSections.has(child.ownedSection)) continue;
|
|
399
|
+
if (!displayChildren.has(child.template)) displayChildren.set(child.template, []);
|
|
400
|
+
displayChildren.get(child.template).push(child);
|
|
401
|
+
}
|
|
402
|
+
const displayedNodeIds = new Set([
|
|
403
|
+
root.id,
|
|
404
|
+
...Array.from(displayChildren.values()).reduce((all, group) => all.concat(group), []).map(child => child.id),
|
|
405
|
+
]);
|
|
406
|
+
const rootSpecializedTemplates = new Set(['Schema', 'EnumDefinition']);
|
|
407
|
+
const rootSpecializedNodes = rootSpecializedTemplates.has(root.template) ? [[root.template, [root]]] : [];
|
|
408
|
+
const childDisplayEntries = [...displayChildren.entries()]
|
|
409
|
+
.filter(([templateName]) => templateName !== 'Field' && templateName !== 'EnumValue');
|
|
410
|
+
const displayEntries = [...rootSpecializedNodes, ...childDisplayEntries];
|
|
411
|
+
|
|
412
|
+
function handlePropertyNavigate(targetNodeId) {
|
|
413
|
+
if (displayedNodeIds.has(targetNodeId)) {
|
|
414
|
+
document.getElementById(anchorIdForNode(targetNodeId))?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
onNavigate(targetNodeId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div className="content">
|
|
422
|
+
<div id={anchorIdForNode(root.id)} style={{ marginBottom: 18 }}>
|
|
423
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
424
|
+
<h1 style={{ margin: 0 }}>{displayName(root.id)}</h1>
|
|
425
|
+
<TemplateBadge name={templateDisplayName(template)} colour={colour} />
|
|
426
|
+
<StateTag state={root.state} />
|
|
427
|
+
<StabilityTag stability={root.stability} />
|
|
428
|
+
</div>
|
|
429
|
+
<div className="label-sm mono">{root.id}</div>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
<div className="meta-strip">
|
|
433
|
+
{[
|
|
434
|
+
['Component', root.component],
|
|
435
|
+
['State', root.state],
|
|
436
|
+
['Stability', root.stability],
|
|
437
|
+
['Schema version', root.schemaVersion],
|
|
438
|
+
['Last modified', root.lastModifiedAt],
|
|
439
|
+
].map(([label, value]) => (
|
|
440
|
+
<div key={label} className="meta-cell">
|
|
441
|
+
<div className="label-xs">{label}</div>
|
|
442
|
+
<div style={{ fontSize: 12, marginTop: 3 }}>{value}</div>
|
|
443
|
+
</div>
|
|
444
|
+
))}
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
{Object.keys(root.properties ?? {}).length > 0 && (
|
|
448
|
+
<div className="card">
|
|
449
|
+
<div className="card-head">Properties</div>
|
|
450
|
+
<div className="card-body">
|
|
451
|
+
<PropertiesTable properties={root.properties} onNavigate={handlePropertyNavigate} />
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
{displayEntries.map(([templateName, groupNodes]) => (
|
|
457
|
+
<SchemaCard
|
|
458
|
+
key={templateName}
|
|
459
|
+
title={templateName}
|
|
460
|
+
nodes={groupNodes}
|
|
461
|
+
allNodes={[root, ...descendants, ...includedNodes]}
|
|
462
|
+
edges={edges}
|
|
463
|
+
anchorIdForNode={anchorIdForNode}
|
|
464
|
+
overlayFields={cluster.overlay ? cluster.overlay.fields : null}
|
|
465
|
+
overlayRefs={cluster.overlay ? cluster.overlay.overlayRefs : null}
|
|
466
|
+
/>
|
|
467
|
+
))}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function resolveTemplates(templates) {
|
|
473
|
+
const map = new Map(templates.map(t => [t.name, t]));
|
|
474
|
+
for (const t of templates) {
|
|
475
|
+
const groupName = t.ui?.nav?.navGroup;
|
|
476
|
+
if (!groupName || t.ui?.colour) continue;
|
|
477
|
+
const groupColour = map.get(groupName)?.ui?.colour;
|
|
478
|
+
if (groupColour) {
|
|
479
|
+
t.ui = { ...t.ui, colour: groupColour };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return templates;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function App() {
|
|
486
|
+
const [templates, setTemplates] = useState([]);
|
|
487
|
+
const [nodes, setNodes] = useState([]);
|
|
488
|
+
const [loading, setLoading] = useState(true);
|
|
489
|
+
const [error, setError] = useState(null);
|
|
490
|
+
const [route, setRoute] = useState(() => parseRoute(window.location.hash));
|
|
491
|
+
const [refreshToken, setRefreshToken] = useState(0);
|
|
492
|
+
const [gitMode, setGitMode] = useState(false);
|
|
493
|
+
const [branches, setBranches] = useState([]);
|
|
494
|
+
const [branchResults, setBranchResults] = useState([]);
|
|
495
|
+
const [viewingRef, setViewingRef] = useState(null);
|
|
496
|
+
const [overlayRefs, setOverlayRefs] = useState([]);
|
|
497
|
+
const [overlayMode, setOverlayMode] = useState('single');
|
|
498
|
+
const [overlayIndicatorIds, setOverlayIndicatorIds] = useState(new Set());
|
|
499
|
+
|
|
500
|
+
const refreshGraphData = useCallback((targetViewingRef = viewingRef) => {
|
|
501
|
+
setError(null);
|
|
502
|
+
const refParam = targetViewingRef ? `?ref=${encodeURIComponent(targetViewingRef)}` : '';
|
|
503
|
+
return Promise.all([
|
|
504
|
+
fetch(`/api/templates${refParam}`).then(response => response.ok ? response.json() : Promise.reject(response.status)),
|
|
505
|
+
fetch(`/api/nodes${refParam}`).then(response => response.ok ? response.json() : Promise.reject(response.status)),
|
|
506
|
+
])
|
|
507
|
+
.then(([templateData, nodeData]) => {
|
|
508
|
+
setTemplates(resolveTemplates(templateData));
|
|
509
|
+
setNodes(nodeData);
|
|
510
|
+
setRefreshToken(token => token + 1);
|
|
511
|
+
setLoading(false);
|
|
512
|
+
})
|
|
513
|
+
.catch(err => {
|
|
514
|
+
setError(String(err));
|
|
515
|
+
setLoading(false);
|
|
516
|
+
});
|
|
517
|
+
}, [viewingRef]);
|
|
518
|
+
|
|
519
|
+
const refreshBranchState = useCallback(() => {
|
|
520
|
+
return fetch('/api/branches')
|
|
521
|
+
.then(res => {
|
|
522
|
+
if (!res.ok) return null;
|
|
523
|
+
return res.json();
|
|
524
|
+
})
|
|
525
|
+
.then(data => {
|
|
526
|
+
if (!data) return null;
|
|
527
|
+
setGitMode(true);
|
|
528
|
+
setBranches(data.branches || []);
|
|
529
|
+
setBranchResults(data.results || []);
|
|
530
|
+
const urlRef = parseRoute(window.location.hash).branch;
|
|
531
|
+
const validUrlRef = (data.branches || []).find(branch => branch.ref === urlRef);
|
|
532
|
+
const validViewingRef = (data.branches || []).find(branch => branch.ref === viewingRef);
|
|
533
|
+
const nextViewingRef = validUrlRef ? urlRef : (validViewingRef ? viewingRef : data.default);
|
|
534
|
+
setViewingRef(nextViewingRef);
|
|
535
|
+
return { nextViewingRef };
|
|
536
|
+
})
|
|
537
|
+
.catch(() => null);
|
|
538
|
+
}, [viewingRef]);
|
|
539
|
+
|
|
540
|
+
const refreshAllData = useCallback(() => {
|
|
541
|
+
return refreshBranchState()
|
|
542
|
+
.then(result => refreshGraphData(result?.nextViewingRef ?? viewingRef));
|
|
543
|
+
}, [refreshBranchState, refreshGraphData, viewingRef]);
|
|
544
|
+
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!window.EventSource) return;
|
|
547
|
+
const eventSource = new EventSource('/api/events');
|
|
548
|
+
eventSource.addEventListener('graph-reloaded', refreshAllData);
|
|
549
|
+
return () => {
|
|
550
|
+
eventSource.removeEventListener('graph-reloaded', refreshAllData);
|
|
551
|
+
eventSource.close();
|
|
552
|
+
};
|
|
553
|
+
}, [refreshAllData]);
|
|
554
|
+
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
refreshBranchState();
|
|
557
|
+
}, [refreshBranchState]);
|
|
558
|
+
|
|
559
|
+
useEffect(() => {
|
|
560
|
+
if (viewingRef !== null || !gitMode) {
|
|
561
|
+
refreshGraphData();
|
|
562
|
+
}
|
|
563
|
+
}, [viewingRef, gitMode, refreshGraphData]);
|
|
564
|
+
|
|
565
|
+
useEffect(() => {
|
|
566
|
+
const handler = () => setRoute(parseRoute(window.location.hash));
|
|
567
|
+
window.addEventListener('hashchange', handler);
|
|
568
|
+
return () => window.removeEventListener('hashchange', handler);
|
|
569
|
+
}, []);
|
|
570
|
+
|
|
571
|
+
const activeNodeId = route.pathname === '/node' ? route.params.get('id') : null;
|
|
572
|
+
const activeSection = activeNodeId ? 'components' : (route.pathname.slice(1) || 'dashboard');
|
|
573
|
+
const navTree = buildNavTree(nodes, templates);
|
|
574
|
+
const showTree = activeSection === 'components' || activeNodeId;
|
|
575
|
+
const activeOverlayRefs = overlayMode === 'single' ? [] :
|
|
576
|
+
overlayMode === 'consolidated' ? branches.filter(branch => branch.ref !== viewingRef).map(branch => branch.ref) :
|
|
577
|
+
overlayRefs;
|
|
578
|
+
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
setOverlayRefs(prev => prev.filter(ref => ref !== viewingRef && branches.some(branch => branch.ref === ref)));
|
|
581
|
+
}, [viewingRef, branches]);
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (!viewingRef || activeOverlayRefs.length === 0) {
|
|
585
|
+
setOverlayIndicatorIds(new Set());
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
fetch(`/api/overlay/${encodeURIComponent(viewingRef)}`)
|
|
589
|
+
.then(res => res.ok ? res.json() : Promise.reject(res.status))
|
|
590
|
+
.then(data => {
|
|
591
|
+
setOverlayIndicatorIds(buildOverlayIndicatorIds(nodes, templates, data.nodes || [], activeOverlayRefs));
|
|
592
|
+
})
|
|
593
|
+
.catch(() => setOverlayIndicatorIds(new Set()));
|
|
594
|
+
}, [viewingRef, overlayMode, overlayRefs, branches, nodes, templates]);
|
|
595
|
+
|
|
596
|
+
function handleSection(section) {
|
|
597
|
+
navigate(buildRoute({ pathname: `/${section}`, params: {}, branch: viewingRef }));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function handleNode(nodeId) {
|
|
601
|
+
navigate(buildRoute({ pathname: '/node', params: { id: nodeId }, branch: viewingRef }));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let page;
|
|
605
|
+
if (loading) {
|
|
606
|
+
page = <div className="content"><p className="label-sm">Loading graph...</p></div>;
|
|
607
|
+
} else if (error) {
|
|
608
|
+
page = <div className="content"><p style={{ color: 'var(--warn)' }}>Error loading graph: {error}</p></div>;
|
|
609
|
+
} else if (route.pathname === '/dashboard' || route.pathname === '/') {
|
|
610
|
+
page = <DashboardPage />;
|
|
611
|
+
} else if (route.pathname === '/components') {
|
|
612
|
+
page = <ComponentsPage />;
|
|
613
|
+
} else if (route.pathname === '/node') {
|
|
614
|
+
page = (
|
|
615
|
+
<NodePage
|
|
616
|
+
nodeId={activeNodeId}
|
|
617
|
+
templates={templates}
|
|
618
|
+
onNavigate={handleNode}
|
|
619
|
+
refreshToken={refreshToken}
|
|
620
|
+
viewingRef={viewingRef}
|
|
621
|
+
overlayRefs={activeOverlayRefs}
|
|
622
|
+
/>
|
|
623
|
+
);
|
|
624
|
+
} else {
|
|
625
|
+
page = <div className="content"><p className="label-sm">Page not found.</p></div>;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return (
|
|
629
|
+
<>
|
|
630
|
+
<TopBar />
|
|
631
|
+
{gitMode && (
|
|
632
|
+
<BranchBar
|
|
633
|
+
branches={branches}
|
|
634
|
+
branchResults={branchResults}
|
|
635
|
+
viewingRef={viewingRef}
|
|
636
|
+
overlayRefs={overlayRefs}
|
|
637
|
+
overlayMode={overlayMode}
|
|
638
|
+
onViewingRef={ref => {
|
|
639
|
+
setViewingRef(ref);
|
|
640
|
+
navigate(buildRoute({ pathname: route.pathname, params: route.params, branch: ref }));
|
|
641
|
+
}}
|
|
642
|
+
onOverlayRefs={setOverlayRefs}
|
|
643
|
+
onOverlayMode={setOverlayMode}
|
|
644
|
+
onReload={() => {
|
|
645
|
+
fetch('/api/reload', { method: 'POST' })
|
|
646
|
+
.then(() => refreshAllData())
|
|
647
|
+
.catch(() => refreshAllData());
|
|
648
|
+
}}
|
|
649
|
+
/>
|
|
650
|
+
)}
|
|
651
|
+
<div className="main">
|
|
652
|
+
<NavRail activeSection={activeSection} onSection={handleSection} />
|
|
653
|
+
{showTree && !loading && !error && (
|
|
654
|
+
<NavTree
|
|
655
|
+
navTree={navTree}
|
|
656
|
+
templates={templates}
|
|
657
|
+
activeNodeId={activeNodeId}
|
|
658
|
+
onNode={handleNode}
|
|
659
|
+
overlayIndicatorIds={overlayIndicatorIds}
|
|
660
|
+
/>
|
|
661
|
+
)}
|
|
662
|
+
{page}
|
|
663
|
+
</div>
|
|
664
|
+
</>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|