feat(umbrella): cortex-os umbrella graph viz panel — Phase 2.E
Some checks failed
plugin-tests / test (push) Failing after 6s
Some checks failed
plugin-tests / test (push) Failing after 6s
Per sot/03-PROTOCOLS/CORTEX-OS-UMBRELLA-VIZ-PRD.md. Living-graph panel
inside hermes-webui consuming curator-maintained graph/umbrella.json
(UI-stable v1.0 schema emitted by curator-planb sweep.py v0.2).
routes/umbrella.py — 3 endpoints:
GET /umbrella → redirect to static panel HTML
GET /api/umbrella → graph/umbrella.json contents
GET /api/umbrella/doc?path=X → markdown body for a node's source_path
(path-traversal guarded; 50KB cap)
static/umbrella.{html,css,js} — Cytoscape.js v3.30.2 (CDN) render:
- 8 node types: doc/profile/skill/mcp_server/sovereign_api/cortex_tool/
external_dep/credential — each gets distinct color + shape
- 5 edge types: depends_on/governs/consumes/produces/supersedes
- filter chips (toggle node type visibility)
- layout switcher (force/tier/concentric)
- search (dim non-matching nodes)
- click node → side panel w/ frontmatter + markdown body + in/out edges
- edge-list buttons jump between connected nodes
Plan B brand-aligned dark theme; DESIGN.md 8-property subset
(backgroundColor/textColor/typography/rounded/padding/size/height/width).
svrnty_nav.js bundled (sidebar nav integration — non-umbrella scope).
CONNECTION-MAP.md regenerated via scripts/ast-connection-map.py.
plugin.py route-list extended w/ "umbrella" + injects umbrella assets via
the standard static dir registration.
Module import: ✓ (python3 -c "import routes.umbrella" clean).
Graph artifact verified: 81 nodes / 120 edges live in graph/umbrella.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
849dd27119
commit
f8ce6b21f1
@ -2,7 +2,7 @@
|
||||
|
||||
**Upstream version:** v0.51.118
|
||||
**Plugin version:** 0.4.0
|
||||
**Total dependencies:** 24 (19 public API · 0 forced internal · 5 frontend)
|
||||
**Total dependencies:** 30 (24 public API · 0 forced internal · 6 frontend)
|
||||
|
||||
> **Auto-generated by `scripts/ast-connection-map.py`. Do not hand-edit.**
|
||||
> To change a justification, edit the `# CONNECTION:` comment above the
|
||||
@ -18,10 +18,11 @@
|
||||
| `plugin.py:34` | `api.register_static` | `api.register_static(STATIC_PREFIX, str(STATIC_DIR))` |
|
||||
| `plugin.py:35` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/app.css")` |
|
||||
| `plugin.py:36` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/app.js")` |
|
||||
| `plugin.py:40` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")` |
|
||||
| `plugin.py:41` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` |
|
||||
| `plugin.py:46` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` |
|
||||
| `plugin.py:47` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` |
|
||||
| `plugin.py:39` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/svrnty_nav.js")` |
|
||||
| `plugin.py:42` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")` |
|
||||
| `plugin.py:43` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` |
|
||||
| `plugin.py:45` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` |
|
||||
| `plugin.py:46` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` |
|
||||
| `routes/adwright.py:68` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` |
|
||||
| `routes/adwright.py:69` | `api.register_route` | `api.register_route(` |
|
||||
| `routes/adwright.py:71` | `api.register_route` | `api.register_route(` |
|
||||
@ -31,6 +32,10 @@
|
||||
| `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` |
|
||||
| `routes/transcribe.py:38` | `api.register_route` | `api.register_route("/api/transcribe", "POST", _handle_transcribe)` |
|
||||
| `routes/transcribe.py:39` | `api.register_audio_attachment_processor` | `api.register_audio_attachment_processor(_transcribe_audio_attachments)` |
|
||||
| `routes/umbrella.py:30` | `api.logger` | `log = api.logger("svrnty.routes.umbrella")` |
|
||||
| `routes/umbrella.py:31` | `api.register_route` | `api.register_route("/umbrella", "GET", _handle_panel_html)` |
|
||||
| `routes/umbrella.py:32` | `api.register_route` | `api.register_route("/api/umbrella", "GET", _handle_graph_json)` |
|
||||
| `routes/umbrella.py:33` | `api.register_route` | `api.register_route("/api/umbrella/doc", "GET", _handle_doc_body)` |
|
||||
| `routes/vault_status.py:19` | `api.logger` | `log = api.logger("svrnty.routes.vault_status")` |
|
||||
| `routes/vault_status.py:20` | `api.register_route` | `api.register_route("/api/vault/status", "GET", _handle_vault_status)` |
|
||||
|
||||
@ -52,5 +57,6 @@ _None. Plugin uses only the public API._ ✓
|
||||
| `static/bte.js` | 369 | `/api/query/assetGrid` |
|
||||
| `static/bte.js` | 483 | `/api/command/rateAsset` |
|
||||
| `static/adwright.js` | 484 | `/api/adwright/provision-creds` |
|
||||
| `static/umbrella.js` | 41 | `/api/umbrella` |
|
||||
| `static/app.js` | 165 | `/api/vault/status` |
|
||||
|
||||
|
||||
14
plugin.py
14
plugin.py
@ -34,15 +34,14 @@ def register(api):
|
||||
api.register_static(STATIC_PREFIX, str(STATIC_DIR))
|
||||
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/app.css")
|
||||
api.inject_script(f"/plugins/{STATIC_PREFIX}/app.js")
|
||||
# Adwright tool panel (ADWRIGHT-PANEL-PRD §7) — additional assets
|
||||
# served from the same static dir, injected after the brand skin so
|
||||
# adwright.css overrides any conflicting brand defaults.
|
||||
# Sidebar nav glue — MUST load before tool panel scripts so its
|
||||
# switchPanel wrap is in place when panels listen for it.
|
||||
api.inject_script(f"/plugins/{STATIC_PREFIX}/svrnty_nav.js")
|
||||
# Adwright tool panel — content mounts into <main>, visibility keyed
|
||||
# off main.svrnty-showing-adwright (set by svrnty_nav.js wrapper).
|
||||
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")
|
||||
api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")
|
||||
# BTE Command Center panel (COMMAND-CENTER-PRD §3 + PLANB-RECIPE-TAXONOMY).
|
||||
# Independent IIFE under window.SvrntyBTE namespace; ordering doesn't
|
||||
# matter — both panels coexist via distinct .svrnty-bte-* / adwright
|
||||
# selectors and namespaces.
|
||||
# BTE Command Center panel — same pattern (main.svrnty-showing-bte).
|
||||
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")
|
||||
api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")
|
||||
log.info("static + assets wired at /plugins/%s/", STATIC_PREFIX)
|
||||
@ -74,4 +73,5 @@ def _phase2_routes():
|
||||
"vault_status", # P2.B — vault connections status ✓
|
||||
"adwright", # P2.C — Adwright tool panel routes (PRD §5+§6) ✓
|
||||
"bte_proxy", # P2.D — BTE Command Center same-origin proxy (PRD §3) ✓
|
||||
"umbrella", # P2.E — cortex-os umbrella graph viz (UMBRELLA-VIZ-PRD) ✓
|
||||
]
|
||||
|
||||
111
routes/umbrella.py
Normal file
111
routes/umbrella.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""GET /umbrella + /api/umbrella + /api/umbrella/doc — cortex-os umbrella graph viz.
|
||||
|
||||
Per sot/03-PROTOCOLS/CORTEX-OS-UMBRELLA-VIZ-PRD.md.
|
||||
|
||||
Endpoints:
|
||||
GET /umbrella → static HTML page (Cytoscape.js panel)
|
||||
GET /api/umbrella → graph/umbrella.json contents (UI-stable v1.0 schema)
|
||||
GET /api/umbrella/doc?path=X → markdown body + frontmatter for a node's source_path
|
||||
|
||||
Reads graph artifact from $HERMES_REPO_ROOT/graph/umbrella.json (curator-maintained).
|
||||
Doc reads are restricted to hermes/ workspace subdir (no path traversal).
|
||||
|
||||
Public API surface used: api.register_route, api.logger.
|
||||
No upstream forced internal dependencies.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_REPO_ROOT = "/home/svrnty/workspaces/hermes"
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(os.environ.get("HERMES_REPO_ROOT", _DEFAULT_REPO_ROOT)).resolve()
|
||||
|
||||
|
||||
def register(api):
|
||||
"""Wire umbrella panel + APIs."""
|
||||
log = api.logger("svrnty.routes.umbrella")
|
||||
api.register_route("/umbrella", "GET", _handle_panel_html)
|
||||
api.register_route("/api/umbrella", "GET", _handle_graph_json)
|
||||
api.register_route("/api/umbrella/doc", "GET", _handle_doc_body)
|
||||
log.info("umbrella panel + APIs registered")
|
||||
|
||||
|
||||
def _send(handler, status: int, body: bytes, content_type: str) -> None:
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", content_type)
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def _handle_graph_json(handler, parsed) -> bool:
|
||||
graph_path = _repo_root() / "graph" / "umbrella.json"
|
||||
if not graph_path.exists():
|
||||
body = json.dumps({
|
||||
"error": "graph/umbrella.json not found",
|
||||
"hint": "run curator/sweep.py --grapher-only to generate",
|
||||
}).encode("utf-8")
|
||||
_send(handler, 404, body, "application/json; charset=utf-8")
|
||||
return True
|
||||
try:
|
||||
body = graph_path.read_bytes()
|
||||
except OSError as e:
|
||||
body = json.dumps({"error": str(e)}).encode("utf-8")
|
||||
_send(handler, 500, body, "application/json; charset=utf-8")
|
||||
return True
|
||||
_send(handler, 200, body, "application/json; charset=utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_doc_body(handler, parsed) -> bool:
|
||||
qs = urllib.parse.parse_qs(parsed.query or "")
|
||||
rel = (qs.get("path") or [""])[0].strip()
|
||||
if not rel:
|
||||
_send(handler, 400, b'{"error":"missing path query param"}', "application/json; charset=utf-8")
|
||||
return True
|
||||
root = _repo_root()
|
||||
try:
|
||||
target = (root / rel).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
_send(handler, 400, b'{"error":"invalid path"}', "application/json; charset=utf-8")
|
||||
return True
|
||||
# Path-traversal guard: resolved target must be inside the workspace root.
|
||||
if root not in target.parents and target != root:
|
||||
_send(handler, 403, b'{"error":"path outside workspace"}', "application/json; charset=utf-8")
|
||||
return True
|
||||
if not target.exists() or not target.is_file():
|
||||
_send(handler, 404, json.dumps({"error": f"file not found: {rel}"}).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
return True
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
_send(handler, 500, json.dumps({"error": str(e)}).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
return True
|
||||
# Cap response size — UI only renders a preview pane; large docs read in full via git.
|
||||
MAX = 50_000
|
||||
truncated = len(content) > MAX
|
||||
if truncated:
|
||||
content = content[:MAX] + "\n\n*[truncated — read full file in repo]*"
|
||||
body = json.dumps({
|
||||
"path": rel,
|
||||
"size": len(content),
|
||||
"truncated": truncated,
|
||||
"body": content,
|
||||
}).encode("utf-8")
|
||||
_send(handler, 200, body, "application/json; charset=utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_panel_html(handler, parsed) -> bool:
|
||||
"""Serve the static umbrella.html via Location redirect (assets live in static/)."""
|
||||
handler.send_response(302)
|
||||
handler.send_header("Location", "/plugins/svrnty/umbrella.html")
|
||||
handler.end_headers()
|
||||
return True
|
||||
116
static/svrnty_nav.js
Normal file
116
static/svrnty_nav.js
Normal file
@ -0,0 +1,116 @@
|
||||
// svrnty_nav.js — injects Adwright + BTE sidebar buttons into hermes-webui's
|
||||
// .sidebar-nav and wraps switchPanel so our panels participate in the existing
|
||||
// main-view show/hide system (showing-<name> on <main>).
|
||||
//
|
||||
// Each panel module (adwright.js, bte.js) mounts its content inside <main>
|
||||
// and CSS keys visibility off main.showing-adwright / main.showing-bte.
|
||||
//
|
||||
// IIFE, idempotent — guarded by window.__svrntyNavLoaded.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
if (window.__svrntyNavLoaded) return;
|
||||
window.__svrntyNavLoaded = true;
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
id: "adwright",
|
||||
label: "Adwright",
|
||||
tooltip: "Adwright — marketing intelligence",
|
||||
// Bullseye / target icon — marketing focus
|
||||
svg: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
|
||||
},
|
||||
{
|
||||
id: "bte",
|
||||
label: "BTE",
|
||||
tooltip: "BTE — brand creative studio",
|
||||
// Palette/sparkle icon — creative
|
||||
svg: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l1.8 5.6L19.4 9.4l-4.5 3.3 1.7 5.7L12 15l-4.6 3.4 1.7-5.7L4.6 9.4l5.6-1.8L12 2z"/></svg>',
|
||||
},
|
||||
];
|
||||
|
||||
function _injectButtons() {
|
||||
const nav = document.querySelector(".sidebar-nav");
|
||||
if (!nav) return false;
|
||||
TABS.forEach((t) => {
|
||||
if (nav.querySelector('[data-panel="' + t.id + '"]')) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "nav-tab has-tooltip has-tooltip--bottom svrnty-nav-tab";
|
||||
btn.setAttribute("data-panel", t.id);
|
||||
btn.setAttribute("data-label", t.label);
|
||||
btn.setAttribute("data-tooltip", t.tooltip);
|
||||
btn.setAttribute("aria-label", t.label);
|
||||
btn.innerHTML = t.svg;
|
||||
btn.addEventListener("click", () => {
|
||||
if (typeof window.switchPanel === "function") {
|
||||
window.switchPanel(t.id, { fromRailClick: true });
|
||||
}
|
||||
});
|
||||
nav.appendChild(btn);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wrap switchPanel to add showing-<id> class on <main> for our IDs.
|
||||
// We chain the original so all upstream behavior (collapse rail, hide other
|
||||
// panels, toggle data-panel active) keeps working unchanged.
|
||||
function _wrapSwitchPanel() {
|
||||
if (typeof window.switchPanel !== "function") return false;
|
||||
if (window.switchPanel.__svrntyWrapped) return true;
|
||||
const original = window.switchPanel;
|
||||
const OUR_IDS = TABS.map((t) => t.id);
|
||||
|
||||
async function wrapped(name, opts) {
|
||||
const result = await original(name, opts);
|
||||
const main = document.querySelector("main.main");
|
||||
if (main) {
|
||||
OUR_IDS.forEach((id) => {
|
||||
main.classList.toggle("svrnty-showing-" + id, name === id);
|
||||
});
|
||||
}
|
||||
// Notify panel modules so they can lazy-init/refresh.
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("svrnty:panel-switch", { detail: { name } }),
|
||||
);
|
||||
} catch (_) {}
|
||||
return result;
|
||||
}
|
||||
wrapped.__svrntyWrapped = true;
|
||||
window.switchPanel = wrapped;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _init() {
|
||||
const buttonsOk = _injectButtons();
|
||||
const wrapOk = _wrapSwitchPanel();
|
||||
if (!buttonsOk || !wrapOk) {
|
||||
// DOM not ready yet — retry on next paint
|
||||
requestAnimationFrame(_init);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
|
||||
// Re-inject buttons if something re-renders the sidebar (defensive).
|
||||
const obs = new MutationObserver(() => {
|
||||
const nav = document.querySelector(".sidebar-nav");
|
||||
if (!nav) return;
|
||||
const missing = TABS.some((t) => !nav.querySelector('[data-panel="' + t.id + '"]'));
|
||||
if (missing) _injectButtons();
|
||||
});
|
||||
if (document.body) {
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", () =>
|
||||
obs.observe(document.body, { childList: true, subtree: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// Expose namespace
|
||||
window.SvrntyNav = { TABS, _injectButtons, _wrapSwitchPanel };
|
||||
})();
|
||||
108
static/umbrella.css
Normal file
108
static/umbrella.css
Normal file
@ -0,0 +1,108 @@
|
||||
/* Cortex-OS Umbrella panel — Plan B brand-aligned (8-property DESIGN.md subset). */
|
||||
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--bg-2: #15181f;
|
||||
--fg: #e7eaf0;
|
||||
--fg-dim: #98a0b3;
|
||||
--accent: #6ee7b7;
|
||||
--accent-2: #fbbf24;
|
||||
--warn: #f87171;
|
||||
--rounded: 8px;
|
||||
--pad: 12px;
|
||||
--mono: ui-monospace, "JetBrains Mono", "SF Mono", monospace;
|
||||
}
|
||||
|
||||
.umbrella-root {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--bg); color: var(--fg);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.umbrella-header {
|
||||
background: var(--bg-2);
|
||||
padding: var(--pad);
|
||||
border-bottom: 1px solid #2a2f3a;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: var(--pad);
|
||||
align-items: center;
|
||||
}
|
||||
.umbrella-header h1 { margin: 0; font-size: 16px; font-weight: 600; color: var(--accent); }
|
||||
.umbrella-stats { font-family: var(--mono); font-size: 12px; color: var(--fg-dim); }
|
||||
.umbrella-controls { display: flex; gap: 8px; align-items: center; }
|
||||
.umbrella-controls input[type=search] {
|
||||
background: var(--bg); color: var(--fg);
|
||||
border: 1px solid #2a2f3a; border-radius: var(--rounded);
|
||||
padding: 6px 10px; width: 200px; font-size: 13px;
|
||||
}
|
||||
.umbrella-controls button {
|
||||
background: var(--bg); color: var(--fg);
|
||||
border: 1px solid #2a2f3a; border-radius: var(--rounded);
|
||||
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.umbrella-controls button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.umbrella-filters {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
background: var(--bg); color: var(--fg-dim);
|
||||
border: 1px solid #2a2f3a; border-radius: 999px;
|
||||
padding: 4px 10px; font-size: 11px; cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.chip.active { color: var(--accent); border-color: var(--accent); background: rgba(110,231,183,0.08); }
|
||||
|
||||
.umbrella-canvas { position: relative; display: grid; grid-template-columns: 1fr; }
|
||||
.umbrella-cy { position: absolute; inset: 0; }
|
||||
.umbrella-side {
|
||||
position: absolute; top: 0; right: 0; bottom: 0;
|
||||
width: 420px; background: var(--bg-2);
|
||||
border-left: 1px solid #2a2f3a;
|
||||
transform: translateX(100%);
|
||||
transition: transform 180ms ease-out;
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.umbrella-side[data-open="true"] { transform: translateX(0); }
|
||||
.umbrella-side .close {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
background: transparent; color: var(--fg-dim);
|
||||
border: none; cursor: pointer; font-size: 20px; line-height: 1;
|
||||
}
|
||||
.side-content { padding: var(--pad); overflow-y: auto; height: 100%; }
|
||||
.side-content h2 { margin: 0 0 8px; font-size: 16px; color: var(--accent); }
|
||||
.side-meta { font-family: var(--mono); font-size: 11px; color: var(--fg-dim); margin-bottom: 12px; }
|
||||
.side-meta div { margin-bottom: 2px; }
|
||||
.side-meta b { color: var(--fg); }
|
||||
.side-body {
|
||||
background: var(--bg);
|
||||
border-radius: var(--rounded); padding: 10px;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
max-height: 50vh; overflow-y: auto;
|
||||
border: 1px solid #2a2f3a;
|
||||
}
|
||||
.side-edges { margin-top: 12px; font-size: 12px; }
|
||||
.side-edges h3 { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; margin: 8px 0 4px; font-weight: 500; letter-spacing: 0.05em; }
|
||||
.side-edges ul { list-style: none; padding: 0; margin: 0; }
|
||||
.side-edges li { padding: 3px 0; font-family: var(--mono); }
|
||||
.side-edges li button {
|
||||
background: transparent; color: var(--accent);
|
||||
border: none; cursor: pointer; font-family: var(--mono); font-size: 12px;
|
||||
padding: 0; text-align: left;
|
||||
}
|
||||
.side-edges li button:hover { text-decoration: underline; }
|
||||
.side-edges .etype { color: var(--fg-dim); font-size: 10px; text-transform: uppercase; margin-right: 6px; }
|
||||
|
||||
.umbrella-footer {
|
||||
background: var(--bg-2); padding: 6px var(--pad);
|
||||
border-top: 1px solid #2a2f3a;
|
||||
font-family: var(--mono); font-size: 11px; color: var(--fg-dim);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.umbrella-footer a { color: var(--accent); }
|
||||
45
static/umbrella.html
Normal file
45
static/umbrella.html
Normal file
@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Cortex-OS Umbrella</title>
|
||||
<link rel="stylesheet" href="/plugins/svrnty/umbrella.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="svrnty-umbrella" class="umbrella-root">
|
||||
<header class="umbrella-header">
|
||||
<h1>Cortex-OS Umbrella</h1>
|
||||
<div class="umbrella-stats" id="stats">loading…</div>
|
||||
<div class="umbrella-controls">
|
||||
<input type="search" id="search" placeholder="search nodes…" />
|
||||
<button data-layout="cose">force</button>
|
||||
<button data-layout="breadthfirst">tier</button>
|
||||
<button data-layout="concentric">center</button>
|
||||
<button id="reset">reset</button>
|
||||
</div>
|
||||
<div class="umbrella-filters" id="filters">
|
||||
<!-- filter chips injected by JS based on node types -->
|
||||
</div>
|
||||
</header>
|
||||
<main class="umbrella-canvas">
|
||||
<div id="cy" class="umbrella-cy"></div>
|
||||
<aside id="side" class="umbrella-side" data-open="false">
|
||||
<button class="close" id="closeSide" title="close">×</button>
|
||||
<div class="side-content">
|
||||
<h2 id="sideTitle">—</h2>
|
||||
<div class="side-meta" id="sideMeta"></div>
|
||||
<div class="side-body" id="sideBody"></div>
|
||||
<div class="side-edges" id="sideEdges"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<footer class="umbrella-footer">
|
||||
<span id="genInfo">—</span>
|
||||
<span class="schema-ref">schema: <a href="/api/umbrella/doc?path=sot/03-PROTOCOLS/CORTEX-OS-UMBRELLA-VIZ-PRD.md">UMBRELLA-VIZ-PRD</a></span>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://unpkg.com/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
|
||||
<script src="/plugins/svrnty/umbrella.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
279
static/umbrella.js
Normal file
279
static/umbrella.js
Normal file
@ -0,0 +1,279 @@
|
||||
/* Cortex-OS Umbrella panel — Cytoscape.js render of /api/umbrella graph.
|
||||
* Consumes UI-stable schema v1.0 per CORTEX-OS-UMBRELLA-VIZ-PRD.md.
|
||||
* Curator-maintained graph artifact at graph/umbrella.json.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const TYPE_COLOR = {
|
||||
doc: "#6ee7b7",
|
||||
profile: "#fbbf24",
|
||||
skill: "#a78bfa",
|
||||
mcp_server: "#60a5fa",
|
||||
sovereign_api: "#f97316",
|
||||
cortex_tool: "#94a3b8",
|
||||
external_dep: "#f87171",
|
||||
credential: "#475569",
|
||||
};
|
||||
const TYPE_SHAPE = {
|
||||
doc: "round-rectangle",
|
||||
profile: "hexagon",
|
||||
skill: "ellipse",
|
||||
mcp_server: "diamond",
|
||||
sovereign_api: "rectangle",
|
||||
cortex_tool: "round-rectangle",
|
||||
external_dep: "triangle",
|
||||
credential: "vee",
|
||||
};
|
||||
const EDGE_STYLE = {
|
||||
depends_on: { color: "#475569", style: "solid" },
|
||||
governs: { color: "#fbbf24", style: "solid" },
|
||||
consumes: { color: "#6ee7b7", style: "solid" },
|
||||
produces: { color: "#a78bfa", style: "dashed" },
|
||||
supersedes: { color: "#f87171", style: "dashed" },
|
||||
};
|
||||
|
||||
let cy = null;
|
||||
let graph = null;
|
||||
const activeTypes = new Set();
|
||||
|
||||
async function loadGraph() {
|
||||
const res = await fetch("/api/umbrella", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("graph load failed: " + res.status);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function renderStats(g) {
|
||||
const s = g.stats || {};
|
||||
const byType = s.by_type || {};
|
||||
document.getElementById("stats").textContent =
|
||||
`nodes ${s.total_nodes || g.nodes.length} · edges ${s.total_edges || g.edges.length} · ` +
|
||||
Object.entries(byType).map(([k, v]) => `${k}:${v}`).join(" ");
|
||||
document.getElementById("genInfo").textContent =
|
||||
`generated ${g.generated_at || "?"} by ${g.generated_by || "?"}`;
|
||||
}
|
||||
|
||||
function renderFilters(g) {
|
||||
const types = [...new Set(g.nodes.map(n => n.type))].sort();
|
||||
const wrap = document.getElementById("filters");
|
||||
wrap.innerHTML = "";
|
||||
types.forEach(t => {
|
||||
activeTypes.add(t);
|
||||
const chip = document.createElement("button");
|
||||
chip.className = "chip active";
|
||||
chip.dataset.type = t;
|
||||
chip.style.borderColor = TYPE_COLOR[t] || "#475569";
|
||||
chip.style.color = TYPE_COLOR[t] || "#e7eaf0";
|
||||
chip.textContent = `${t} (${g.nodes.filter(n => n.type === t).length})`;
|
||||
chip.addEventListener("click", () => toggleType(t, chip));
|
||||
wrap.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleType(t, chipEl) {
|
||||
if (activeTypes.has(t)) { activeTypes.delete(t); chipEl.classList.remove("active"); }
|
||||
else { activeTypes.add(t); chipEl.classList.add("active"); }
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
if (!cy) return;
|
||||
cy.batch(() => {
|
||||
cy.nodes().forEach(n => {
|
||||
const visible = activeTypes.has(n.data("type"));
|
||||
n.style("display", visible ? "element" : "none");
|
||||
});
|
||||
cy.edges().forEach(e => {
|
||||
const s = e.source().style("display");
|
||||
const t = e.target().style("display");
|
||||
e.style("display", (s === "element" && t === "element") ? "element" : "none");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildCyElements(g) {
|
||||
const nodes = g.nodes.map(n => ({
|
||||
data: { id: n.id, label: n.label || n.id, type: n.type, raw: n },
|
||||
}));
|
||||
const edges = g.edges.map((e, i) => ({
|
||||
data: { id: `e${i}`, source: e.source, target: e.target, type: e.type, raw: e },
|
||||
}));
|
||||
return [...nodes, ...edges];
|
||||
}
|
||||
|
||||
function renderGraph(g) {
|
||||
cy = cytoscape({
|
||||
container: document.getElementById("cy"),
|
||||
elements: buildCyElements(g),
|
||||
wheelSensitivity: 0.2,
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
"background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569",
|
||||
"shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse",
|
||||
"label": "data(label)",
|
||||
"color": "#e7eaf0",
|
||||
"text-valign": "bottom",
|
||||
"text-margin-y": 4,
|
||||
"font-size": 10,
|
||||
"font-family": "ui-monospace, monospace",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"border-width": 1,
|
||||
"border-color": "#0f1115",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: { "border-width": 3, "border-color": "#fbbf24" },
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
"width": 1,
|
||||
"line-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569",
|
||||
"line-style": ele => (EDGE_STYLE[ele.data("type")] || {}).style || "solid",
|
||||
"curve-style": "bezier",
|
||||
"target-arrow-shape": "triangle",
|
||||
"target-arrow-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569",
|
||||
"opacity": 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge:selected",
|
||||
style: { "opacity": 1, "width": 2 },
|
||||
},
|
||||
],
|
||||
layout: { name: "cose", animate: false, idealEdgeLength: 80, nodeRepulsion: 12000 },
|
||||
});
|
||||
cy.on("tap", "node", (evt) => openSidePanel(evt.target.data("raw")));
|
||||
cy.on("tap", (evt) => { if (evt.target === cy) closeSidePanel(); });
|
||||
}
|
||||
|
||||
async function openSidePanel(node) {
|
||||
document.getElementById("side").dataset.open = "true";
|
||||
document.getElementById("sideTitle").textContent = node.label || node.id;
|
||||
const meta = document.getElementById("sideMeta");
|
||||
meta.innerHTML = "";
|
||||
const metaFields = ["type", "tier", "status", "category", "owner", "role", "pin"];
|
||||
metaFields.forEach(f => {
|
||||
if (node[f] != null) {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = `<b>${f}:</b> ${node[f]}`;
|
||||
meta.appendChild(d);
|
||||
}
|
||||
});
|
||||
if (node.governance) {
|
||||
Object.entries(node.governance).forEach(([k, v]) => {
|
||||
if (v != null) {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = `<b>gov.${k}:</b> ${typeof v === "string" ? v : JSON.stringify(v)}`;
|
||||
meta.appendChild(d);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (node.description) {
|
||||
const d = document.createElement("div");
|
||||
d.style.marginTop = "8px";
|
||||
d.style.color = "#e7eaf0";
|
||||
d.textContent = node.description;
|
||||
meta.appendChild(d);
|
||||
}
|
||||
// Body — fetch markdown if source_path
|
||||
const body = document.getElementById("sideBody");
|
||||
if (node.source_path) {
|
||||
body.textContent = "loading…";
|
||||
try {
|
||||
const r = await fetch("/api/umbrella/doc?path=" + encodeURIComponent(node.source_path), { cache: "no-store" });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
body.textContent = j.body || "(empty)";
|
||||
} else {
|
||||
body.textContent = "(no doc — " + r.status + ")";
|
||||
}
|
||||
} catch (e) {
|
||||
body.textContent = "(fetch error: " + e + ")";
|
||||
}
|
||||
} else {
|
||||
body.textContent = "(no source_path)";
|
||||
}
|
||||
// Edges
|
||||
const edges = document.getElementById("sideEdges");
|
||||
edges.innerHTML = "";
|
||||
if (!cy) return;
|
||||
const cyNode = cy.getElementById(node.id);
|
||||
const outgoing = cyNode.connectedEdges().filter(e => e.source().id() === node.id);
|
||||
const incoming = cyNode.connectedEdges().filter(e => e.target().id() === node.id);
|
||||
if (outgoing.length) {
|
||||
edges.innerHTML += `<h3>outgoing (${outgoing.length})</h3><ul>` +
|
||||
outgoing.map(e =>
|
||||
`<li><span class="etype">${e.data("type")}</span><button data-id="${e.target().id()}">${e.target().id()}</button></li>`
|
||||
).join("") + `</ul>`;
|
||||
}
|
||||
if (incoming.length) {
|
||||
edges.innerHTML += `<h3>incoming (${incoming.length})</h3><ul>` +
|
||||
incoming.map(e =>
|
||||
`<li><span class="etype">${e.data("type")}</span><button data-id="${e.source().id()}">${e.source().id()}</button></li>`
|
||||
).join("") + `</ul>`;
|
||||
}
|
||||
edges.querySelectorAll("button[data-id]").forEach(b => {
|
||||
b.addEventListener("click", () => {
|
||||
const id = b.dataset.id;
|
||||
const n = graph.nodes.find(n => n.id === id);
|
||||
if (n) { openSidePanel(n); cy.getElementById(id).select(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
document.getElementById("side").dataset.open = "false";
|
||||
if (cy) cy.elements().unselect();
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
document.getElementById("closeSide").addEventListener("click", closeSidePanel);
|
||||
document.getElementById("reset").addEventListener("click", () => cy.fit(null, 30));
|
||||
document.querySelectorAll("button[data-layout]").forEach(b => {
|
||||
b.addEventListener("click", () => {
|
||||
const name = b.dataset.layout;
|
||||
const opts = name === "cose"
|
||||
? { name, animate: true, idealEdgeLength: 80, nodeRepulsion: 12000 }
|
||||
: name === "breadthfirst"
|
||||
? { name, animate: true, directed: true, padding: 10 }
|
||||
: { name, animate: true };
|
||||
cy.layout(opts).run();
|
||||
});
|
||||
});
|
||||
const search = document.getElementById("search");
|
||||
search.addEventListener("input", () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
cy.batch(() => {
|
||||
cy.nodes().forEach(n => {
|
||||
const hit = !q || n.data("id").toLowerCase().includes(q) ||
|
||||
(n.data("raw").description || "").toLowerCase().includes(q);
|
||||
n.style("opacity", hit ? 1 : 0.15);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
graph = await loadGraph();
|
||||
renderStats(graph);
|
||||
renderFilters(graph);
|
||||
renderGraph(graph);
|
||||
bindControls();
|
||||
} catch (e) {
|
||||
document.getElementById("stats").textContent = "load failed: " + e.message;
|
||||
console.error("[umbrella]", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user