generated from coulomb/repo-seed
2555 lines
114 KiB
Python
2555 lines
114 KiB
Python
from __future__ import annotations
|
|
|
|
|
|
def graph_explorer_page() -> str:
|
|
return """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Fabric Map</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
--bg: #f7f8fa;
|
|
--panel: #ffffff;
|
|
--line: #d6dce5;
|
|
--text: #172033;
|
|
--muted: #667085;
|
|
--accent: #0f766e;
|
|
--accent-2: #2563eb;
|
|
--warn: #b45309;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; margin: 0; }
|
|
body { background: var(--bg); color: var(--text); font-size: 14px; }
|
|
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
|
|
.toolbar {
|
|
display: grid;
|
|
grid-template-columns: minmax(180px, 1.2fr) repeat(7, minmax(108px, .55fr)) auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: var(--panel);
|
|
}
|
|
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
|
|
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
|
|
#graph-canvas { position: absolute; inset: 0; z-index: 0; }
|
|
.zone-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
overflow: hidden;
|
|
pointer-events: none;
|
|
}
|
|
.zone-boundary {
|
|
position: absolute;
|
|
border: 2px solid var(--zone-color, #2563eb);
|
|
border-radius: 8px;
|
|
background: var(--zone-fill, rgba(37, 99, 235, .06));
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .74), 0 10px 32px rgba(23, 32, 51, .08);
|
|
min-height: 56px;
|
|
min-width: 90px;
|
|
pointer-events: none;
|
|
}
|
|
.zone-boundary.selected {
|
|
border-width: 3px;
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 14px 36px rgba(23, 32, 51, .14);
|
|
}
|
|
.zone-label {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 10px;
|
|
z-index: 1;
|
|
min-height: 26px;
|
|
max-width: calc(100% - 20px);
|
|
border-color: var(--zone-color, #2563eb);
|
|
color: var(--zone-color, #2563eb);
|
|
background: rgba(255, 255, 255, .96);
|
|
box-shadow: 0 8px 18px rgba(23, 32, 51, .1);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
overflow: hidden;
|
|
padding: 3px 8px;
|
|
pointer-events: auto;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.zone-label:focus-visible {
|
|
outline: 2px solid var(--zone-color, #2563eb);
|
|
outline-offset: 2px;
|
|
}
|
|
.side {
|
|
border-left: 1px solid var(--line);
|
|
background: var(--panel);
|
|
min-height: 0;
|
|
overflow: auto;
|
|
padding: 14px;
|
|
}
|
|
.side h1 { font-size: 17px; line-height: 1.2; margin: 0 0 10px; }
|
|
.section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
|
|
.section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
|
label, .field { color: var(--muted); display: grid; gap: 4px; font-size: 12px; }
|
|
input, select, button {
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font: inherit;
|
|
min-height: 34px;
|
|
padding: 6px 8px;
|
|
background: #ffffff;
|
|
}
|
|
button {
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
button.primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
|
|
button:disabled { color: #98a2b3; cursor: default; }
|
|
.field-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.help-tip {
|
|
width: 18px;
|
|
height: 18px;
|
|
min-height: 18px;
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
line-height: 1;
|
|
background: #f8fafc;
|
|
}
|
|
.help-tip:hover, .help-tip:focus-visible {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
outline: none;
|
|
}
|
|
.help-popup {
|
|
position: fixed;
|
|
z-index: 30;
|
|
display: none;
|
|
max-width: 300px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
box-shadow: 0 16px 40px rgba(23, 32, 51, .16);
|
|
color: var(--text);
|
|
padding: 10px;
|
|
pointer-events: none;
|
|
}
|
|
.help-popup strong { display: block; margin-bottom: 4px; }
|
|
.help-popup p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.35; }
|
|
.filter-menu {
|
|
position: relative;
|
|
min-width: 0;
|
|
}
|
|
.filter-menu summary {
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
list-style: none;
|
|
min-height: 34px;
|
|
overflow: hidden;
|
|
padding: 6px 8px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
background: #ffffff;
|
|
}
|
|
.filter-menu summary::-webkit-details-marker { display: none; }
|
|
.filter-menu[open] summary { border-color: var(--accent); }
|
|
.check-list {
|
|
position: absolute;
|
|
z-index: 8;
|
|
display: grid;
|
|
gap: 4px;
|
|
width: max-content;
|
|
min-width: 210px;
|
|
max-width: 280px;
|
|
max-height: 300px;
|
|
overflow: auto;
|
|
margin-top: 4px;
|
|
padding: 8px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
|
|
}
|
|
.check-list label {
|
|
grid-template-columns: auto 1fr;
|
|
align-items: center;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
gap: 8px;
|
|
min-height: 26px;
|
|
}
|
|
.check-list input {
|
|
min-height: 0;
|
|
padding: 0;
|
|
}
|
|
.map-controls {
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
z-index: 3;
|
|
display: flex;
|
|
align-items: end;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
max-width: min(460px, calc(100% - 24px));
|
|
padding: 6px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: rgba(255, 255, 255, .94);
|
|
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
|
|
}
|
|
.map-controls button { min-width: 54px; }
|
|
.map-control-field {
|
|
display: grid;
|
|
gap: 2px;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
line-height: 1;
|
|
}
|
|
.map-control-field select {
|
|
min-height: 30px;
|
|
min-width: 84px;
|
|
padding: 4px 6px;
|
|
}
|
|
.map-control-field input[type="checkbox"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
min-height: 0;
|
|
margin: 5px 0;
|
|
padding: 0;
|
|
accent-color: var(--accent);
|
|
}
|
|
.selection-anchor {
|
|
position: absolute;
|
|
z-index: 5;
|
|
display: none;
|
|
width: 0;
|
|
height: 0;
|
|
pointer-events: none;
|
|
transform: translate(-50%, -100%);
|
|
}
|
|
.selection-anchor::before {
|
|
content: "";
|
|
position: absolute;
|
|
left: -1px;
|
|
top: -36px;
|
|
width: 2px;
|
|
height: 28px;
|
|
border-radius: 999px;
|
|
background: #111827;
|
|
box-shadow: 0 1px 2px rgba(23, 32, 51, .28);
|
|
}
|
|
.selection-anchor::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: -6px;
|
|
top: -10px;
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 6px solid transparent;
|
|
border-right: 6px solid transparent;
|
|
border-top: 10px solid #111827;
|
|
filter: drop-shadow(0 1px 1px rgba(23, 32, 51, .24));
|
|
}
|
|
.selection-anchor-label {
|
|
position: absolute;
|
|
left: 12px;
|
|
top: -48px;
|
|
max-width: 220px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: rgba(255, 255, 255, .96);
|
|
box-shadow: 0 12px 30px rgba(23, 32, 51, .12);
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
line-height: 1.25;
|
|
padding: 6px 8px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.button-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.meta { color: var(--muted); font-size: 12px; margin: 0; }
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
color: var(--muted);
|
|
min-height: 24px;
|
|
padding: 2px 8px;
|
|
margin: 0 4px 4px 0;
|
|
font-size: 12px;
|
|
}
|
|
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
|
.detail-list li { overflow-wrap: anywhere; }
|
|
.orientation-list { gap: 8px; }
|
|
.orientation-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #fbfcfe;
|
|
padding: 8px;
|
|
}
|
|
.orientation-label {
|
|
color: var(--muted);
|
|
display: block;
|
|
font-size: 11px;
|
|
margin-bottom: 3px;
|
|
text-transform: uppercase;
|
|
}
|
|
.orientation-value { color: var(--text); line-height: 1.35; }
|
|
.orientation-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
|
.orientation-warning { border-color: #f59e0b; background: #fffbeb; }
|
|
.orientation-good { border-color: #5eead4; background: #f0fdfa; }
|
|
.rule-panel {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #fbfcfe;
|
|
}
|
|
.rule-panel summary {
|
|
cursor: pointer;
|
|
list-style: none;
|
|
padding: 10px;
|
|
}
|
|
.rule-panel summary::-webkit-details-marker { display: none; }
|
|
.rule-editor {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 0 10px 10px;
|
|
}
|
|
.rule-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
.rule-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin: 10px 0 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
.rule-item {
|
|
display: grid;
|
|
gap: 6px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
padding: 8px;
|
|
}
|
|
.rule-title {
|
|
align-items: center;
|
|
display: flex;
|
|
gap: 6px;
|
|
justify-content: space-between;
|
|
}
|
|
.rule-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.rule-actions button { min-height: 28px; padding: 4px 7px; }
|
|
.popup {
|
|
position: absolute;
|
|
z-index: 4;
|
|
max-width: 260px;
|
|
display: none;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
|
|
padding: 10px;
|
|
pointer-events: none;
|
|
}
|
|
.popup strong { display: block; margin-bottom: 4px; }
|
|
.notice { color: var(--warn); padding: 14px; }
|
|
.canvas-notice {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--muted);
|
|
padding: 24px;
|
|
text-align: center;
|
|
}
|
|
@media (max-width: 900px) {
|
|
.shell { min-height: 760px; }
|
|
.toolbar { grid-template-columns: 1fr 1fr; }
|
|
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
|
|
.side { border-left: 0; border-top: 1px solid var(--line); }
|
|
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
|
|
.rule-grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<section class="shell">
|
|
<div class="toolbar">
|
|
<div class="field">
|
|
<span class="field-label">Search <button type="button" class="help-tip" aria-label="Search help" data-help-title="Search" data-help="Search matches labels, ids, repos, node types, edge types, and descriptions. It hides non-matching nodes and edges without redrawing the layout.">?</button></span>
|
|
<input id="search" autocomplete="off" aria-label="Search graph">
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Mode <button type="button" class="help-tip" aria-label="Mode help" data-help-title="Mode" data-help="Modes are predefined map views. Some modes use the selected item as context; changing modes changes which graph entities are visible, but keeps the layout controls separate.">?</button></span>
|
|
<select id="mode-select" aria-label="Graph mode"></select>
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Layout <button type="button" class="help-tip" aria-label="Layout help" data-help-title="Layout" data-help="Layout redraws the map arrangement. Cose uses relationship strength and repo affinity; circle, grid, concentric, and breadthfirst are simpler alternate arrangements.">?</button></span>
|
|
<select id="layout-select" aria-label="Graph layout">
|
|
<option value="cose">Cose</option>
|
|
<option value="concentric">Concentric</option>
|
|
<option value="circle">Circle</option>
|
|
<option value="grid">Grid</option>
|
|
<option value="breadthfirst">Breadthfirst</option>
|
|
</select>
|
|
</div>
|
|
<div class="field"><span class="field-label">Node Types <button type="button" class="help-tip" aria-label="Node types help" data-help-title="Node Types" data-help="Nodes are the entities drawn on the map: repositories, services, deployments, servers, capabilities, interfaces, dependencies, bindings, and libraries. This filter hides unchecked node types while preserving the current layout.">?</button></span>
|
|
<details id="node-type-menu" class="filter-menu">
|
|
<summary id="node-type-summary">All node types</summary>
|
|
<div id="node-type-filter" class="check-list"></div>
|
|
</details>
|
|
</div>
|
|
<div class="field"><span class="field-label">Edge Types <button type="button" class="help-tip" aria-label="Edge types help" data-help-title="Edge Types" data-help="Edges are relationships between nodes, such as provides, consumes, runs_on, or binds. This filter hides unchecked relationship types while preserving the current layout.">?</button></span>
|
|
<details id="edge-type-menu" class="filter-menu">
|
|
<summary id="edge-type-summary">All edge types</summary>
|
|
<div id="edge-type-filter" class="check-list"></div>
|
|
</details>
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Review <button type="button" class="help-tip" aria-label="Review help" data-help-title="Review" data-help="Review state applies only to entities that carry review metadata. Accepted means trusted data; candidate means registered or inferred data that still needs context.">?</button></span>
|
|
<select id="review-filter" aria-label="Review state filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Unresolved <button type="button" class="help-tip" aria-label="Unresolved help" data-help-title="Unresolved" data-help="Unresolved marks missing provider bindings or registered repositories without accepted graph snapshots. It only applies to node and edge types that can express those gaps.">?</button></span>
|
|
<select id="unresolved-filter" aria-label="Unresolved filter"><option value="">Any</option><option value="true">Only</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, rules, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
|
|
<select id="profile-select" aria-label="Saved view profile" disabled><option>Unsaved exploration</option></select>
|
|
</div>
|
|
<span id="counts" class="meta"></span>
|
|
</div>
|
|
<main class="main">
|
|
<div class="canvas-wrap">
|
|
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div>
|
|
<div id="zone-overlay" class="zone-overlay" aria-label="Deployment zone boundaries"></div>
|
|
<div class="map-controls" aria-label="Map navigation controls">
|
|
<label class="map-control-field"><span class="field-label">Zones <button type="button" class="help-tip" aria-label="Zone boundary help" data-help-title="Zones" data-help="Zone boundaries draw labeled rectangles around visible nodes that share deployment overlay fields. They are visual annotations only.">?</button></span>
|
|
<input id="zone-boundary-toggle" type="checkbox" aria-label="Show zone boundaries" checked>
|
|
</label>
|
|
<label class="map-control-field"><span class="field-label">Zone By <button type="button" class="help-tip" aria-label="Zone grouping help" data-help-title="Zone By" data-help="Environment groups nodes as dev-tegwick, test, or prod. Access Zone groups by intended reachability such as private-dev or production-public.">?</button></span>
|
|
<select id="zone-group-select" title="Choose zone boundary grouping">
|
|
<option value="deploymentEnvironment">Environment</option>
|
|
<option value="accessZone">Access Zone</option>
|
|
</select>
|
|
</label>
|
|
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
|
|
<select id="label-select" title="Control node label density">
|
|
<option value="auto">Auto</option>
|
|
<option value="key">Key</option>
|
|
<option value="all">All</option>
|
|
<option value="none">None</option>
|
|
</select>
|
|
</label>
|
|
<button type="button" data-action="fit" title="Fit graph to view" data-help-title="Fit" data-help="Fit pans and zooms the current visible graph into the map viewport. It does not change filters, labels, or layout.">Fit</button>
|
|
<button type="button" data-action="focus" title="Focus selected neighborhood" data-help-title="Focus" data-help="Focus narrows the map to the selected entity and its nearby context. It changes visibility, not the underlying registry data.">Focus</button>
|
|
<button type="button" data-action="clear" class="primary" title="Reset map controls" data-help-title="Reset" data-help="Reset clears search, mode, node and edge type filters, label density, unresolved/review filters, focus, and manual overrides.">Reset</button>
|
|
</div>
|
|
<div id="selection-anchor" class="selection-anchor" aria-hidden="true">
|
|
<span id="selection-anchor-label" class="selection-anchor-label"></span>
|
|
</div>
|
|
<div id="popup" class="popup"></div>
|
|
<div id="help-popup" class="help-popup" role="tooltip"></div>
|
|
</div>
|
|
<aside class="side">
|
|
<section class="section">
|
|
<h1 id="detail-title">Fabric Map</h1>
|
|
<p id="detail-summary" class="meta">No selection</p>
|
|
<div id="detail-pills"></div>
|
|
<ul id="detail-list" class="detail-list"></ul>
|
|
</section>
|
|
<section class="section">
|
|
<p id="orientation-title" class="meta">Select a service, interface, dependency, or registered-only repo.</p>
|
|
<ul id="orientation-list" class="detail-list orientation-list"></ul>
|
|
<div id="orientation-actions" class="button-row"></div>
|
|
</section>
|
|
<section class="section">
|
|
<div class="button-row">
|
|
<button type="button" data-override="show" data-help-title="Show" data-help="Show pins the selected entity visible even when other filters would hide it. It does not redraw the layout.">Show</button>
|
|
<button type="button" data-override="blur" data-help-title="Blur" data-help="Blur keeps the selected entity in place but reduces emphasis. It is useful for context that should stay visible without competing for attention.">Blur</button>
|
|
<button type="button" data-override="hide" data-help-title="Hide" data-help="Hide makes the selected entity invisible while preserving layout context. Remove is reserved for the upcoming rule panel and will redraw the graph without the entity.">Hide</button>
|
|
<button type="button" data-action="reset-overrides" data-help-title="Clear Overrides" data-help="Clear Overrides removes manual Show, Blur, and Hide choices. Filters, mode, layout, and label density stay as they are.">Clear Overrides</button>
|
|
</div>
|
|
</section>
|
|
<section class="section">
|
|
<details id="rule-panel" class="rule-panel">
|
|
<summary><span class="field-label">Rules <button type="button" class="help-tip" aria-label="Rule builder help" data-help-title="Rule Builder" data-help="Rules select nodes or edges by type and available attributes, then apply a modifier. Hide preserves the current layout; Remove redraws the map without matching entities.">?</button></span></summary>
|
|
<div class="rule-editor">
|
|
<div class="rule-grid">
|
|
<label>Target
|
|
<select id="rule-target">
|
|
<option value="node">Nodes</option>
|
|
<option value="edge">Edges</option>
|
|
</select>
|
|
</label>
|
|
<label>Type
|
|
<select id="rule-type"></select>
|
|
</label>
|
|
<label>Attribute
|
|
<select id="rule-attribute"></select>
|
|
</label>
|
|
<label>Value
|
|
<select id="rule-value"></select>
|
|
</label>
|
|
<label>Modifier
|
|
<select id="rule-action">
|
|
<option value="highlight">Highlight</option>
|
|
<option value="show">Show</option>
|
|
<option value="blur">Blur</option>
|
|
<option value="hide">Hide</option>
|
|
<option value="remove">Remove and redraw</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p id="rule-context" class="meta">Rules are applied top to bottom; later matching rules refine earlier ones.</p>
|
|
<div class="button-row">
|
|
<button type="button" data-rule-action="save" class="primary">Add Rule</button>
|
|
<button type="button" data-rule-action="cancel">Cancel Edit</button>
|
|
<button type="button" data-rule-action="reset">Reset Rules</button>
|
|
</div>
|
|
<ul id="rule-list" class="rule-list"></ul>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
<section class="section">
|
|
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
|
|
<div class="field">
|
|
<span class="field-label">Profile Name <button type="button" class="help-tip" aria-label="Profile name help" data-help-title="Profile Name" data-help="Profile Name labels a saved browser-local map view. It does not rename graph entities or registry data.">?</button></span>
|
|
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
|
|
</div>
|
|
<div class="button-row">
|
|
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, rules, layout, label density, and manual overrides." disabled>Save</button>
|
|
<button type="button" data-profile-action="duplicate" data-help-title="Duplicate" data-help="Duplicate creates a new saved view from the currently loaded profile and current map state." disabled>Duplicate</button>
|
|
<button type="button" data-profile-action="delete" data-help-title="Delete" data-help="Delete removes the selected browser-local saved view. It does not delete graph data." disabled>Delete</button>
|
|
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters, rules, and manual overrides." disabled>Copy State</button>
|
|
</div>
|
|
</section>
|
|
<section class="section">
|
|
<p class="meta"><span id="hidden-summary">Hidden 0</span> <button type="button" class="help-tip" aria-label="Hidden entities help" data-help-title="Hidden Entities" data-help="Hidden counts entities currently not shown because of filters, mode, or manual Hide overrides. Hidden keeps layout context; Remove will redraw without the entity in the rule panel.">?</button></p>
|
|
<div id="legend"></div>
|
|
</section>
|
|
</aside>
|
|
</main>
|
|
</section>
|
|
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
|
|
<script>
|
|
(() => {
|
|
const manifestUrl = "/exports/graph-explorer/manifest";
|
|
const graphUrl = "/exports/graph-explorer";
|
|
const canvas = document.getElementById("graph-canvas");
|
|
const zoneOverlay = document.getElementById("zone-overlay");
|
|
const popup = document.getElementById("popup");
|
|
const helpPopup = document.getElementById("help-popup");
|
|
const selectionAnchor = document.getElementById("selection-anchor");
|
|
const selectionAnchorLabel = document.getElementById("selection-anchor-label");
|
|
const searchInput = document.getElementById("search");
|
|
const modeSelect = document.getElementById("mode-select");
|
|
const layoutSelect = document.getElementById("layout-select");
|
|
const labelSelect = document.getElementById("label-select");
|
|
const zoneBoundaryToggle = document.getElementById("zone-boundary-toggle");
|
|
const zoneGroupSelect = document.getElementById("zone-group-select");
|
|
const nodeTypeFilter = document.getElementById("node-type-filter");
|
|
const nodeTypeSummary = document.getElementById("node-type-summary");
|
|
const edgeTypeFilter = document.getElementById("edge-type-filter");
|
|
const edgeTypeSummary = document.getElementById("edge-type-summary");
|
|
const reviewFilter = document.getElementById("review-filter");
|
|
const unresolvedFilter = document.getElementById("unresolved-filter");
|
|
const ruleTarget = document.getElementById("rule-target");
|
|
const ruleType = document.getElementById("rule-type");
|
|
const ruleAttribute = document.getElementById("rule-attribute");
|
|
const ruleValue = document.getElementById("rule-value");
|
|
const ruleAction = document.getElementById("rule-action");
|
|
const ruleContext = document.getElementById("rule-context");
|
|
const ruleList = document.getElementById("rule-list");
|
|
const profileSelect = document.getElementById("profile-select");
|
|
const profileNameInput = document.getElementById("profile-name");
|
|
const profileSummary = document.getElementById("profile-summary");
|
|
const counts = document.getElementById("counts");
|
|
const hiddenSummary = document.getElementById("hidden-summary");
|
|
const detailTitle = document.getElementById("detail-title");
|
|
const detailSummary = document.getElementById("detail-summary");
|
|
const detailPills = document.getElementById("detail-pills");
|
|
const detailList = document.getElementById("detail-list");
|
|
const orientationTitle = document.getElementById("orientation-title");
|
|
const orientationList = document.getElementById("orientation-list");
|
|
const orientationActions = document.getElementById("orientation-actions");
|
|
const legend = document.getElementById("legend");
|
|
const profileStorageKey = "railiance.fabric.graphExplorer.profiles";
|
|
let cy = null;
|
|
let selected = null;
|
|
let selectedAnchor = null;
|
|
let focusSet = null;
|
|
let manualOverrides = {};
|
|
let layerColors = {};
|
|
let nodeTypeLabels = {};
|
|
let allNodeTypes = [];
|
|
let allEdgeTypes = [];
|
|
let activeMode = "full";
|
|
let activeLabelMode = "auto";
|
|
let activeZoneGrouping = "deploymentEnvironment";
|
|
let profilePersistence = "none";
|
|
let profiles = [];
|
|
let currentProfileId = "";
|
|
let filterRules = [];
|
|
let editingRuleId = "";
|
|
let orientationContext = null;
|
|
let selectedZoneId = "";
|
|
let zoneOverlayFrame = 0;
|
|
let zoneSummaries = new Map();
|
|
|
|
const escapeHtml = (value) => String(value ?? "")
|
|
.replaceAll("&", "&").replaceAll("<", "<")
|
|
.replaceAll(">", ">").replaceAll('"', """);
|
|
|
|
const showHelp = (target) => {
|
|
if (!helpPopup || !target?.dataset?.help) return;
|
|
const bounds = target.getBoundingClientRect();
|
|
const title = target.dataset.helpTitle || target.textContent || "Help";
|
|
helpPopup.innerHTML = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(target.dataset.help)}</p>`;
|
|
helpPopup.style.display = "block";
|
|
const width = Math.min(helpPopup.offsetWidth || 300, 300);
|
|
const left = Math.min(Math.max(12, bounds.left), window.innerWidth - width - 12);
|
|
const top = bounds.bottom + 8 < window.innerHeight - 80
|
|
? bounds.bottom + 8
|
|
: Math.max(12, bounds.top - helpPopup.offsetHeight - 8);
|
|
helpPopup.style.left = `${left}px`;
|
|
helpPopup.style.top = `${top}px`;
|
|
};
|
|
|
|
const hideHelp = () => {
|
|
if (!helpPopup) return;
|
|
helpPopup.style.display = "none";
|
|
};
|
|
|
|
const elementText = (data) => [
|
|
data.id, data.stableKey, data.label, data.name, data.description,
|
|
data.repo, data.domain, data.kind, data.layer, data.edgeType,
|
|
data.deploymentEnvironment, data.deploymentScenario, data.routingAuthority,
|
|
data.accessZone, data.policyAuthority, data.exposureClass,
|
|
data.routeHost, data.routeHostname, data.routePort, data.routeProtocol, data.route
|
|
].join(" ").toLowerCase();
|
|
|
|
const overrideKey = (element) => element.data("stableKey") || element.id();
|
|
|
|
const manualOverrideFor = (element) =>
|
|
manualOverrides[overrideKey(element)] || manualOverrides[element.id()];
|
|
|
|
const hasManualOverrides = () => Object.keys(manualOverrides).length > 0;
|
|
|
|
const supportsLocalProfiles = () => profilePersistence === "local";
|
|
|
|
const humanize = (value) => String(value || "")
|
|
.replaceAll("_", " ")
|
|
.replaceAll(":", ": ")
|
|
.replace(/\\b\\w/g, (letter) => letter.toUpperCase());
|
|
|
|
const parseListParam = (value) => String(value || "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
|
|
const checkedValues = (container) => new Set(
|
|
Array.from(container.querySelectorAll("input[type='checkbox']"))
|
|
.filter((input) => input.checked)
|
|
.map((input) => input.value)
|
|
);
|
|
|
|
const setCheckedValues = (container, values = null) => {
|
|
const explicit = Array.isArray(values);
|
|
const selectedValues = new Set(values || []);
|
|
container.querySelectorAll("input[type='checkbox']").forEach((input) => {
|
|
input.checked = explicit ? selectedValues.has(input.value) : true;
|
|
});
|
|
};
|
|
|
|
const selectedNodeTypes = () => checkedValues(nodeTypeFilter);
|
|
|
|
const selectedEdgeTypes = () => checkedValues(edgeTypeFilter);
|
|
|
|
const hasValue = (value) => value !== undefined && value !== null && value !== "";
|
|
|
|
const overlayRuleAttributes = [
|
|
"deploymentEnvironment",
|
|
"deploymentScenario",
|
|
"routingAuthority",
|
|
"accessZone",
|
|
"policyAuthority",
|
|
"exposureClass",
|
|
"routeHost",
|
|
"routeHostname",
|
|
"routePort",
|
|
"routeProtocol",
|
|
];
|
|
|
|
const zoneModeFields = {
|
|
"by-fabric": ["domain", "repo", "kind"],
|
|
"by-deployment-environment": ["deploymentEnvironment"],
|
|
"by-deployment-scenario": ["deploymentScenario"],
|
|
"by-routing-authority": ["routingAuthority"],
|
|
"by-access-zone": ["accessZone"],
|
|
};
|
|
|
|
const zoneModeLabels = {
|
|
"by-fabric": "Fabric",
|
|
"by-deployment-environment": "Deployment Environment",
|
|
"by-deployment-scenario": "Deployment Scenario",
|
|
"by-routing-authority": "Routing Authority",
|
|
"by-access-zone": "Access Zone",
|
|
};
|
|
|
|
const summarizeSelection = (selected, allValues, labels, noun) => {
|
|
if (selected.size === 0) return `No ${noun}`;
|
|
if (selected.size === allValues.length) return `All ${noun}`;
|
|
const names = allValues
|
|
.filter((value) => selected.has(value))
|
|
.map((value) => labels[value] || humanize(value));
|
|
return names.length <= 2 ? names.join(", ") : `${names.length} ${noun}`;
|
|
};
|
|
|
|
const syncFilterSummaries = () => {
|
|
nodeTypeSummary.textContent = summarizeSelection(
|
|
selectedNodeTypes(), allNodeTypes, nodeTypeLabels, "node types"
|
|
);
|
|
edgeTypeSummary.textContent = summarizeSelection(
|
|
selectedEdgeTypes(), allEdgeTypes, {}, "edge types"
|
|
);
|
|
};
|
|
|
|
const renderChecklist = (container, values, labels, name) => {
|
|
container.innerHTML = values.map((value) => {
|
|
const label = labels[value] || humanize(value);
|
|
return `<label><input type="checkbox" name="${escapeHtml(name)}" value="${escapeHtml(value)}" checked> ${escapeHtml(label)}</label>`;
|
|
}).join("");
|
|
};
|
|
|
|
const ruleActionLabels = {
|
|
show: "Show",
|
|
hide: "Hide",
|
|
blur: "Blur",
|
|
highlight: "Highlight",
|
|
remove: "Remove and redraw",
|
|
};
|
|
|
|
const ruleAttributeLabels = {
|
|
any: "Any attribute",
|
|
repo: "Repo",
|
|
kind: "Kind",
|
|
reviewState: "Review state",
|
|
unresolved: "Unresolved",
|
|
lifecycle: "Lifecycle",
|
|
strength: "Strength",
|
|
sameRepo: "Same repo",
|
|
layoutAffinity: "Layout affinity",
|
|
deploymentEnvironment: "Deployment Environment",
|
|
deploymentScenario: "Deployment Scenario",
|
|
routingAuthority: "Routing Authority",
|
|
accessZone: "Access Zone",
|
|
policyAuthority: "Policy Authority",
|
|
exposureClass: "Exposure Class",
|
|
routeHost: "Route Host",
|
|
routeHostname: "Route Hostname",
|
|
routePort: "Route Port",
|
|
routeProtocol: "Route Protocol",
|
|
};
|
|
|
|
const ruleAttributeCandidates = {
|
|
node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle", ...overlayRuleAttributes],
|
|
edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity", ...overlayRuleAttributes],
|
|
};
|
|
|
|
const renderOptions = (select, options, current = "") => {
|
|
select.innerHTML = options.map((option) =>
|
|
`<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`
|
|
).join("");
|
|
if (options.some((option) => option.value === current)) select.value = current;
|
|
};
|
|
|
|
const ruleElementType = (element, target) =>
|
|
target === "edge" ? element.data("edgeType") : element.data("layer");
|
|
|
|
const currentRuleElements = () => {
|
|
if (!cy) return [];
|
|
const target = ruleTarget.value || "node";
|
|
const type = ruleType.value || "";
|
|
const elements = target === "edge" ? cy.edges() : cy.nodes();
|
|
return elements.filter((element) => !type || ruleElementType(element, target) === type).toArray();
|
|
};
|
|
|
|
const valuesForRuleAttribute = (attribute) => {
|
|
if (attribute === "any") return [{value: "", label: "Any"}];
|
|
const values = Array.from(new Set(currentRuleElements()
|
|
.map((element) => element.data(attribute))
|
|
.filter((value) => value !== undefined && value !== null && value !== "")
|
|
.map((value) => String(value))
|
|
)).sort((left, right) => left.localeCompare(right));
|
|
return values.length
|
|
? values.map((value) => ({value, label: humanize(value)}))
|
|
: [{value: "", label: "No values"}];
|
|
};
|
|
|
|
const availableRuleAttributes = () => {
|
|
const elements = currentRuleElements();
|
|
return (ruleAttributeCandidates[ruleTarget.value || "node"] || ["any"])
|
|
.filter((attribute) => attribute === "any" || elements.some((element) => {
|
|
const value = element.data(attribute);
|
|
return value !== undefined && value !== null && value !== "";
|
|
}))
|
|
.map((attribute) => ({value: attribute, label: ruleAttributeLabels[attribute] || humanize(attribute)}));
|
|
};
|
|
|
|
const refreshRuleBuilder = (state = {}) => {
|
|
const target = state.target || ruleTarget.value || "node";
|
|
ruleTarget.value = target;
|
|
const types = target === "edge" ? allEdgeTypes : allNodeTypes;
|
|
const labels = target === "edge" ? {} : nodeTypeLabels;
|
|
renderOptions(ruleType, [
|
|
{value: "", label: target === "edge" ? "All edge types" : "All node types"},
|
|
...types.map((type) => ({value: type, label: labels[type] || humanize(type)})),
|
|
], state.type ?? ruleType.value);
|
|
renderOptions(ruleAttribute, availableRuleAttributes(), state.attribute ?? ruleAttribute.value);
|
|
renderOptions(ruleValue, valuesForRuleAttribute(ruleAttribute.value || "any"), state.value ?? ruleValue.value);
|
|
if (state.action && ruleActionLabels[state.action]) ruleAction.value = state.action;
|
|
ruleValue.disabled = (ruleAttribute.value || "any") === "any" || ruleValue.options.length === 0;
|
|
const attributeNames = Array.from(ruleAttribute.options).map((option) => option.textContent).join(", ");
|
|
ruleContext.textContent = attributeNames
|
|
? `Available here: ${attributeNames}. Rules are applied top to bottom; later matches refine earlier ones.`
|
|
: "No attributes are available for this type yet.";
|
|
};
|
|
|
|
const normalizeRules = (rules) => Array.isArray(rules)
|
|
? rules
|
|
.filter((rule) => rule && (rule.target === "node" || rule.target === "edge") && ruleActionLabels[rule.action])
|
|
.map((rule, index) => ({
|
|
id: String(rule.id || `rule-${Date.now().toString(36)}-${index}`),
|
|
target: rule.target,
|
|
type: String(rule.type || ""),
|
|
attribute: String(rule.attribute || "any"),
|
|
value: String(rule.value || ""),
|
|
action: rule.action,
|
|
}))
|
|
: [];
|
|
|
|
const ruleDescription = (rule) => {
|
|
const target = rule.target === "edge" ? "edges" : "nodes";
|
|
const typeLabel = rule.type
|
|
? (rule.target === "edge" ? humanize(rule.type) : nodeTypeLabels[rule.type] || humanize(rule.type))
|
|
: `all ${target}`;
|
|
const attribute = rule.attribute && rule.attribute !== "any"
|
|
? `${ruleAttributeLabels[rule.attribute] || humanize(rule.attribute)} = ${humanize(rule.value)}`
|
|
: "any attribute";
|
|
return `${ruleActionLabels[rule.action]} ${typeLabel} where ${attribute}`;
|
|
};
|
|
|
|
const renderRules = () => {
|
|
const saveButton = document.querySelector("[data-rule-action='save']");
|
|
const cancelButton = document.querySelector("[data-rule-action='cancel']");
|
|
saveButton.textContent = editingRuleId ? "Update Rule" : "Add Rule";
|
|
cancelButton.disabled = !editingRuleId;
|
|
ruleList.innerHTML = filterRules.length
|
|
? filterRules.map((rule, index) => `
|
|
<li class="rule-item" data-rule-id="${escapeHtml(rule.id)}">
|
|
<div class="rule-title"><strong>${escapeHtml(ruleDescription(rule))}</strong><span class="meta">${index + 1}</span></div>
|
|
<div class="rule-actions">
|
|
<button type="button" data-rule-list-action="up" ${index === 0 ? "disabled" : ""}>Up</button>
|
|
<button type="button" data-rule-list-action="down" ${index === filterRules.length - 1 ? "disabled" : ""}>Down</button>
|
|
<button type="button" data-rule-list-action="edit">Edit</button>
|
|
<button type="button" data-rule-list-action="delete">Delete</button>
|
|
</div>
|
|
</li>
|
|
`).join("")
|
|
: '<li class="meta">No rules yet.</li>';
|
|
};
|
|
|
|
const matchesRule = (element, rule) => {
|
|
if (rule.target === "node" && !element.isNode()) return false;
|
|
if (rule.target === "edge" && !element.isEdge()) return false;
|
|
if (rule.type && ruleElementType(element, rule.target) !== rule.type) return false;
|
|
if (!rule.attribute || rule.attribute === "any") return true;
|
|
const value = element.data(rule.attribute);
|
|
return value !== undefined && value !== null && String(value) === String(rule.value);
|
|
};
|
|
|
|
const ruleActionFor = (element) => {
|
|
let action = "";
|
|
filterRules.forEach((rule) => {
|
|
if (matchesRule(element, rule)) action = rule.action;
|
|
});
|
|
return action;
|
|
};
|
|
|
|
const ruleRemovalSignature = () => cy
|
|
? cy.elements()
|
|
.filter((element) => element.data("displayState") === "remove")
|
|
.map((element) => element.id())
|
|
.sort()
|
|
.join("|")
|
|
: "";
|
|
|
|
const isFinitePoint = (position) =>
|
|
position && Number.isFinite(position.x) && Number.isFinite(position.y);
|
|
|
|
const elementLabel = (element) => {
|
|
if (!element) return "";
|
|
const data = element.data();
|
|
return data.name || data.label || data.edgeType || data.id;
|
|
};
|
|
|
|
const nodeById = (id) => {
|
|
if (!cy || !id) return null;
|
|
const node = cy.getElementById(id);
|
|
return node && node.length > 0 ? node : null;
|
|
};
|
|
|
|
const nodeName = (id) => {
|
|
const node = nodeById(id);
|
|
return node ? elementLabel(node) : id;
|
|
};
|
|
|
|
const addContextElement = (ids, element) => {
|
|
if (!element || element.length === 0) return;
|
|
ids.add(element.id());
|
|
};
|
|
|
|
const addContextNode = (ids, id) => {
|
|
const node = nodeById(id);
|
|
if (node) ids.add(node.id());
|
|
};
|
|
|
|
const addContextEdge = (ids, edge) => {
|
|
addContextElement(ids, edge);
|
|
if (!edge || edge.length === 0) return;
|
|
addContextNode(ids, edge.data("source"));
|
|
addContextNode(ids, edge.data("target"));
|
|
};
|
|
|
|
const edgeCollection = (source = "", target = "", type = "") => cy ? cy.edges().filter((edge) =>
|
|
(!source || edge.data("source") === source) &&
|
|
(!target || edge.data("target") === target) &&
|
|
(!type || edge.data("edgeType") === type)
|
|
) : [];
|
|
|
|
const routeLabel = (data) => {
|
|
if (hasValue(data.route)) return data.route;
|
|
const host = data.routeHost || data.routeHostname || "";
|
|
const protocol = data.routeProtocol || "";
|
|
const port = data.routePort || "";
|
|
if (!hasValue(host) && !hasValue(port)) return "";
|
|
const authority = hasValue(port) ? `${host}:${port}` : host;
|
|
return [protocol, authority].filter(hasValue).join(" ");
|
|
};
|
|
|
|
const hasRouteEvidence = (data) =>
|
|
hasValue(data.route) ||
|
|
hasValue(data.routeHost) ||
|
|
hasValue(data.routeHostname) ||
|
|
hasValue(data.routePort);
|
|
|
|
const zoneWarningsForData = (data) => {
|
|
const warnings = [];
|
|
if (hasRouteEvidence(data) && !hasValue(data.policyAuthority)) {
|
|
warnings.push("route without policy authority");
|
|
}
|
|
return warnings;
|
|
};
|
|
|
|
const zonePalette = {
|
|
"deploymentEnvironment:dev-tegwick": {color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
|
|
"deploymentEnvironment:test": {color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
|
|
"deploymentEnvironment:prod": {color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
|
|
};
|
|
|
|
const fallbackZonePalette = [
|
|
{color: "#7c3aed", fill: "rgba(124, 58, 237, .07)"},
|
|
{color: "#b45309", fill: "rgba(180, 83, 9, .08)"},
|
|
{color: "#0891b2", fill: "rgba(8, 145, 178, .08)"},
|
|
{color: "#4f46e5", fill: "rgba(79, 70, 229, .07)"},
|
|
];
|
|
|
|
const hashString = (value) => String(value || "")
|
|
.split("")
|
|
.reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0);
|
|
|
|
const normalizeDeploymentZoneValue = (value) => {
|
|
const raw = String(value || "").trim();
|
|
const normalized = raw.toLowerCase();
|
|
if (!normalized || normalized === "all") return "";
|
|
if (normalized === "dev") return "dev-tegwick";
|
|
if (normalized === "test" || normalized === "staging") return "test";
|
|
if (normalized === "prod") return "prod";
|
|
return raw;
|
|
};
|
|
|
|
const defaultZoneDefinitions = {
|
|
deploymentEnvironment: [
|
|
{
|
|
id: "deploymentEnvironment:dev-tegwick",
|
|
label: "dev-tegwick",
|
|
field: "deploymentEnvironment",
|
|
value: "dev-tegwick",
|
|
rank: 0,
|
|
membership: {
|
|
field: "deploymentEnvironment",
|
|
op: "equals",
|
|
value: "dev-tegwick",
|
|
normalize: "deploymentEnvironment",
|
|
},
|
|
presentation: {height: 10, color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
|
|
},
|
|
{
|
|
id: "deploymentEnvironment:test",
|
|
label: "test",
|
|
field: "deploymentEnvironment",
|
|
value: "test",
|
|
rank: 1,
|
|
membership: {
|
|
field: "deploymentEnvironment",
|
|
op: "equals",
|
|
value: "test",
|
|
normalize: "deploymentEnvironment",
|
|
},
|
|
presentation: {height: 20, color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
|
|
},
|
|
{
|
|
id: "deploymentEnvironment:prod",
|
|
label: "prod",
|
|
field: "deploymentEnvironment",
|
|
value: "prod",
|
|
rank: 2,
|
|
membership: {
|
|
field: "deploymentEnvironment",
|
|
op: "equals",
|
|
value: "prod",
|
|
normalize: "deploymentEnvironment",
|
|
},
|
|
presentation: {height: 30, color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
|
|
},
|
|
],
|
|
};
|
|
|
|
const zoneElementData = (element) => {
|
|
if (!element) return {};
|
|
if (typeof element.data === "function") return element.data();
|
|
return element.data && typeof element.data === "object" ? element.data : element;
|
|
};
|
|
|
|
const zoneFieldValue = (data, field) => {
|
|
if (!field) return "";
|
|
let current = data || {};
|
|
String(field).split(".").forEach((part) => {
|
|
current = current && typeof current === "object" ? current[part] : undefined;
|
|
});
|
|
return current;
|
|
};
|
|
|
|
const zoneRuleValue = (data, rule) => {
|
|
const value = zoneFieldValue(data, rule.field);
|
|
if (rule.normalize === "deploymentEnvironment") return normalizeDeploymentZoneValue(value);
|
|
return hasValue(value) ? String(value).trim() : "";
|
|
};
|
|
|
|
const zoneRuleMatches = (data, rule, emptyMatches = false) => {
|
|
if (!rule || !Object.keys(rule).length) return emptyMatches;
|
|
if (Array.isArray(rule.all)) {
|
|
return rule.all.every((child) => zoneRuleMatches(data, child));
|
|
}
|
|
if (Array.isArray(rule.any)) {
|
|
return rule.any.some((child) => zoneRuleMatches(data, child));
|
|
}
|
|
if (Array.isArray(rule.rules)) {
|
|
return rule.rules.every((child) => zoneRuleMatches(data, child));
|
|
}
|
|
const actual = zoneRuleValue(data, rule);
|
|
if (rule.op === "exists") return hasValue(actual);
|
|
if (rule.op === "in") {
|
|
const expected = Array.isArray(rule.value) ? rule.value : [rule.value];
|
|
return expected.map((value) => String(value)).includes(String(actual));
|
|
}
|
|
return String(actual) === String(rule.value ?? "");
|
|
};
|
|
|
|
const zoneDescriptorFromDefinition = (definition) => ({
|
|
field: definition.field,
|
|
id: definition.id,
|
|
label: definition.label || definition.id,
|
|
value: definition.value || definition.label || definition.id,
|
|
rank: definition.rank,
|
|
presentation: definition.presentation || {},
|
|
membership: definition.membership || {},
|
|
diagnostics: [],
|
|
});
|
|
|
|
const accessZoneDefinition = (value) => {
|
|
const label = String(value || "").trim();
|
|
if (!label) return null;
|
|
return {
|
|
id: `accessZone:${label}`,
|
|
label,
|
|
field: "accessZone",
|
|
value: label,
|
|
rank: 10 + hashString(label) % 20,
|
|
membership: {field: "accessZone", op: "equals", value: label},
|
|
presentation: {},
|
|
};
|
|
};
|
|
|
|
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
|
|
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
|
if (grouping === "accessZone") {
|
|
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
|
|
return definition ? [definition] : [];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
|
|
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
|
if (grouping === "accessZone") {
|
|
const values = new Set();
|
|
elements.forEach((element) => {
|
|
const value = String(zoneFieldValue(zoneElementData(element), "accessZone") || "").trim();
|
|
if (value) values.add(value);
|
|
});
|
|
return Array.from(values)
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.map(accessZoneDefinition)
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const chooseZoneCandidate = (candidates) => candidates
|
|
.slice()
|
|
.sort((left, right) => {
|
|
const leftHeight = Number(left.definition.presentation?.height) || 0;
|
|
const rightHeight = Number(right.definition.presentation?.height) || 0;
|
|
return rightHeight - leftHeight || left.index - right.index || left.definition.id.localeCompare(right.definition.id);
|
|
})[0];
|
|
|
|
const resolveZoneInstances = (elements, definitions) => {
|
|
const zones = new Map();
|
|
definitions.forEach((definition, index) => {
|
|
zones.set(definition.id, {
|
|
...zoneDescriptorFromDefinition(definition),
|
|
definition,
|
|
definitionIndex: index,
|
|
nodes: [],
|
|
elements: [],
|
|
nodeIds: new Set(),
|
|
elementIds: new Set(),
|
|
diagnostics: [],
|
|
});
|
|
});
|
|
const assignments = new Map();
|
|
const addElementToZone = (zoneId, element) => {
|
|
const zone = zones.get(zoneId);
|
|
if (!zone) return;
|
|
const elementId = element.id ? element.id() : zoneElementData(element).id;
|
|
if (elementId && zone.elementIds.has(elementId)) return;
|
|
if (elementId) zone.elementIds.add(elementId);
|
|
zone.elements.push(element);
|
|
};
|
|
elements.filter((element) => element.isNode && element.isNode()).forEach((node) => {
|
|
const data = zoneElementData(node);
|
|
const candidates = definitions
|
|
.map((definition, index) => ({definition, index}))
|
|
.filter((candidate) => zoneRuleMatches(data, candidate.definition.membership));
|
|
if (!candidates.length) return;
|
|
if (candidates.length > 1) {
|
|
const diagnostic = {
|
|
severity: "warning",
|
|
code: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
|
|
message: `${elementLabel(node)} matched multiple zone definitions.`,
|
|
};
|
|
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
|
|
}
|
|
const chosen = chooseZoneCandidate(candidates).definition;
|
|
const zone = zones.get(chosen.id);
|
|
if (!zone) return;
|
|
assignments.set(node.id(), chosen.id);
|
|
zone.nodeIds.add(node.id());
|
|
zone.nodes.push(node);
|
|
addElementToZone(chosen.id, node);
|
|
});
|
|
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
|
|
const data = zoneElementData(edge);
|
|
const zoneIds = new Set(
|
|
definitions
|
|
.filter((definition) => zoneRuleMatches(data, definition.membership))
|
|
.map((definition) => definition.id)
|
|
);
|
|
const sourceZoneId = assignments.get(edge.data("source"));
|
|
const targetZoneId = assignments.get(edge.data("target"));
|
|
if (sourceZoneId) zoneIds.add(sourceZoneId);
|
|
if (targetZoneId) zoneIds.add(targetZoneId);
|
|
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
|
|
});
|
|
return zones;
|
|
};
|
|
|
|
const zoneStyle = (zone) =>
|
|
zone.presentation?.color
|
|
? {color: zone.presentation.color, fill: zone.presentation.fill || zonePalette[zone.id]?.fill || "rgba(37, 99, 235, .07)"}
|
|
: zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
|
|
|
|
const zoneRank = (zone) => {
|
|
if (Number.isFinite(zone.rank)) return zone.rank;
|
|
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
|
|
if (zone.id === "deploymentEnvironment:test") return 1;
|
|
if (zone.id === "deploymentEnvironment:prod") return 2;
|
|
return 10 + hashString(zone.id) % 20;
|
|
};
|
|
|
|
const zoneLabelRank = (zone, index) => {
|
|
if (zone.id === "deploymentEnvironment:test") return 0;
|
|
if (zone.id === "deploymentEnvironment:prod") return 1;
|
|
if (zone.id === "deploymentEnvironment:dev-tegwick") return 2;
|
|
return index % 4;
|
|
};
|
|
|
|
const zoneLabelTop = (zone, bounds, index) => {
|
|
const rank = zone.field === "deploymentEnvironment" ? zoneLabelRank(zone, index) : index % 4;
|
|
return Math.min(8 + rank * 28, Math.max(8, Math.round(bounds.height - 32)));
|
|
};
|
|
|
|
const zoneForData = (data, grouping = activeZoneGrouping) => {
|
|
const definition = zoneDefinitionsForData(data, grouping)
|
|
.find((candidate) => zoneRuleMatches(data, candidate.membership));
|
|
return definition ? zoneDescriptorFromDefinition(definition) : null;
|
|
};
|
|
|
|
const renderedNodeBox = (node) => {
|
|
try {
|
|
const box = node.renderedBoundingBox({includeLabels: true, includeOverlays: false});
|
|
if ([box.x1, box.y1, box.x2, box.y2].every(Number.isFinite)) return box;
|
|
} catch {
|
|
// Fall back to the rendered center point below.
|
|
}
|
|
const position = node.renderedPosition();
|
|
const radius = (Number(node.data("visualSize")) || 44) / 2;
|
|
return {
|
|
x1: position.x - radius,
|
|
y1: position.y - radius,
|
|
x2: position.x + radius,
|
|
y2: position.y + radius,
|
|
};
|
|
};
|
|
|
|
const zoneBoundsForNodes = (nodes) => {
|
|
if (!nodes.length) return null;
|
|
const bounds = nodes.reduce((box, node) => {
|
|
const nodeBox = renderedNodeBox(node);
|
|
return {
|
|
x1: Math.min(box.x1, nodeBox.x1),
|
|
y1: Math.min(box.y1, nodeBox.y1),
|
|
x2: Math.max(box.x2, nodeBox.x2),
|
|
y2: Math.max(box.y2, nodeBox.y2),
|
|
};
|
|
}, {x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity});
|
|
if (![bounds.x1, bounds.y1, bounds.x2, bounds.y2].every(Number.isFinite)) return null;
|
|
const padding = 34;
|
|
let left = bounds.x1 - padding;
|
|
let top = bounds.y1 - padding;
|
|
let width = bounds.x2 - bounds.x1 + padding * 2;
|
|
let height = bounds.y2 - bounds.y1 + padding * 2;
|
|
const minWidth = 132;
|
|
const minHeight = 84;
|
|
if (width < minWidth) {
|
|
left -= (minWidth - width) / 2;
|
|
width = minWidth;
|
|
}
|
|
if (height < minHeight) {
|
|
top -= (minHeight - height) / 2;
|
|
height = minHeight;
|
|
}
|
|
return {left, top, width, height};
|
|
};
|
|
|
|
const collectZoneSummaries = () => {
|
|
if (!cy) return new Map();
|
|
const visibleElements = cy.elements()
|
|
.filter((element) => element.style("display") !== "none")
|
|
.toArray();
|
|
return resolveZoneInstances(
|
|
visibleElements,
|
|
zoneDefinitionsForElements(visibleElements, activeZoneGrouping)
|
|
);
|
|
};
|
|
|
|
const zoneCountsForField = (elements, field) => {
|
|
if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field);
|
|
const zones = resolveZoneInstances(elements, zoneDefinitionsForElements(elements, field));
|
|
return Array.from(zones.values())
|
|
.filter((zone) => zone.elements.length > 0)
|
|
.map((zone) => [zone.label, zone.elements.length])
|
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
};
|
|
|
|
const renderZoneDetails = (zone) => {
|
|
selected = null;
|
|
selectedZoneId = zone.id;
|
|
const visibleElements = zone.elements || [];
|
|
const visibleNodes = zone.nodes || [];
|
|
detailTitle.textContent = zone.label;
|
|
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
|
|
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
|
|
.map((value) => value ? `<span class="pill">${escapeHtml(value)}</span>` : "")
|
|
.join("");
|
|
|
|
const valuesFor = (field) => groupCounts(visibleElements, field)
|
|
.map(([value, count]) => `${value} (${count})`)
|
|
.join(", ");
|
|
const warnings = visibleElements
|
|
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
|
|
`${elementLabel(element)}: ${warning}`
|
|
));
|
|
const rows = [
|
|
["visible nodes", String(visibleNodes.length)],
|
|
["deployment environments", valuesFor("deploymentEnvironment")],
|
|
["deployment scenarios", valuesFor("deploymentScenario")],
|
|
["access zones", valuesFor("accessZone")],
|
|
["routing authorities", valuesFor("routingAuthority")],
|
|
["policy authorities", valuesFor("policyAuthority")],
|
|
...warnings.slice(0, 8).map((warning) => ["warning", warning]),
|
|
];
|
|
if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
|
|
detailList.innerHTML = rows
|
|
.filter(([, value]) => value)
|
|
.map(([key, value]) => `<li class="${key === "warning" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
|
.join("");
|
|
|
|
const contextIds = new Set(visibleNodes.map((node) => node.id()));
|
|
visibleElements.filter((element) => element.isEdge()).forEach((edge) => addContextEdge(contextIds, edge));
|
|
orientationContext = {
|
|
ids: Array.from(contextIds),
|
|
profileName: `Zone: ${zone.label}`,
|
|
};
|
|
orientationTitle.textContent = `Zone context (${orientationContext.ids.length} items)`;
|
|
orientationList.innerHTML = [
|
|
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
|
|
{label: "zone", value: zone.label},
|
|
{label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
|
|
].map((row) => `
|
|
<li class="orientation-item ${orientationStateClass(row.state)}">
|
|
<span class="orientation-label">${escapeHtml(row.label)}</span>
|
|
<span class="orientation-value">${escapeHtml(row.value)}</span>
|
|
</li>
|
|
`).join("");
|
|
orientationActions.innerHTML = `
|
|
<button type="button" data-orientation-action="focus">Focus Context</button>
|
|
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
|
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
|
<button type="button" data-orientation-action="remove-other">Remove Other</button>
|
|
<button type="button" data-orientation-action="name-view">Name View</button>
|
|
`;
|
|
updateLabelVisibility();
|
|
updateSelectionAnchor();
|
|
};
|
|
|
|
const renderZoneOverlay = () => {
|
|
if (!zoneOverlay || !cy) return;
|
|
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
|
|
if (!enabled) {
|
|
zoneSummaries = new Map();
|
|
selectedZoneId = "";
|
|
zoneOverlay.innerHTML = "";
|
|
return;
|
|
}
|
|
zoneSummaries = collectZoneSummaries();
|
|
const boundaries = Array.from(zoneSummaries.values())
|
|
.filter((zone) => zone.nodes.length > 0)
|
|
.sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label))
|
|
.map((zone, index) => {
|
|
const bounds = zoneBoundsForNodes(zone.nodes);
|
|
if (!bounds) return "";
|
|
const style = zoneStyle(zone);
|
|
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
|
|
const labelTop = zoneLabelTop(zone, bounds, index);
|
|
return `
|
|
<div class="zone-boundary${selectedClass}" data-zone-id="${escapeHtml(zone.id)}" style="--zone-color:${style.color};--zone-fill:${style.fill};left:${Math.round(bounds.left)}px;top:${Math.round(bounds.top)}px;width:${Math.round(bounds.width)}px;height:${Math.round(bounds.height)}px">
|
|
<button type="button" class="zone-label" data-zone-id="${escapeHtml(zone.id)}" style="--zone-color:${style.color};top:${labelTop}px" title="${escapeHtml(zone.label)}">${escapeHtml(zone.label)}</button>
|
|
</div>
|
|
`;
|
|
});
|
|
zoneOverlay.innerHTML = boundaries.join("");
|
|
if (selectedZoneId && zoneSummaries.has(selectedZoneId)) {
|
|
renderZoneDetails(zoneSummaries.get(selectedZoneId));
|
|
} else if (selectedZoneId) {
|
|
selectedZoneId = "";
|
|
}
|
|
};
|
|
|
|
const scheduleZoneOverlayUpdate = () => {
|
|
if (!zoneOverlay || zoneOverlayFrame) return;
|
|
zoneOverlayFrame = window.requestAnimationFrame(() => {
|
|
zoneOverlayFrame = 0;
|
|
renderZoneOverlay();
|
|
});
|
|
};
|
|
|
|
const groupCounts = (elements, field) => {
|
|
const counts = new Map();
|
|
elements.forEach((element) => {
|
|
const value = element.data(field);
|
|
if (hasValue(value)) counts.set(String(value), (counts.get(String(value)) || 0) + 1);
|
|
});
|
|
return Array.from(counts.entries())
|
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
};
|
|
|
|
const renderMapOverview = () => {
|
|
const visibleElements = cy
|
|
? cy.elements().filter((element) => element.style("display") !== "none").toArray()
|
|
: [];
|
|
const visibleNodes = visibleElements.filter((element) => element.isNode()).length;
|
|
const visibleEdges = visibleElements.filter((element) => element.isEdge()).length;
|
|
const fields = zoneModeFields[activeMode] || [];
|
|
const title = zoneModeLabels[activeMode] || "Full";
|
|
detailTitle.textContent = activeMode === "full" ? "Fabric Map" : `${title} View`;
|
|
detailSummary.textContent = `${visibleNodes} nodes / ${visibleEdges} edges visible`;
|
|
detailPills.innerHTML = activeMode === "full" ? "" : `<span class="pill">${escapeHtml(activeMode)}</span>`;
|
|
|
|
const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}];
|
|
fields.forEach((field) => {
|
|
const counts = zoneCountsForField(visibleElements, field);
|
|
rows.push({
|
|
label: ruleAttributeLabels[field] || humanize(field),
|
|
value: counts.length
|
|
? counts.slice(0, 8).map(([value, count]) => `${value} (${count})`).join(", ")
|
|
: "no annotated visible entities",
|
|
state: counts.length ? "" : "warning",
|
|
});
|
|
});
|
|
|
|
const warnings = visibleElements
|
|
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
|
|
`${elementLabel(element)}: ${warning}`
|
|
));
|
|
warnings.slice(0, 6).forEach((warning) => rows.push({label: "warning", value: warning, state: "warning"}));
|
|
if (warnings.length > 6) {
|
|
rows.push({label: "warning", value: `${warnings.length - 6} additional route warnings`, state: "warning"});
|
|
}
|
|
|
|
detailList.innerHTML = rows
|
|
.filter((row) => row.value)
|
|
.map((row) => `<li class="${orientationStateClass(row.state)}"><strong>${escapeHtml(row.label)}</strong> ${escapeHtml(row.value)}</li>`)
|
|
.join("");
|
|
};
|
|
|
|
const collectionArray = (collection) => Array.from(collection || []);
|
|
|
|
const orientationStateClass = (state) =>
|
|
state === "warning" ? "orientation-warning" : state === "good" ? "orientation-good" : "";
|
|
|
|
const renderedElementPosition = (element) => {
|
|
if (!element || !cy) return null;
|
|
if (element.isNode && element.isNode()) return element.renderedPosition();
|
|
if (element.isEdge && element.isEdge()) {
|
|
if (typeof element.renderedMidpoint === "function") {
|
|
const midpoint = element.renderedMidpoint();
|
|
if (isFinitePoint(midpoint)) return midpoint;
|
|
}
|
|
const source = cy.getElementById(element.data("source"));
|
|
const target = cy.getElementById(element.data("target"));
|
|
if (source.length > 0 && target.length > 0) {
|
|
const sourcePosition = source.renderedPosition();
|
|
const targetPosition = target.renderedPosition();
|
|
if (isFinitePoint(sourcePosition) && isFinitePoint(targetPosition)) {
|
|
return {
|
|
x: (sourcePosition.x + targetPosition.x) / 2,
|
|
y: (sourcePosition.y + targetPosition.y) / 2,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const updateSelectionAnchor = () => {
|
|
if (!selected || !selectionAnchor) {
|
|
selectedAnchor = null;
|
|
selectionAnchor.style.display = "none";
|
|
return;
|
|
}
|
|
const position = renderedElementPosition(selected);
|
|
if (isFinitePoint(position)) {
|
|
selectedAnchor = {x: position.x, y: position.y, label: elementLabel(selected)};
|
|
}
|
|
if (!selectedAnchor) {
|
|
selectionAnchor.style.display = "none";
|
|
return;
|
|
}
|
|
selectionAnchor.style.left = `${selectedAnchor.x}px`;
|
|
selectionAnchor.style.top = `${selectedAnchor.y}px`;
|
|
selectionAnchorLabel.textContent = selectedAnchor.label;
|
|
selectionAnchor.style.display = "block";
|
|
};
|
|
|
|
const isKeyLabelNode = (node) => {
|
|
const layer = node.data("layer");
|
|
return (
|
|
["repository", "server", "deployment", "service"].includes(layer) ||
|
|
node.data("unresolved") === true ||
|
|
node.data("lifecycle") === "registered-only"
|
|
);
|
|
};
|
|
|
|
const updateLabelVisibility = () => {
|
|
if (!cy) return;
|
|
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none");
|
|
const mode = activeLabelMode === "auto" && visibleNodes.length > 70 ? "key" : activeLabelMode;
|
|
cy.nodes().forEach((node) => {
|
|
let showLabel = mode === "all" || mode === "auto";
|
|
if (mode === "key") showLabel = isKeyLabelNode(node);
|
|
if (mode === "none") showLabel = false;
|
|
if (selected && node.id() === selected.id()) showLabel = false;
|
|
node.toggleClass("label-hidden", !showLabel);
|
|
});
|
|
};
|
|
|
|
const currentViewState = () => ({
|
|
search: searchInput.value,
|
|
mode: modeSelect.value || "full",
|
|
layout: layoutSelect.value || "cose",
|
|
labelMode: labelSelect.value || "auto",
|
|
nodeTypes: Array.from(selectedNodeTypes()),
|
|
edgeTypes: Array.from(selectedEdgeTypes()),
|
|
review: reviewFilter.value,
|
|
unresolved: unresolvedFilter.value,
|
|
zoneBoundaries: zoneBoundaryToggle ? zoneBoundaryToggle.checked : true,
|
|
zoneGrouping: zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment",
|
|
rules: filterRules.map((rule) => ({...rule})),
|
|
manualOverrides: {...manualOverrides},
|
|
});
|
|
|
|
const encodeStateBlob = (state) => {
|
|
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
|
let binary = "";
|
|
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
|
return btoa(binary);
|
|
};
|
|
|
|
const decodeStateBlob = (blob) => {
|
|
const bytes = Uint8Array.from(atob(blob), (char) => char.charCodeAt(0));
|
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
};
|
|
|
|
const readUrlState = () => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const state = {};
|
|
if (params.has("search")) state.search = params.get("search") || "";
|
|
if (params.has("mode")) state.mode = params.get("mode") || "";
|
|
if (params.has("layout")) state.layout = params.get("layout") || "";
|
|
if (params.has("labelMode")) state.labelMode = params.get("labelMode") || "";
|
|
if (params.has("nodeTypes")) state.nodeTypes = parseListParam(params.get("nodeTypes"));
|
|
if (params.has("edgeTypes")) state.edgeTypes = parseListParam(params.get("edgeTypes"));
|
|
if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer"));
|
|
if (params.has("review")) state.review = params.get("review") || "";
|
|
if (params.has("unresolved")) state.unresolved = params.get("unresolved") || "";
|
|
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
|
|
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
|
|
if (params.has("profile")) state.profile = params.get("profile") || "";
|
|
if (params.has("state")) {
|
|
try {
|
|
Object.assign(state, decodeStateBlob(params.get("state") || ""));
|
|
} catch (error) {
|
|
profileSummary.textContent = `Could not load copied map state: ${error.message}`;
|
|
}
|
|
}
|
|
return state;
|
|
};
|
|
|
|
const viewUrl = (includeStateBlob = false) => {
|
|
const params = new URLSearchParams();
|
|
const state = currentViewState();
|
|
if (state.search) params.set("search", state.search);
|
|
if (state.mode && state.mode !== "full") params.set("mode", state.mode);
|
|
if (state.layout && state.layout !== "cose") params.set("layout", state.layout);
|
|
if (state.labelMode && state.labelMode !== "auto") params.set("labelMode", state.labelMode);
|
|
if (state.nodeTypes.length !== allNodeTypes.length) params.set("nodeTypes", state.nodeTypes.join(","));
|
|
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
|
|
if (state.review) params.set("review", state.review);
|
|
if (state.unresolved) params.set("unresolved", state.unresolved);
|
|
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
|
|
if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping);
|
|
if (currentProfileId) params.set("profile", currentProfileId);
|
|
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
|
|
params.set("state", encodeStateBlob(state));
|
|
}
|
|
const query = params.toString();
|
|
return `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
|
|
};
|
|
|
|
const updateUrlState = () => {
|
|
if (!window.history || !window.history.replaceState) return;
|
|
window.history.replaceState({}, "", viewUrl(false));
|
|
};
|
|
|
|
const optionExists = (select, value) =>
|
|
Array.from(select.options).some((option) => option.value === value);
|
|
|
|
const applyViewState = (state, options = {}) => {
|
|
if ("search" in state) searchInput.value = state.search || "";
|
|
if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full";
|
|
if ("layout" in state) layoutSelect.value = optionExists(layoutSelect, state.layout) ? state.layout : "cose";
|
|
if ("labelMode" in state) labelSelect.value = optionExists(labelSelect, state.labelMode) ? state.labelMode : "auto";
|
|
if ("nodeTypes" in state) setCheckedValues(nodeTypeFilter, (state.nodeTypes || []).filter((value) => allNodeTypes.includes(value)));
|
|
if ("layer" in state) setCheckedValues(nodeTypeFilter, allNodeTypes.includes(state.layer) ? [state.layer] : null);
|
|
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
|
|
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
|
|
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
|
|
if ("zoneBoundaries" in state && zoneBoundaryToggle) zoneBoundaryToggle.checked = state.zoneBoundaries !== false;
|
|
if ("zoneGrouping" in state && zoneGroupSelect) {
|
|
zoneGroupSelect.value = optionExists(zoneGroupSelect, state.zoneGrouping) ? state.zoneGrouping : "deploymentEnvironment";
|
|
}
|
|
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
|
manualOverrides = {...state.manualOverrides};
|
|
}
|
|
if ("rules" in state) {
|
|
filterRules = normalizeRules(state.rules);
|
|
editingRuleId = "";
|
|
refreshRuleBuilder();
|
|
renderRules();
|
|
}
|
|
activeMode = modeSelect.value || "full";
|
|
activeLabelMode = labelSelect.value || "auto";
|
|
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
|
|
selectedZoneId = "";
|
|
focusSet = null;
|
|
syncFilterSummaries();
|
|
applyFilters();
|
|
if (!options.skipLayout) runLayout();
|
|
if (!options.skipUrl) updateUrlState();
|
|
};
|
|
|
|
const loadProfiles = () => {
|
|
if (!supportsLocalProfiles()) return [];
|
|
try {
|
|
const parsed = JSON.parse(window.localStorage.getItem(profileStorageKey) || "[]");
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((profile) => profile && typeof profile.id === "string")
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const persistProfiles = () => {
|
|
if (!supportsLocalProfiles()) return;
|
|
window.localStorage.setItem(profileStorageKey, JSON.stringify(profiles));
|
|
};
|
|
|
|
const selectedProfile = () => profiles.find((profile) => profile.id === currentProfileId) || null;
|
|
|
|
const viewStateSummary = () => {
|
|
const parts = [];
|
|
if (searchInput.value.trim()) parts.push("search");
|
|
if ((modeSelect.value || "full") !== "full") parts.push(modeSelect.options[modeSelect.selectedIndex]?.textContent || "mode");
|
|
if ((labelSelect.value || "auto") !== "auto") parts.push(`${labelSelect.value} labels`);
|
|
if (selectedNodeTypes().size !== allNodeTypes.length) parts.push("node filter");
|
|
if (selectedEdgeTypes().size !== allEdgeTypes.length) parts.push("edge filter");
|
|
if (reviewFilter.value) parts.push(`${reviewFilter.value} review`);
|
|
if (unresolvedFilter.value) parts.push("unresolved only");
|
|
if (zoneBoundaryToggle && !zoneBoundaryToggle.checked) parts.push("zones off");
|
|
if (zoneGroupSelect && (zoneGroupSelect.value || "deploymentEnvironment") !== "deploymentEnvironment") {
|
|
parts.push("access zone boundaries");
|
|
}
|
|
if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`);
|
|
const overrideCount = Object.keys(manualOverrides).length;
|
|
if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`);
|
|
return parts.length ? parts.join(", ") : "no filters";
|
|
};
|
|
|
|
const updateProfileSummary = (message = "") => {
|
|
if (!supportsLocalProfiles()) {
|
|
profileSummary.textContent = "Profile persistence unavailable for this host.";
|
|
return;
|
|
}
|
|
const profile = selectedProfile();
|
|
const prefix = profile ? `Loaded "${profile.name}".` : "Unsaved exploration.";
|
|
profileSummary.textContent = message || `${prefix} Current state: ${viewStateSummary()}. ${profiles.length} saved view${profiles.length === 1 ? "" : "s"}.`;
|
|
};
|
|
|
|
const updateProfileControls = () => {
|
|
const enabled = supportsLocalProfiles();
|
|
profileSelect.disabled = !enabled;
|
|
profileNameInput.disabled = !enabled;
|
|
document.querySelectorAll("[data-profile-action]").forEach((button) => {
|
|
const action = button.dataset.profileAction;
|
|
button.disabled = !enabled || ((action === "duplicate" || action === "delete") && !currentProfileId);
|
|
});
|
|
};
|
|
|
|
const renderProfiles = () => {
|
|
profileSelect.innerHTML = "";
|
|
const unsaved = document.createElement("option");
|
|
unsaved.value = "";
|
|
unsaved.textContent = "Unsaved exploration";
|
|
profileSelect.appendChild(unsaved);
|
|
profiles
|
|
.slice()
|
|
.sort((left, right) => String(left.name).localeCompare(String(right.name)))
|
|
.forEach((profile) => {
|
|
const option = document.createElement("option");
|
|
option.value = profile.id;
|
|
option.textContent = profile.name || profile.id;
|
|
profileSelect.appendChild(option);
|
|
});
|
|
if (!profiles.some((profile) => profile.id === currentProfileId)) currentProfileId = "";
|
|
profileSelect.value = currentProfileId;
|
|
const profile = selectedProfile();
|
|
profileNameInput.value = profile ? profile.name : "";
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
};
|
|
|
|
const saveCurrentProfile = (name, sourceProfile = null) => {
|
|
const now = new Date().toISOString();
|
|
const profileName = name.trim() || sourceProfile?.name || orientationContext?.profileName || `Fabric view ${profiles.length + 1}`;
|
|
if (sourceProfile) {
|
|
const profile = {
|
|
...sourceProfile,
|
|
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
name: profileName,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
profiles = [...profiles, profile];
|
|
currentProfileId = profile.id;
|
|
} else if (currentProfileId) {
|
|
profiles = profiles.map((profile) => profile.id === currentProfileId
|
|
? {...profile, name: profileName, state: currentViewState(), updatedAt: now}
|
|
: profile);
|
|
} else {
|
|
const profile = {
|
|
id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
name: profileName,
|
|
state: currentViewState(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
profiles = [...profiles, profile];
|
|
currentProfileId = profile.id;
|
|
}
|
|
persistProfiles();
|
|
renderProfiles();
|
|
updateUrlState();
|
|
};
|
|
|
|
const matchesFilters = (element) => {
|
|
const data = element.data();
|
|
const modeSet = visibleSetForMode();
|
|
if (modeSet && !modeSet.has(element.id())) return false;
|
|
const text = searchInput.value.trim().toLowerCase();
|
|
if (text && !elementText(data).includes(text)) return false;
|
|
if (element.isNode() && !selectedNodeTypes().has(data.layer)) return false;
|
|
if (element.isEdge() && !selectedEdgeTypes().has(data.edgeType)) return false;
|
|
if (reviewFilter.value && data.reviewState !== reviewFilter.value) return false;
|
|
if (unresolvedFilter.value === "true" && data.unresolved !== true) return false;
|
|
if (focusSet && !focusSet.has(element.id())) return false;
|
|
return true;
|
|
};
|
|
|
|
const visibleSetForMode = () => {
|
|
if (!cy) return null;
|
|
if (focusSet) return focusSet;
|
|
if (activeMode === "full") return null;
|
|
if (activeMode === "onboarding-gaps") {
|
|
return new Set(cy.elements().filter((element) =>
|
|
element.data("lifecycle") === "registered-only" || element.data("unresolved") === true
|
|
).map((element) => element.id()));
|
|
}
|
|
if (activeMode === "unresolved") {
|
|
return new Set(cy.elements().filter((element) => element.data("unresolved") === true).map((element) => element.id()));
|
|
}
|
|
const zoneFields = zoneModeFields[activeMode] || [];
|
|
if (zoneFields.length && activeMode !== "by-fabric") {
|
|
return new Set(cy.elements().filter((element) =>
|
|
zoneFields.some((field) => hasValue(element.data(field)))
|
|
).map((element) => element.id()));
|
|
}
|
|
if (!selected) return null;
|
|
if (activeMode === "selected-path") {
|
|
const collection = selected.union(selected.predecessors()).union(selected.successors());
|
|
return new Set(collection.map((element) => element.id()));
|
|
}
|
|
if (activeMode === "neighborhood") {
|
|
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
|
|
return new Set(collection.map((element) => element.id()));
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const applyFilters = (options = {}) => {
|
|
if (!cy) return;
|
|
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
|
|
syncFilterSummaries();
|
|
const hiddenNodes = new Set();
|
|
const removedNodes = new Set();
|
|
cy.nodes().forEach((node) => {
|
|
let state = matchesFilters(node) ? "show" : "hide";
|
|
const ruleAction = ruleActionFor(node);
|
|
if (ruleAction) state = ruleAction;
|
|
if (state !== "remove") {
|
|
const override = manualOverrideFor(node);
|
|
if (override) state = override;
|
|
}
|
|
node.data("displayState", state);
|
|
node.toggleClass("display-blur", state === "blur");
|
|
node.toggleClass("rule-highlight", state === "highlight");
|
|
node.style("display", state === "hide" || state === "remove" ? "none" : "element");
|
|
if (state === "remove") removedNodes.add(node.id());
|
|
if (state === "hide") hiddenNodes.add(node.id());
|
|
});
|
|
cy.edges().forEach((edge) => {
|
|
let state = matchesFilters(edge) ? "show" : "hide";
|
|
const ruleAction = ruleActionFor(edge);
|
|
if (ruleAction) state = ruleAction;
|
|
if (removedNodes.has(edge.data("source")) || removedNodes.has(edge.data("target"))) {
|
|
state = "remove";
|
|
} else if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) {
|
|
state = "hide";
|
|
}
|
|
if (state !== "remove") {
|
|
const override = manualOverrideFor(edge);
|
|
if (override) state = override;
|
|
}
|
|
edge.data("displayState", state);
|
|
edge.toggleClass("display-blur", state === "blur");
|
|
edge.toggleClass("rule-highlight", state === "highlight");
|
|
edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
|
|
});
|
|
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
|
|
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
|
|
const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
|
|
const hidden = cy.elements().length - visibleNodes - visibleEdges - removed;
|
|
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
|
hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
|
|
updateLabelVisibility();
|
|
updateSelectionAnchor();
|
|
scheduleZoneOverlayUpdate();
|
|
if (!selected) renderMapOverview();
|
|
if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
|
|
};
|
|
|
|
const showDetails = (element) => {
|
|
selected = element || null;
|
|
selectedZoneId = "";
|
|
scheduleZoneOverlayUpdate();
|
|
if (!element) {
|
|
orientationContext = null;
|
|
renderMapOverview();
|
|
orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo.";
|
|
orientationList.innerHTML = "";
|
|
orientationActions.innerHTML = "";
|
|
updateLabelVisibility();
|
|
updateSelectionAnchor();
|
|
return;
|
|
}
|
|
const data = element.data();
|
|
detailTitle.textContent = data.name || data.label || data.id;
|
|
detailSummary.textContent = data.description || data.id;
|
|
const nodeType = data.layer ? nodeTypeLabels[data.layer] || humanize(data.layer) : "";
|
|
detailPills.innerHTML = [data.kind, nodeType, data.canonCategory || data.canonicalType, data.evidenceState, data.repo, data.reviewState, data.displayState]
|
|
.map((value) => value ? `<span class="pill">${escapeHtml(value)}</span>` : "")
|
|
.join("");
|
|
const links = data.deepLinks || {};
|
|
const refs = data.sourceReferences || [];
|
|
const rows = [
|
|
["id", data.id],
|
|
["source", data.source],
|
|
["target", data.target],
|
|
["edge", data.edgeType],
|
|
["canonical", data.canonicalType || data.canonCategory],
|
|
["evidence", data.evidenceState],
|
|
["mapping", data.mappingFit],
|
|
["display only", data.displayOnly === true ? "yes" : ""],
|
|
["strength", data.strength],
|
|
["deployment environment", data.deploymentEnvironment],
|
|
["deployment scenario", data.deploymentScenario],
|
|
["routing authority", data.routingAuthority],
|
|
["access zone", data.accessZone],
|
|
["policy authority", data.policyAuthority],
|
|
["exposure", data.exposureClass],
|
|
["route", routeLabel(data)],
|
|
...zoneWarningsForData(data).map((warning) => ["warning", warning]),
|
|
...Object.entries(links),
|
|
...refs.map((ref) => [ref.label || ref.kind || "source", ref.path || ref.url || ref.ref || ""])
|
|
];
|
|
detailList.innerHTML = rows
|
|
.filter(([, value]) => value)
|
|
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
|
.join("");
|
|
renderOrientation(element);
|
|
updateLabelVisibility();
|
|
updateSelectionAnchor();
|
|
};
|
|
|
|
const renderOrientation = (element) => {
|
|
const data = element.data();
|
|
const contextIds = new Set([element.id()]);
|
|
const rows = [];
|
|
let title = "Graph context";
|
|
let profileName = `Fabric context: ${elementLabel(element)}`;
|
|
if (data.kind === "Repository" && data.lifecycle === "registered-only") {
|
|
title = "Onboarding gap";
|
|
profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`;
|
|
rows.push(
|
|
{label: "repo", value: data.repo || data.id},
|
|
{label: "status", value: "registered without an accepted Fabric graph snapshot", state: "warning"},
|
|
{label: "next", value: "add Fabric declarations or run registry sync once a snapshot exists"}
|
|
);
|
|
} else if (data.kind === "InterfaceDeclaration") {
|
|
title = "Interface consumers and impact";
|
|
profileName = `Interface impact: ${elementLabel(element)}`;
|
|
const attributes = data.attributes || {};
|
|
if (attributes.service_id) {
|
|
addContextNode(contextIds, attributes.service_id);
|
|
rows.push({label: "provider service", value: nodeName(attributes.service_id), state: "good"});
|
|
}
|
|
edgeCollection("", data.id, "available_via").forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
const capability = edge.data("source");
|
|
edgeCollection("", capability, "provides").forEach((providerEdge) => {
|
|
addContextEdge(contextIds, providerEdge);
|
|
rows.push({
|
|
label: "provider surface",
|
|
value: `${nodeName(providerEdge.data("source"))} -> ${nodeName(capability)} -> ${elementLabel(element)}`,
|
|
path: true,
|
|
state: "good",
|
|
});
|
|
});
|
|
});
|
|
let consumerCount = 0;
|
|
edgeCollection("", data.id, "uses_interface").forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
const dependency = edge.data("source");
|
|
const dependencyNode = nodeById(dependency);
|
|
edgeCollection("", dependency, "consumes").forEach((consumerEdge) => {
|
|
consumerCount += 1;
|
|
addContextEdge(contextIds, consumerEdge);
|
|
rows.push({
|
|
label: "consumer",
|
|
value: `${nodeName(consumerEdge.data("source"))} -> ${nodeName(dependency)} -> ${elementLabel(element)}`,
|
|
path: true,
|
|
state: dependencyNode?.data("unresolved") === true ? "warning" : "",
|
|
});
|
|
});
|
|
});
|
|
if (consumerCount === 0) rows.push({label: "consumer", value: "no accepted consumers in current graph"});
|
|
} else if (data.kind === "ServiceDeclaration") {
|
|
title = "Service dependency chain";
|
|
profileName = `Service map: ${elementLabel(element)}`;
|
|
let dependencyCount = 0;
|
|
edgeCollection(data.id, "", "consumes").forEach((edge) => {
|
|
dependencyCount += 1;
|
|
addContextEdge(contextIds, edge);
|
|
const dependency = edge.data("target");
|
|
const providerEdges = cy.edges().filter((candidate) =>
|
|
candidate.data("source") === dependency && (
|
|
String(candidate.data("edgeType")).startsWith("binds:") ||
|
|
candidate.data("edgeType") === "uses_interface"
|
|
)
|
|
);
|
|
if (providerEdges.length === 0) {
|
|
rows.push({
|
|
label: "requires",
|
|
value: `${elementLabel(element)} -> ${nodeName(dependency)} -> unresolved`,
|
|
path: true,
|
|
state: "warning",
|
|
});
|
|
return;
|
|
}
|
|
providerEdges.forEach((providerEdge) => {
|
|
addContextEdge(contextIds, providerEdge);
|
|
const target = providerEdge.data("target");
|
|
rows.push({
|
|
label: "requires",
|
|
value: `${elementLabel(element)} -> ${nodeName(dependency)} -> ${nodeName(target)} (${providerEdge.data("edgeType")})`,
|
|
path: true,
|
|
state: String(providerEdge.data("edgeType")).includes("missing") ? "warning" : "good",
|
|
});
|
|
});
|
|
});
|
|
if (dependencyCount === 0) rows.push({label: "requires", value: "no declared dependencies"});
|
|
edgeCollection(data.id, "", "deployed_as").forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
const deployment = edge.data("target");
|
|
const runtimeEdges = edgeCollection(deployment, "", "runs_on");
|
|
if (runtimeEdges.length === 0) {
|
|
rows.push({label: "deployment", value: `${nodeName(deployment)} -> no server binding`, path: true, state: "warning"});
|
|
return;
|
|
}
|
|
runtimeEdges.forEach((runtimeEdge) => {
|
|
addContextEdge(contextIds, runtimeEdge);
|
|
rows.push({
|
|
label: "runtime",
|
|
value: `${nodeName(deployment)} -> ${nodeName(runtimeEdge.data("target"))}`,
|
|
path: true,
|
|
state: "good",
|
|
});
|
|
});
|
|
});
|
|
} else if (data.kind === "DependencyDeclaration") {
|
|
title = "Dependency binding";
|
|
profileName = `Dependency binding: ${elementLabel(element)}`;
|
|
edgeCollection("", data.id, "consumes").forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
rows.push({
|
|
label: "consumer",
|
|
value: `${nodeName(edge.data("source"))} -> ${elementLabel(element)}`,
|
|
path: true,
|
|
});
|
|
});
|
|
cy.edges().filter((edge) =>
|
|
edge.data("source") === data.id && (
|
|
String(edge.data("edgeType")).startsWith("binds:") ||
|
|
edge.data("edgeType") === "uses_interface"
|
|
)
|
|
).forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
const target = edge.data("target");
|
|
rows.push({
|
|
label: edge.data("edgeType"),
|
|
value: `${elementLabel(element)} -> ${nodeName(target)}`,
|
|
path: true,
|
|
state: String(edge.data("edgeType")).includes("missing") ? "warning" : "good",
|
|
});
|
|
});
|
|
if (rows.length === 0) rows.push({label: "binding", value: "no provider binding in current graph", state: "warning"});
|
|
} else if (data.kind === "CapabilityDeclaration") {
|
|
title = "Provider surface";
|
|
profileName = `Provider surface: ${elementLabel(element)}`;
|
|
edgeCollection("", data.id, "provides").forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
rows.push({label: "service", value: `${nodeName(edge.data("source"))} -> ${elementLabel(element)}`, path: true, state: "good"});
|
|
});
|
|
edgeCollection(data.id, "", "available_via").forEach((edge) => {
|
|
addContextEdge(contextIds, edge);
|
|
rows.push({label: "interface", value: `${elementLabel(element)} -> ${nodeName(edge.data("target"))}`, path: true, state: "good"});
|
|
});
|
|
if (rows.length === 0) rows.push({label: "surface", value: "no provider surface in current graph"});
|
|
} else {
|
|
const neighborhood = element.neighborhood();
|
|
neighborhood.forEach((item) => addContextElement(contextIds, item));
|
|
rows.push({label: "neighbors", value: `${neighborhood.nodes().length} connected nodes`});
|
|
}
|
|
orientationContext = {
|
|
ids: Array.from(contextIds),
|
|
profileName,
|
|
};
|
|
orientationTitle.textContent = `${title} (${orientationContext.ids.length} items)`;
|
|
orientationList.innerHTML = rows
|
|
.map((row) => `
|
|
<li class="orientation-item ${orientationStateClass(row.state)}">
|
|
<span class="orientation-label">${escapeHtml(row.label)}</span>
|
|
<span class="orientation-value ${row.path ? "orientation-path" : ""}">${escapeHtml(row.value)}</span>
|
|
</li>
|
|
`)
|
|
.join("");
|
|
orientationActions.innerHTML = `
|
|
<button type="button" data-orientation-action="focus">Focus Context</button>
|
|
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
|
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
|
<button type="button" data-orientation-action="remove-other">Remove Other</button>
|
|
<button type="button" data-orientation-action="name-view">Name View</button>
|
|
`;
|
|
};
|
|
|
|
const applyOrientationContext = (action) => {
|
|
if (!cy || !orientationContext) return;
|
|
const contextIds = new Set(orientationContext.ids);
|
|
if (action === "focus") {
|
|
focusSet = contextIds;
|
|
modeSelect.value = "neighborhood";
|
|
activeMode = "neighborhood";
|
|
} else if (action === "highlight") {
|
|
contextIds.forEach((id) => { manualOverrides[id] = "highlight"; });
|
|
} else if (action === "hide-other" || action === "remove-other") {
|
|
const otherState = action === "remove-other" ? "remove" : "hide";
|
|
cy.elements().forEach((item) => {
|
|
manualOverrides[item.id()] = contextIds.has(item.id()) ? "show" : otherState;
|
|
});
|
|
} else if (action === "name-view") {
|
|
if (supportsLocalProfiles()) {
|
|
profileNameInput.value = orientationContext.profileName;
|
|
updateProfileSummary(`Prepared view name "${orientationContext.profileName}".`);
|
|
}
|
|
return;
|
|
}
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters({redrawOnRemove: action === "remove-other"});
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
};
|
|
|
|
const applyFocus = () => {
|
|
if (!cy || !selected) return;
|
|
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
|
|
focusSet = new Set(collection.map((item) => item.id()));
|
|
modeSelect.value = "neighborhood";
|
|
activeMode = "neighborhood";
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
};
|
|
|
|
const edgeIdealLength = (edge) => Number(edge.data("layoutIdealLength")) || 110;
|
|
|
|
const edgeElasticity = (edge) => Number(edge.data("layoutElasticity")) || 80;
|
|
|
|
const runLayout = () => {
|
|
if (!cy) return;
|
|
cy.elements().stop();
|
|
const layoutElements = cy.elements().filter((element) => element.data("displayState") !== "remove");
|
|
const name = layoutSelect.value || "cose";
|
|
const options = name === "breadthfirst"
|
|
? {name, directed: true, padding: 48, animate: false}
|
|
: name === "cose"
|
|
? {
|
|
name,
|
|
padding: 48,
|
|
animate: false,
|
|
randomize: false,
|
|
nodeOverlap: 12,
|
|
idealEdgeLength: edgeIdealLength,
|
|
edgeElasticity,
|
|
nodeRepulsion: 5000,
|
|
gravity: 1,
|
|
numIter: 1400,
|
|
}
|
|
: {name, padding: 48, animate: false};
|
|
layoutElements.layout(options).run();
|
|
updateSelectionAnchor();
|
|
scheduleZoneOverlayUpdate();
|
|
};
|
|
|
|
const renderLegend = (layers) => {
|
|
const nodeTypes = layers.map((layer) =>
|
|
`<span class="pill"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(layer.color || "#667085")}"></span>${escapeHtml(layer.label)}</span>`
|
|
);
|
|
const statusItems = [
|
|
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #b45309;border-radius:50%"></span>Unresolved</span>',
|
|
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #be123c;border-radius:3px"></span>Registered only</span>',
|
|
'<span class="pill"><span style="display:inline-block;width:18px;border-top:2px dotted #98a2b3"></span>Weak edge</span>',
|
|
];
|
|
legend.innerHTML = [...nodeTypes, ...statusItems].join("");
|
|
};
|
|
|
|
const boot = async () => {
|
|
const [manifest, payload] = await Promise.all([
|
|
fetch(manifestUrl).then((response) => response.json()),
|
|
fetch(graphUrl).then((response) => response.json())
|
|
]);
|
|
(manifest.modes || [{id: "full", label: "Full"}]).forEach((mode) => {
|
|
const option = document.createElement("option");
|
|
option.value = mode.id;
|
|
option.textContent = mode.label;
|
|
modeSelect.appendChild(option);
|
|
});
|
|
profilePersistence = manifest.profile_persistence || "none";
|
|
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
|
nodeTypeLabels = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.label]));
|
|
allNodeTypes = (manifest.layers || []).map((layer) => layer.id);
|
|
allEdgeTypes = Array.from(new Set((payload.elements || [])
|
|
.map((element) => element.data || {})
|
|
.filter((data) => data.source && data.target && data.edgeType)
|
|
.map((data) => data.edgeType)
|
|
)).sort((left, right) => left.localeCompare(right));
|
|
renderChecklist(nodeTypeFilter, allNodeTypes, nodeTypeLabels, "node-type");
|
|
renderChecklist(edgeTypeFilter, allEdgeTypes, {}, "edge-type");
|
|
syncFilterSummaries();
|
|
renderLegend(manifest.layers || []);
|
|
profiles = loadProfiles();
|
|
renderProfiles();
|
|
const elements = (payload.elements || []).map((element) => ({
|
|
...element,
|
|
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
|
|
}));
|
|
if (elements.length === 0) {
|
|
canvas.innerHTML = "<p class='canvas-notice'>No graph entities were returned by the registry.</p>";
|
|
counts.textContent = "0 nodes / 0 edges";
|
|
hiddenSummary.textContent = "Hidden 0";
|
|
return;
|
|
}
|
|
canvas.innerHTML = "";
|
|
cy = cytoscape({
|
|
container: canvas,
|
|
elements,
|
|
layout: {name: layoutSelect.value || "cose", animate: false, randomize: false},
|
|
style: [
|
|
{selector: "node", style: {
|
|
"background-color": "data(color)",
|
|
"border-color": "#172033",
|
|
"border-width": 1,
|
|
"color": "#172033",
|
|
"font-size": 11,
|
|
"height": "data(visualSize)",
|
|
"label": "data(label)",
|
|
"text-background-color": "#ffffff",
|
|
"text-background-opacity": .85,
|
|
"text-background-padding": 2,
|
|
"text-wrap": "wrap",
|
|
"text-max-width": 130,
|
|
"width": "data(visualSize)"
|
|
}},
|
|
{selector: "node[layer = 'repository']", style: {"shape": "round-rectangle"}},
|
|
{selector: "node[layer = 'server']", style: {"shape": "pentagon"}},
|
|
{selector: "node[layer = 'deployment']", style: {"shape": "diamond"}},
|
|
{selector: "node[layer = 'service']", style: {"shape": "hexagon"}},
|
|
{selector: "node[layer = 'capability']", style: {"shape": "vee"}},
|
|
{selector: "node[layer = 'interface']", style: {"shape": "rectangle"}},
|
|
{selector: "node[layer = 'dependency']", style: {"shape": "triangle"}},
|
|
{selector: "node[layer = 'binding']", style: {"shape": "rhomboid"}},
|
|
{selector: "node[unresolved = true]", style: {"border-color": "#b45309", "border-style": "dashed", "border-width": 3}},
|
|
{selector: "node[lifecycle = 'registered-only']", style: {"border-color": "#be123c", "border-style": "dashed", "border-width": 3}},
|
|
{selector: "edge", style: {
|
|
"curve-style": "bezier",
|
|
"line-color": "#98a2b3",
|
|
"target-arrow-color": "#98a2b3",
|
|
"target-arrow-shape": "triangle",
|
|
"width": "data(edgeWidth)"
|
|
}},
|
|
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
|
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
|
{selector: "node.rule-highlight", style: {
|
|
"border-color": "#2563eb",
|
|
"border-width": 3,
|
|
"z-index": 3
|
|
}},
|
|
{selector: "edge.rule-highlight", style: {
|
|
"line-color": "#2563eb",
|
|
"target-arrow-color": "#2563eb",
|
|
"width": 5,
|
|
"z-index": 3
|
|
}},
|
|
{selector: "edge.hover", style: {
|
|
"label": "data(edgeType)",
|
|
"font-size": 10,
|
|
"text-background-color": "#ffffff",
|
|
"text-background-opacity": .9,
|
|
"text-background-padding": 2,
|
|
"z-index": 2
|
|
}},
|
|
{selector: ".display-blur", style: {"opacity": .24, "label": ""}},
|
|
{selector: ".display-blur.hover", style: {"opacity": .78, "label": "data(label)"}},
|
|
{selector: ".label-hidden", style: {"label": ""}},
|
|
{selector: ":selected", style: {"overlay-opacity": 0}}
|
|
]
|
|
});
|
|
refreshRuleBuilder();
|
|
renderRules();
|
|
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
|
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
|
cy.on("pan zoom resize render layoutstop", () => {
|
|
updateSelectionAnchor();
|
|
scheduleZoneOverlayUpdate();
|
|
});
|
|
cy.on("position", "node", () => {
|
|
updateSelectionAnchor();
|
|
scheduleZoneOverlayUpdate();
|
|
});
|
|
cy.on("mouseover", "node, edge", (event) => {
|
|
event.target.addClass("hover");
|
|
const data = event.target.data();
|
|
const nodeType = data.layer ? nodeTypeLabels[data.layer] || humanize(data.layer) : "";
|
|
popup.innerHTML = `<strong>${escapeHtml(data.name || data.label || data.id)}</strong><span class="meta">${escapeHtml(data.kind)} / ${escapeHtml(nodeType || data.edgeType || "")}</span>`;
|
|
popup.style.left = `${Math.min(event.renderedPosition.x + 14, canvas.clientWidth - 270)}px`;
|
|
popup.style.top = `${Math.max(10, event.renderedPosition.y + 14)}px`;
|
|
popup.style.display = "block";
|
|
});
|
|
cy.on("mouseout", "node, edge", (event) => {
|
|
event.target.removeClass("hover");
|
|
popup.style.display = "none";
|
|
});
|
|
const urlState = readUrlState();
|
|
if (urlState.profile && profiles.some((profile) => profile.id === urlState.profile)) {
|
|
currentProfileId = urlState.profile;
|
|
profileSelect.value = currentProfileId;
|
|
const profile = selectedProfile();
|
|
profileNameInput.value = profile ? profile.name : "";
|
|
if (profile) applyViewState(profile.state || {}, {skipLayout: true, skipUrl: true});
|
|
}
|
|
applyViewState(urlState, {skipUrl: true});
|
|
runLayout();
|
|
updateUrlState();
|
|
};
|
|
|
|
const handleFilterChange = () => {
|
|
focusSet = null;
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
};
|
|
[searchInput, reviewFilter, unresolvedFilter].forEach((control) => {
|
|
control.addEventListener("input", handleFilterChange);
|
|
});
|
|
nodeTypeFilter.addEventListener("change", handleFilterChange);
|
|
edgeTypeFilter.addEventListener("change", handleFilterChange);
|
|
modeSelect.addEventListener("input", () => {
|
|
activeMode = modeSelect.value || "full";
|
|
focusSet = null;
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
layoutSelect.addEventListener("input", () => {
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
runLayout();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
labelSelect.addEventListener("input", () => {
|
|
activeLabelMode = labelSelect.value || "auto";
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
updateLabelVisibility();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
zoneBoundaryToggle.addEventListener("input", () => {
|
|
selectedZoneId = "";
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
renderZoneOverlay();
|
|
if (!selected) renderMapOverview();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
zoneGroupSelect.addEventListener("input", () => {
|
|
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
|
|
selectedZoneId = "";
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
renderZoneOverlay();
|
|
if (!selected) renderMapOverview();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
zoneOverlay.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-zone-id]");
|
|
if (!button) return;
|
|
const zone = zoneSummaries.get(button.dataset.zoneId);
|
|
if (!zone) return;
|
|
event.stopPropagation();
|
|
selectedZoneId = zone.id;
|
|
renderZoneOverlay();
|
|
});
|
|
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
|
|
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
|
|
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
|
|
target: ruleTarget.value,
|
|
type: ruleType.value,
|
|
attribute: ruleAttribute.value,
|
|
}));
|
|
document.querySelector("[data-rule-action='save']").addEventListener("click", () => {
|
|
const rule = {
|
|
id: editingRuleId || `rule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
target: ruleTarget.value || "node",
|
|
type: ruleType.value || "",
|
|
attribute: ruleAttribute.value || "any",
|
|
value: ruleAttribute.value === "any" ? "" : ruleValue.value || "",
|
|
action: ruleAction.value || "highlight",
|
|
};
|
|
filterRules = editingRuleId
|
|
? filterRules.map((existing) => existing.id === editingRuleId ? rule : existing)
|
|
: [...filterRules, rule];
|
|
editingRuleId = "";
|
|
renderRules();
|
|
applyFilters({redrawOnRemove: true});
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
document.querySelector("[data-rule-action='cancel']").addEventListener("click", () => {
|
|
editingRuleId = "";
|
|
refreshRuleBuilder();
|
|
renderRules();
|
|
});
|
|
document.querySelector("[data-rule-action='reset']").addEventListener("click", () => {
|
|
filterRules = [];
|
|
editingRuleId = "";
|
|
refreshRuleBuilder();
|
|
renderRules();
|
|
applyFilters({redrawOnRemove: true});
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
ruleList.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-rule-list-action]");
|
|
if (!button) return;
|
|
const item = button.closest("[data-rule-id]");
|
|
const index = filterRules.findIndex((rule) => rule.id === item?.dataset?.ruleId);
|
|
if (index < 0) return;
|
|
const action = button.dataset.ruleListAction;
|
|
if (action === "edit") {
|
|
const rule = filterRules[index];
|
|
editingRuleId = rule.id;
|
|
refreshRuleBuilder(rule);
|
|
renderRules();
|
|
return;
|
|
}
|
|
if (action === "delete") {
|
|
filterRules = filterRules.filter((_, ruleIndex) => ruleIndex !== index);
|
|
} else if (action === "up" && index > 0) {
|
|
[filterRules[index - 1], filterRules[index]] = [filterRules[index], filterRules[index - 1]];
|
|
} else if (action === "down" && index < filterRules.length - 1) {
|
|
[filterRules[index + 1], filterRules[index]] = [filterRules[index], filterRules[index + 1]];
|
|
}
|
|
editingRuleId = "";
|
|
renderRules();
|
|
applyFilters({redrawOnRemove: true});
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
orientationActions.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-orientation-action]");
|
|
if (!button) return;
|
|
applyOrientationContext(button.dataset.orientationAction);
|
|
});
|
|
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
|
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
|
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
|
searchInput.value = "";
|
|
modeSelect.value = "full";
|
|
activeMode = "full";
|
|
labelSelect.value = "auto";
|
|
activeLabelMode = "auto";
|
|
zoneBoundaryToggle.checked = true;
|
|
zoneGroupSelect.value = "deploymentEnvironment";
|
|
activeZoneGrouping = "deploymentEnvironment";
|
|
selectedZoneId = "";
|
|
setCheckedValues(nodeTypeFilter);
|
|
setCheckedValues(edgeTypeFilter);
|
|
reviewFilter.value = "";
|
|
unresolvedFilter.value = "";
|
|
focusSet = null;
|
|
manualOverrides = {};
|
|
filterRules = [];
|
|
editingRuleId = "";
|
|
refreshRuleBuilder();
|
|
renderRules();
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters({redrawOnRemove: true});
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
|
|
manualOverrides = {};
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
document.querySelectorAll("[data-override]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
if (!selected) return;
|
|
manualOverrides[overrideKey(selected)] = button.dataset.override;
|
|
currentProfileId = "";
|
|
profileSelect.value = "";
|
|
applyFilters();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
updateUrlState();
|
|
});
|
|
});
|
|
profileSelect.addEventListener("input", () => {
|
|
currentProfileId = profileSelect.value;
|
|
const profile = selectedProfile();
|
|
profileNameInput.value = profile ? profile.name : "";
|
|
if (profile) applyViewState(profile.state || {});
|
|
if (!profile) updateUrlState();
|
|
updateProfileControls();
|
|
updateProfileSummary();
|
|
});
|
|
document.querySelectorAll("[data-profile-action]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
if (!supportsLocalProfiles()) return;
|
|
const action = button.dataset.profileAction;
|
|
if (action === "save") {
|
|
saveCurrentProfile(profileNameInput.value);
|
|
} else if (action === "duplicate") {
|
|
const profile = selectedProfile();
|
|
if (!profile) return;
|
|
saveCurrentProfile(`Copy of ${profile.name}`, {...profile, state: currentViewState()});
|
|
} else if (action === "delete") {
|
|
profiles = profiles.filter((profile) => profile.id !== currentProfileId);
|
|
currentProfileId = "";
|
|
persistProfiles();
|
|
renderProfiles();
|
|
updateUrlState();
|
|
} else if (action === "copy") {
|
|
const url = `${window.location.origin}${viewUrl(true)}`;
|
|
if (window.navigator?.clipboard?.writeText) await window.navigator.clipboard.writeText(url);
|
|
updateProfileSummary("Copied the current map state URL.");
|
|
}
|
|
});
|
|
});
|
|
document.querySelectorAll("[data-help]").forEach((target) => {
|
|
target.addEventListener("mouseenter", () => showHelp(target));
|
|
target.addEventListener("focus", () => showHelp(target));
|
|
target.addEventListener("mouseleave", hideHelp);
|
|
target.addEventListener("blur", hideHelp);
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") hideHelp();
|
|
});
|
|
if (!window.cytoscape) {
|
|
canvas.innerHTML = "<p class='notice'>Cytoscape.js could not be loaded.</p>";
|
|
return;
|
|
}
|
|
boot().catch((error) => {
|
|
canvas.innerHTML = `<p class='canvas-notice'>Could not load graph explorer data: ${escapeHtml(error.message)}</p>`;
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|