From 28511db90946089a7ea4f3d801f9ee69d5328269 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 19 May 2026 01:16:18 +0200 Subject: [PATCH] Add graph explorer filter rules --- railiance_fabric/graph_explorer_ui.py | 400 +++++++++++++++++- tests/test_graph_explorer.py | 7 + ...AB-WP-0009-graph-explorer-ui-refinement.md | 2 +- 3 files changed, 392 insertions(+), 17 deletions(-) diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 9a36c2d..4c841de 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -245,6 +245,50 @@ def graph_explorer_page() -> str: } .detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; } .detail-list li { overflow-wrap: anywhere; } + .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; @@ -274,6 +318,7 @@ def graph_explorer_page() -> str: .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; } } @@ -319,7 +364,7 @@ def graph_explorer_page() -> str:
- Profile + Profile
@@ -365,6 +410,46 @@ def graph_explorer_page() -> str: +
+
+ Rules +
+
+ + + + + +
+

Rules are applied top to bottom; later matching rules refine earlier ones.

+
+ + + +
+
    +
    +
    +

    Profile persistence unavailable for this host.

    @@ -372,10 +457,10 @@ def graph_explorer_page() -> str:
    - + - +
    @@ -405,6 +490,13 @@ def graph_explorer_page() -> str: 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"); @@ -432,6 +524,8 @@ def graph_explorer_page() -> str: let profilePersistence = "none"; let profiles = []; let currentProfileId = ""; + let filterRules = []; + let editingRuleId = ""; const escapeHtml = (value) => String(value ?? "") .replaceAll("&", "&").replaceAll("<", "<") @@ -524,6 +618,159 @@ def graph_explorer_page() -> str: }).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", + }; + + const ruleAttributeCandidates = { + node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle"], + edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity"], + }; + + const renderOptions = (select, options, current = "") => { + select.innerHTML = options.map((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) => ` +
  • +
    ${escapeHtml(ruleDescription(rule))}${index + 1}
    +
    + + + + +
    +
  • + `).join("") + : '
  • No rules yet.
  • '; + }; + + 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); @@ -608,6 +855,7 @@ def graph_explorer_page() -> str: edgeTypes: Array.from(selectedEdgeTypes()), review: reviewFilter.value, unresolved: unresolvedFilter.value, + rules: filterRules.map((rule) => ({...rule})), manualOverrides: {...manualOverrides}, }); @@ -658,7 +906,9 @@ def graph_explorer_page() -> str: if (state.review) params.set("review", state.review); if (state.unresolved) params.set("unresolved", state.unresolved); if (currentProfileId) params.set("profile", currentProfileId); - if (includeStateBlob || hasManualOverrides()) params.set("state", encodeStateBlob(state)); + if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) { + params.set("state", encodeStateBlob(state)); + } const query = params.toString(); return `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; }; @@ -684,6 +934,12 @@ def graph_explorer_page() -> str: 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"; focusSet = null; @@ -826,35 +1082,54 @@ def graph_explorer_page() -> str: return null; }; - const applyFilters = () => { + 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 override = manualOverrideFor(node); - if (override) state = override; + 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.style("display", state === "hide" ? "none" : "element"); + 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"; - if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide"; - const override = manualOverrideFor(edge); - if (override) state = override; + 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.style("display", state === "hide" ? "none" : "element"); + 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 hidden = cy.elements().length - visibleNodes - visibleEdges; + 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 = `Hidden ${hidden}`; + hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`; updateLabelVisibility(); updateSelectionAnchor(); + if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout(); }; const showDetails = (element) => { @@ -989,6 +1264,7 @@ def graph_explorer_page() -> str: 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} @@ -1006,7 +1282,7 @@ def graph_explorer_page() -> str: numIter: 1400, } : {name, padding: 48, animate: false}; - cy.layout(options).run(); + layoutElements.layout(options).run(); updateSelectionAnchor(); }; @@ -1098,6 +1374,17 @@ def graph_explorer_page() -> str: }}, {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, @@ -1112,6 +1399,8 @@ def graph_explorer_page() -> str: {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); @@ -1183,6 +1472,81 @@ def graph_explorer_page() -> str: updateProfileSummary(); updateUrlState(); }); + 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(); + }); 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", () => { @@ -1197,9 +1561,13 @@ def graph_explorer_page() -> str: unresolvedFilter.value = ""; focusSet = null; manualOverrides = {}; + filterRules = []; + editingRuleId = ""; + refreshRuleBuilder(); + renderRules(); currentProfileId = ""; profileSelect.value = ""; - applyFilters(); + applyFilters({redrawOnRemove: true}); updateProfileControls(); updateProfileSummary(); updateUrlState(); diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 02e85f5..d8a2067 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -209,10 +209,17 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert 'id="label-select"' in page assert 'id="node-type-filter"' in page assert 'id="edge-type-filter"' in page + assert 'id="rule-panel"' in page + assert 'id="rule-target"' in page + assert 'id="rule-list"' in page assert 'id="selection-anchor"' in page assert 'id="help-popup"' in page assert "updateSelectionAnchor" in page assert "updateLabelVisibility" in page + assert "ruleActionFor" in page + assert "ruleRemovalSignature" in page + assert "Remove and redraw" in page + assert "Rules are applied top to bottom" in page assert "showHelp" in page assert "label-hidden" in page assert "Loading graph..." in page diff --git a/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md b/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md index 7112e79..eb4982d 100644 --- a/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md +++ b/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md @@ -151,7 +151,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0009-T04 -status: todo +status: done priority: high state_hub_task_id: "1b446ac8-eeba-4de6-9a32-d7f980d70b93" ```