From 0a365515e91dcc61c87cd1dfaff5bbaaa3f8ee38 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 4 Mar 2026 21:53:15 +0100 Subject: [PATCH] Enhance graph explorer: widen inspector, redesign index, add search suggestions - Widen code inspector panel from 450px to 550px for better readability - Redesign graph index landing page with polished repo cards showing name, git URL, branch, findings count, and relative update time - Add search suggestions dropdown in graph explorer that appears on typing >= 2 chars, showing node name, kind badge, and file path - Add full graph explorer styles matching Obsidian Control dark theme Co-Authored-By: Claude Opus 4.6 --- compliance-dashboard/assets/main.css | 1558 +++++++++++++++-- .../src/pages/graph_explorer.rs | 426 ++++- compliance-dashboard/src/pages/graph_index.rs | 89 +- 3 files changed, 1884 insertions(+), 189 deletions(-) diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index d2c9218..37b1fbc 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -1,38 +1,128 @@ +/* ═══════════════════════════════════════════════════════════════ + OBSIDIAN CONTROL — Compliance Scanner Dashboard + A precision security operations center aesthetic + ═══════════════════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:ital,wght@0,400;0,500;0,600;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap'); + +/* ── Design Tokens ── */ :root { - --sidebar-width: 260px; + --sidebar-width: 264px; --header-height: 56px; - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-card: #1e293b; - --text-primary: #f1f5f9; - --text-secondary: #94a3b8; - --accent: #38bdf8; - --accent-hover: #7dd3fc; - --border: #334155; - --danger: #ef4444; - --warning: #f59e0b; - --success: #22c55e; - --info: #3b82f6; + + /* Surfaces */ + --bg-primary: #060a13; + --bg-secondary: #0b1120; + --bg-card: rgba(12, 20, 38, 0.75); + --bg-card-solid: #0c1426; + --bg-card-hover: #101c34; + --bg-elevated: #111b30; + + /* Text */ + --text-primary: #e4eaf4; + --text-secondary: #5e7291; + --text-tertiary: #3d506b; + + /* Accent */ + --accent: #00c8ff; + --accent-hover: #40d8ff; + --accent-muted: rgba(0, 200, 255, 0.12); + --accent-glow: 0 0 20px rgba(0, 200, 255, 0.15); + + /* Borders */ + --border: #162038; + --border-bright: #1e3050; + --border-accent: rgba(0, 200, 255, 0.2); + + /* Status */ + --danger: #ff3b5c; + --danger-bg: rgba(255, 59, 92, 0.1); + --warning: #ffb020; + --warning-bg: rgba(255, 176, 32, 0.1); + --success: #00e676; + --success-bg: rgba(0, 230, 118, 0.1); + --info: #448aff; + --info-bg: rgba(68, 138, 255, 0.1); + --orange: #ff8a3d; + --orange-bg: rgba(255, 138, 61, 0.1); + + /* Typography */ + --font-display: 'Outfit', -apple-system, sans-serif; + --font-body: 'DM Sans', -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Radii */ + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + /* Transitions */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); } -* { + +/* ── Reset & Base ── */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.6; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; } +a { + color: var(--accent); + text-decoration: none; + transition: color 0.15s; +} + +a:hover { + color: var(--accent-hover); +} + +h1, h2, h3, h4 { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.25; +} + +code { + font-family: var(--font-mono); + font-size: 0.9em; +} + +::selection { + background: var(--accent); + color: var(--bg-primary); +} + + +/* ── App Shell ── */ + .app-shell { display: flex; min-height: 100vh; } + +/* ── Sidebar ── */ + .sidebar { width: var(--sidebar-width); background: var(--bg-secondary); @@ -45,239 +135,601 @@ body { bottom: 0; z-index: 40; overflow-y: auto; + overflow-x: hidden; + transition: width 0.2s ease; +} + +.sidebar.collapsed { + width: 60px; +} + +.sidebar.collapsed + .main-content { + margin-left: 60px; +} + +.sidebar::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background: linear-gradient( + to bottom, + transparent, + var(--accent-muted) 30%, + var(--accent-muted) 70%, + transparent + ); + pointer-events: none; } .sidebar-header { - padding: 20px; + padding: 22px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; + position: relative; +} + +.sidebar-header::after { + content: ''; + position: absolute; + bottom: -1px; + left: 20px; + right: 20px; + height: 1px; + background: linear-gradient(to right, var(--accent-muted), transparent); +} + +.sidebar-header svg { + color: var(--accent); + filter: drop-shadow(0 0 8px rgba(0, 200, 255, 0.3)); } .sidebar-header h1 { - font-size: 16px; + font-family: var(--font-display); + font-size: 15px; font-weight: 700; color: var(--text-primary); + letter-spacing: -0.01em; } .sidebar-nav { - padding: 12px 8px; + padding: 12px 10px; flex: 1; + display: flex; + flex-direction: column; + gap: 2px; } .nav-item { display: flex; align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 8px; + gap: 11px; + padding: 9px 14px; + border-radius: var(--radius); color: var(--text-secondary); text-decoration: none; - font-size: 14px; + font-family: var(--font-body); + font-size: 13.5px; font-weight: 500; - transition: all 0.15s; + transition: all 0.2s var(--ease-out); cursor: pointer; + position: relative; + border: 1px solid transparent; +} + +.nav-item svg { + opacity: 0.6; + transition: opacity 0.2s; + flex-shrink: 0; } .nav-item:hover { - background: rgba(56, 189, 248, 0.1); + background: rgba(0, 200, 255, 0.06); color: var(--text-primary); + border-color: var(--border); +} + +.nav-item:hover svg { + opacity: 0.9; } .nav-item.active { - background: rgba(56, 189, 248, 0.15); + background: var(--accent-muted); + color: var(--accent); + border-color: var(--border-accent); + font-weight: 600; +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 18px; + background: var(--accent); + border-radius: 0 4px 4px 0; + box-shadow: 0 0 10px rgba(0, 200, 255, 0.4); +} + +.nav-item.active svg { + opacity: 1; color: var(--accent); } +.sidebar-footer { + padding: 14px 20px; + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-tertiary); + letter-spacing: 0.02em; +} + +.sidebar-toggle { + background: none; + border: none; + border-top: 1px solid var(--border); + color: var(--text-tertiary); + padding: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s, background 0.2s; +} + +.sidebar-toggle:hover { + color: var(--accent); + background: rgba(0, 200, 255, 0.06); +} + +.sidebar.collapsed .sidebar-header { + padding: 22px 0; + justify-content: center; +} + +.sidebar.collapsed .sidebar-nav { + padding: 12px 6px; +} + +.sidebar.collapsed .nav-item { + justify-content: center; + padding: 9px; +} + +.sidebar.collapsed .nav-item.active::before { + left: 0; +} + +/* ── Main Content ── */ + .main-content { margin-left: var(--sidebar-width); flex: 1; - padding: 24px 32px; + padding: 28px 36px; + transition: margin-left 0.2s ease; min-height: 100vh; + background-image: radial-gradient(circle at 1px 1px, rgba(22, 32, 56, 0.5) 1px, transparent 0); + background-size: 28px 28px; + position: relative; } + +/* ── Page Header ── */ + .page-header { - margin-bottom: 24px; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.page-header::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 80px; + height: 2px; + background: var(--accent); + border-radius: 1px; + box-shadow: var(--accent-glow); } .page-header h2 { - font-size: 24px; - font-weight: 700; + font-family: var(--font-display); + font-size: 26px; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.02em; } .page-header p { color: var(--text-secondary); - margin-top: 4px; + margin-top: 5px; + font-size: 14px; } + +/* ── Stat Cards ── */ + .stat-cards { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 16px; - margin-bottom: 24px; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; + margin-bottom: 28px; } .stat-card { background: var(--bg-card); + backdrop-filter: blur(12px); border: 1px solid var(--border); - border-radius: 12px; - padding: 20px; + border-radius: var(--radius-lg); + padding: 20px 22px; + position: relative; + overflow: hidden; + transition: all 0.25s var(--ease-out); } -.stat-card .label { - font-size: 12px; +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent), transparent); + opacity: 0.5; +} + +.stat-card:hover { + border-color: var(--border-bright); + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.stat-card .label, +.stat-card .stat-label { + font-family: var(--font-body); + font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; color: var(--text-secondary); margin-bottom: 8px; } -.stat-card .value { - font-size: 28px; - font-weight: 700; +.stat-card .value, +.stat-card .stat-value { + font-family: var(--font-mono); + font-size: 30px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1; } + +/* ── Cards ── */ + .card { background: var(--bg-card); + backdrop-filter: blur(12px); border: 1px solid var(--border); - border-radius: 12px; - padding: 20px; + border-radius: var(--radius-lg); + padding: 22px; margin-bottom: 16px; + position: relative; + transition: border-color 0.2s; +} + +.card:hover { + border-color: var(--border-bright); } .card-header { - font-size: 16px; + font-family: var(--font-display); + font-size: 15px; font-weight: 600; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border); + color: var(--text-primary); + letter-spacing: -0.01em; } + +/* ── Tables ── */ + .table-wrapper { overflow-x: auto; + margin: -2px; + padding: 2px; } table { width: 100%; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; +} + +thead tr { + position: relative; } th { text-align: left; - padding: 12px 16px; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); - border-bottom: 1px solid var(--border); + padding: 10px 16px; + font-family: var(--font-body); + font-size: 11px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-tertiary); + border-bottom: 1px solid var(--border-bright); + white-space: nowrap; + position: sticky; + top: 0; + background: var(--bg-card-solid); } td { - padding: 12px 16px; + padding: 11px 16px; border-bottom: 1px solid var(--border); - font-size: 14px; + font-size: 13.5px; + color: var(--text-primary); + vertical-align: middle; } -tr:hover { - background: rgba(56, 189, 248, 0.05); +tbody tr { + transition: background-color 0.15s; + position: relative; } +tbody tr:hover { + background: rgba(0, 200, 255, 0.03); +} + +tbody tr:last-child td { + border-bottom: none; +} + + +/* ── Badges ── */ + .badge { display: inline-flex; align-items: center; - padding: 2px 10px; - border-radius: 9999px; - font-size: 12px; - font-weight: 600; + padding: 3px 10px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + white-space: nowrap; + border: 1px solid transparent; } -.badge-critical { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } -.badge-high { background: rgba(249, 115, 22, 0.2); color: #fdba74; } -.badge-medium { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } -.badge-low { background: rgba(34, 197, 94, 0.2); color: #86efac; } -.badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +.badge-critical { + background: var(--danger-bg); + color: #ff6b82; + border-color: rgba(255, 59, 92, 0.25); + box-shadow: 0 0 10px rgba(255, 59, 92, 0.08); +} + +.badge-high { + background: var(--orange-bg); + color: #ffaa6b; + border-color: rgba(255, 138, 61, 0.25); +} + +.badge-medium { + background: var(--warning-bg); + color: #ffc85c; + border-color: rgba(255, 176, 32, 0.2); +} + +.badge-low { + background: var(--success-bg); + color: #5cffaa; + border-color: rgba(0, 230, 118, 0.2); +} + +.badge-info { + background: var(--info-bg); + color: #7aabff; + border-color: rgba(68, 138, 255, 0.2); +} + +.badge-danger { + background: var(--danger-bg); + color: #ff6b82; + border-color: rgba(255, 59, 92, 0.25); +} + +.badge-success { + background: var(--success-bg); + color: #5cffaa; + border-color: rgba(0, 230, 118, 0.2); +} + + +/* ── Buttons ── */ .btn { display: inline-flex; align-items: center; - gap: 6px; - padding: 8px 16px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; + justify-content: center; + gap: 7px; + padding: 8px 18px; + border-radius: var(--radius); + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; border: none; cursor: pointer; - transition: all 0.15s; + transition: all 0.2s var(--ease-out); + white-space: nowrap; + position: relative; + letter-spacing: 0.01em; } .btn-primary { background: var(--accent); - color: #0f172a; + color: var(--bg-primary); + box-shadow: 0 0 16px rgba(0, 200, 255, 0.2); } .btn-primary:hover { background: var(--accent-hover); + box-shadow: 0 0 24px rgba(0, 200, 255, 0.3); + transform: translateY(-1px); } .btn-ghost { background: transparent; color: var(--text-secondary); - border: 1px solid var(--border); + border: 1px solid var(--border-bright); } .btn-ghost:hover { color: var(--text-primary); - border-color: var(--text-secondary); + border-color: var(--accent); + background: var(--accent-muted); } +.btn-secondary { + background: transparent; + color: var(--accent); + border: 1px solid var(--border-accent); +} + +.btn-secondary:hover { + background: var(--accent-muted); + border-color: var(--accent); +} + +.btn-sm { + padding: 5px 12px; + font-size: 12px; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-bright); +} + +.btn-sm:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-muted); +} + +.btn:active { + transform: scale(0.97); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + + +/* ── Code Blocks ── */ + .code-block { - background: #0d1117; + background: #050a12; border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; - font-family: "JetBrains Mono", "Fira Code", monospace; - font-size: 13px; - line-height: 1.6; + border-radius: var(--radius); + padding: 18px; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.7; overflow-x: auto; white-space: pre; + color: var(--text-primary); } + +/* ── Pagination ── */ + .pagination { display: flex; align-items: center; justify-content: center; - gap: 8px; - margin-top: 16px; + gap: 12px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--border); } +.pagination span { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-tertiary); + letter-spacing: 0.02em; +} + + +/* ── Filter Bar ── */ + .filter-bar { display: flex; - gap: 12px; - margin-bottom: 16px; + gap: 10px; + margin-bottom: 18px; flex-wrap: wrap; } .filter-bar select, .filter-bar input { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 12px; + background: var(--bg-card-solid); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + padding: 8px 14px; color: var(--text-primary); - font-size: 14px; + font-family: var(--font-body); + font-size: 13px; + transition: border-color 0.2s; + cursor: pointer; + -webkit-appearance: none; + appearance: none; } +.filter-bar select { + padding-right: 32px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235e7291' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +.filter-bar select:hover, +.filter-bar input:hover, +.filter-bar select:focus, +.filter-bar input:focus { + border-color: var(--accent); + outline: none; +} + + +/* ── Forms ── */ + .form-group { - margin-bottom: 16px; + margin-bottom: 18px; } .form-group label { display: block; - font-size: 14px; - font-weight: 500; - margin-bottom: 6px; + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 7px; color: var(--text-secondary); } @@ -285,22 +737,111 @@ tr:hover { .form-group select { width: 100%; background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 8px; + border: 1px solid var(--border-bright); + border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary); + font-family: var(--font-body); font-size: 14px; + transition: border-color 0.2s, box-shadow 0.2s; } +.form-group input:focus, +.form-group select:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-muted); +} + +.form-group input::placeholder { + color: var(--text-tertiary); +} + + +/* ── Loading State ── */ + .loading { display: flex; align-items: center; justify-content: center; - padding: 40px; - color: var(--text-secondary); + padding: 48px; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-tertiary); + letter-spacing: 0.03em; } -/* Toast notifications */ +.loading::before { + content: ''; + width: 16px; + height: 16px; + border: 2px solid var(--border-bright); + border-top-color: var(--accent); + border-radius: 50%; + margin-right: 12px; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + + +/* ── Severity Chart ── */ + +.severity-chart { + display: flex; + gap: 8px; + align-items: flex-end; + height: 200px; + padding: 16px 0; +} + +.severity-bar { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + height: 100%; + justify-content: flex-end; +} + +.severity-bar-count { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.severity-bar-fill { + width: 100%; + border-radius: 4px 4px 0 0; + min-height: 4px; + transition: height 0.6s var(--ease-out); + position: relative; +} + +.severity-bar-fill::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(to top, transparent, rgba(255,255,255,0.1)); +} + +.severity-bar-label { + font-family: var(--font-body); + font-size: 11px; + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + + +/* ── Toast Notifications ── */ + .toast-container { position: fixed; top: 20px; @@ -317,32 +858,37 @@ tr:hover { align-items: center; justify-content: space-between; gap: 12px; - min-width: 280px; - max-width: 420px; + min-width: 300px; + max-width: 440px; padding: 12px 16px; - border-radius: 8px; - font-size: 14px; + border-radius: var(--radius); + font-family: var(--font-body); + font-size: 13px; font-weight: 500; pointer-events: auto; - animation: toast-in 0.3s ease-out; + animation: toast-in 0.35s var(--ease-spring); + backdrop-filter: blur(16px); } .toast-success { - background: rgba(34, 197, 94, 0.15); - border: 1px solid var(--success); - color: #86efac; + background: rgba(0, 230, 118, 0.1); + border: 1px solid rgba(0, 230, 118, 0.3); + color: #5cffaa; + box-shadow: 0 4px 20px rgba(0, 230, 118, 0.08); } .toast-error { - background: rgba(239, 68, 68, 0.15); - border: 1px solid var(--danger); - color: #fca5a5; + background: rgba(255, 59, 92, 0.1); + border: 1px solid rgba(255, 59, 92, 0.3); + color: #ff6b82; + box-shadow: 0 4px 20px rgba(255, 59, 92, 0.08); } .toast-info { - background: rgba(59, 130, 246, 0.15); - border: 1px solid var(--info); - color: #93c5fd; + background: rgba(68, 138, 255, 0.1); + border: 1px solid rgba(68, 138, 255, 0.3); + color: #7aabff; + box-shadow: 0 4px 20px rgba(68, 138, 255, 0.08); } .toast-dismiss { @@ -351,9 +897,10 @@ tr:hover { color: inherit; font-size: 18px; cursor: pointer; - opacity: 0.7; + opacity: 0.5; padding: 0 4px; line-height: 1; + transition: opacity 0.15s; } .toast-dismiss:hover { @@ -362,29 +909,628 @@ tr:hover { @keyframes toast-in { from { - transform: translateX(100%); + transform: translateX(100%) scale(0.95); opacity: 0; } to { - transform: translateX(0); + transform: translateX(0) scale(1); opacity: 1; } } -/* Button click animation + disabled */ -.btn:active { - transform: scale(0.95); + +/* ── Stagger Animation for Cards ── */ + +.stat-cards > .stat-card { + animation: card-in 0.5s var(--ease-out) both; } -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; +.stat-cards > .stat-card:nth-child(1) { animation-delay: 0s; } +.stat-cards > .stat-card:nth-child(2) { animation-delay: 0.04s; } +.stat-cards > .stat-card:nth-child(3) { animation-delay: 0.08s; } +.stat-cards > .stat-card:nth-child(4) { animation-delay: 0.12s; } +.stat-cards > .stat-card:nth-child(5) { animation-delay: 0.16s; } +.stat-cards > .stat-card:nth-child(6) { animation-delay: 0.20s; } +.stat-cards > .stat-card:nth-child(7) { animation-delay: 0.24s; } +.stat-cards > .stat-card:nth-child(8) { animation-delay: 0.28s; } +.stat-cards > .stat-card:nth-child(9) { animation-delay: 0.32s; } + +@keyframes card-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } } + +/* ── Scrollbar ── */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-bright); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + + +/* ── Graph Explorer ── */ + +.graph-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.graph-search-bar { + flex: 1; + max-width: 400px; +} + +.graph-search-bar input { + width: 100%; + background: var(--bg-card-solid); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + padding: 8px 14px; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + transition: border-color 0.2s; +} + +.graph-search-bar input:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-muted); +} + +.graph-search-bar input::placeholder { + color: var(--text-tertiary); +} + +.graph-explorer-layout { + display: grid; + grid-template-columns: 250px 1fr; + gap: 0; + height: calc(100vh - 200px); + min-height: 500px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg-card); +} + +.graph-explorer-layout.inspector-open { + grid-template-columns: 250px 550px 1fr; +} + +/* File Tree Panel */ +.file-tree-panel { + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.file-tree-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.file-tree-header h4 { + font-family: var(--font-display); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); +} + +.file-tree-search { + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} + +.file-tree-search input { + width: 100%; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 5px 10px; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 12px; + transition: border-color 0.2s; +} + +.file-tree-search input:focus { + border-color: var(--accent); + outline: none; +} + +.file-tree-search input::placeholder { + color: var(--text-tertiary); +} + +.file-tree-content { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.file-tree-empty { + padding: 20px 16px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} + +.file-tree-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + cursor: pointer; + font-size: 12px; + color: var(--text-secondary); + transition: all 0.15s; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-tree-item:hover { + background: rgba(0, 200, 255, 0.06); + color: var(--text-primary); +} + +.file-tree-icon { + flex-shrink: 0; + width: 14px; + text-align: center; + font-size: 10px; + color: var(--text-tertiary); +} + +.file-tree-dir .file-tree-icon { + color: var(--accent); +} + +.file-tree-file .file-tree-icon { + color: var(--text-tertiary); + font-size: 8px; +} + +.file-tree-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-tree-dir .file-tree-name { + font-weight: 500; + color: var(--text-primary); +} + +.file-tree-badge { + flex-shrink: 0; + background: var(--accent-muted); + color: var(--accent); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + padding: 1px 5px; + border-radius: 4px; +} + +/* Graph Canvas Panel */ +.graph-canvas-panel { + display: flex; + flex-direction: column; + background: var(--bg-primary); + position: relative; + overflow: hidden; +} + +.graph-canvas { + flex: 1; + width: 100%; + min-height: 0; +} + +/* ── Graph Stabilization Overlay ── */ + +.graph-stab-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(ellipse at center, rgba(6, 10, 19, 0.88) 0%, rgba(6, 10, 19, 0.96) 100%); + backdrop-filter: blur(6px); + transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.8s; +} + +.graph-stab-overlay.graph-stab-fade-out { + opacity: 0; + pointer-events: none; +} + +.graph-stab-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +/* Orbital rings */ +.graph-stab-rings { + position: relative; + width: 120px; + height: 120px; +} + +.graph-stab-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 1px solid transparent; +} + +.graph-stab-ring-1 { + border-top-color: rgba(0, 200, 255, 0.6); + border-right-color: rgba(0, 200, 255, 0.15); + animation: stab-orbit 2.4s linear infinite; + filter: drop-shadow(0 0 6px rgba(0, 200, 255, 0.3)); +} + +.graph-stab-ring-2 { + inset: 14px; + border-bottom-color: rgba(224, 64, 251, 0.5); + border-left-color: rgba(224, 64, 251, 0.1); + animation: stab-orbit 1.8s linear infinite reverse; + filter: drop-shadow(0 0 6px rgba(224, 64, 251, 0.25)); +} + +.graph-stab-ring-3 { + inset: 30px; + border-top-color: rgba(0, 230, 118, 0.4); + border-right-color: rgba(0, 230, 118, 0.08); + animation: stab-orbit 3.2s linear infinite; + filter: drop-shadow(0 0 4px rgba(0, 230, 118, 0.2)); +} + +.graph-stab-core { + position: absolute; + top: 50%; + left: 50%; + width: 10px; + height: 10px; + transform: translate(-50%, -50%); + background: var(--accent); + border-radius: 50%; + box-shadow: + 0 0 12px rgba(0, 200, 255, 0.6), + 0 0 40px rgba(0, 200, 255, 0.2); + animation: stab-pulse 1.6s ease-in-out infinite; +} + +@keyframes stab-orbit { + to { transform: rotate(360deg); } +} + +@keyframes stab-pulse { + 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; } + 50% { transform: translate(-50%, -50%) scale(1.6); opacity: 0.6; } +} + +/* Text */ +.graph-stab-text { + text-align: center; +} + +.graph-stab-title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 4px; +} + +.graph-stab-subtitle { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + letter-spacing: 0.03em; +} + +/* Progress bar */ +.graph-stab-progress { + width: 200px; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + position: relative; +} + +.graph-stab-progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--accent), #e040fb); + border-radius: 2px; + transition: width 0.3s ease-out; + box-shadow: 0 0 8px rgba(0, 200, 255, 0.4); + position: relative; +} + +.graph-stab-progress-fill::after { + content: ''; + position: absolute; + right: 0; + top: -2px; + width: 7px; + height: 7px; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 6px rgba(0, 200, 255, 0.8); +} + +.graph-stab-pct { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-tertiary); + letter-spacing: 0.04em; +} + +.graph-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-tertiary); + gap: 8px; +} + +.graph-empty-icon { + font-size: 48px; + opacity: 0.3; +} + +.graph-empty-state h3 { + font-family: var(--font-display); + font-size: 16px; + color: var(--text-secondary); +} + +.graph-empty-state p { + font-size: 13px; +} + +/* Stats Bar */ +.graph-stats-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 11px; + flex-shrink: 0; +} + +.graph-stat { + display: inline-flex; + align-items: center; +} + +.graph-stat-value { + color: var(--accent); + font-weight: 600; +} + +.graph-stat-label { + color: var(--text-tertiary); +} + +.graph-stat-divider { + color: var(--border-bright); +} + +/* Node Kind Badges */ +.node-label-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.node-kind-function, .node-kind-method { + background: rgba(0, 200, 255, 0.12); + color: #00c8ff; + border: 1px solid rgba(0, 200, 255, 0.2); +} + +.node-kind-struct, .node-kind-class { + background: rgba(224, 64, 251, 0.12); + color: #e040fb; + border: 1px solid rgba(224, 64, 251, 0.2); +} + +.node-kind-enum { + background: rgba(255, 176, 32, 0.12); + color: #ffb020; + border: 1px solid rgba(255, 176, 32, 0.2); +} + +.node-kind-interface, .node-kind-trait { + background: rgba(124, 77, 255, 0.12); + color: #7c4dff; + border: 1px solid rgba(124, 77, 255, 0.2); +} + +.node-kind-module { + background: rgba(255, 138, 61, 0.12); + color: #ff8a3d; + border: 1px solid rgba(255, 138, 61, 0.2); +} + +.node-kind-file { + background: rgba(0, 230, 118, 0.12); + color: #00e676; + border: 1px solid rgba(0, 230, 118, 0.2); +} + +/* Code Inspector Panel */ +.code-inspector-panel { + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.code-inspector-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} + +.code-inspector-title { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.code-inspector-file-name { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-inspector-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.code-inspector-node-name { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--accent); +} + +.code-inspector-lines { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-tertiary); +} + +.code-inspector-close { + position: absolute; + top: 10px; + right: 10px; +} + +.code-inspector-body { + flex: 1; + overflow-y: auto; +} + +.code-inspector-code { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.7; + padding: 0; +} + +.code-line { + display: flex; + padding: 0 12px 0 0; + transition: background-color 0.15s; +} + +.code-line:hover { + background: rgba(0, 200, 255, 0.03); +} + +.code-line-highlight { + background: rgba(0, 200, 255, 0.08); + border-left: 2px solid var(--accent); +} + +.code-line-highlight:hover { + background: rgba(0, 200, 255, 0.12); +} + +.code-line-number { + display: inline-block; + width: 48px; + text-align: right; + padding-right: 12px; + color: var(--text-tertiary); + user-select: none; + flex-shrink: 0; + font-size: 11px; +} + +.code-line-content { + white-space: pre; + color: var(--text-primary); + overflow-x: auto; +} + +.code-inspector-empty { + padding: 20px 16px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} + + +/* ── Responsive ── */ + @media (max-width: 768px) { .sidebar { transform: translateX(-100%); - transition: transform 0.3s; + transition: transform 0.3s var(--ease-out); } .sidebar.open { transform: translateX(0); @@ -393,4 +1539,174 @@ tr:hover { margin-left: 0; padding: 16px; } + .stat-cards { + grid-template-columns: repeat(2, 1fr); + } + .graph-explorer-layout { + grid-template-columns: 1fr; + height: auto; + } + .graph-explorer-layout.inspector-open { + grid-template-columns: 1fr; + } + .file-tree-panel { + max-height: 200px; + border-right: none; + border-bottom: 1px solid var(--border); + } + .graph-canvas-panel { + min-height: 400px; + } + .code-inspector-panel { + border-right: none; + border-top: 1px solid var(--border); + max-height: 300px; + } +} + +@media (max-width: 480px) { + .stat-cards { + grid-template-columns: 1fr; + } +} + +/* ── Graph Index Repo Cards ── */ +.graph-index-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; +} + +.graph-repo-card { + display: block; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + text-decoration: none; + color: var(--text-primary); + transition: border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out), background 0.2s var(--ease-out); +} + +.graph-repo-card:hover { + border-color: var(--border-accent); + background: var(--bg-card-hover); + box-shadow: var(--accent-glow); +} + +.graph-repo-card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.graph-repo-card-icon { + font-size: 20px; + color: var(--accent); + line-height: 1; +} + +.graph-repo-card-name { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.graph-repo-card-url { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + margin: 0 0 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.graph-repo-card-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.graph-repo-card-tag { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px 8px; +} + +.graph-repo-card-tag-findings { + color: var(--accent); + border-color: var(--border-accent); + background: var(--accent-muted); +} + +/* ── Search Suggestions ── */ +.graph-search-bar { + position: relative; +} + +.search-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 100; + background: var(--bg-elevated); + border: 1px solid var(--border-bright); + border-radius: 0 0 var(--radius) var(--radius); + max-height: 300px; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.search-suggestion-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.15s; +} + +.search-suggestion-item:last-child { + border-bottom: none; +} + +.search-suggestion-item:hover { + background: var(--bg-card-hover); +} + +.search-suggestion-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + flex-shrink: 0; +} + +.search-suggestion-kind { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent); + background: var(--accent-muted); + border-radius: var(--radius-sm); + padding: 1px 6px; + flex-shrink: 0; +} + +.search-suggestion-path { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; } diff --git a/compliance-dashboard/src/pages/graph_explorer.rs b/compliance-dashboard/src/pages/graph_explorer.rs index c82bdca..ce5b400 100644 --- a/compliance-dashboard/src/pages/graph_explorer.rs +++ b/compliance-dashboard/src/pages/graph_explorer.rs @@ -1,8 +1,10 @@ use dioxus::prelude::*; +use crate::components::code_inspector::CodeInspector; +use crate::components::file_tree::{build_file_tree, FileTree}; use crate::components::page_header::PageHeader; use crate::components::toast::{ToastType, Toasts}; -use crate::infrastructure::graph::{fetch_graph, trigger_graph_build}; +use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_build}; #[component] pub fn GraphExplorerPage(repo_id: String) -> Element { @@ -20,6 +22,129 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { let mut building = use_signal(|| false); let mut toasts = use_context::(); + // Selected node state + let mut selected_node = use_signal(|| Option::::None); + let mut inspector_open = use_signal(|| false); + + // Search state + let mut search_query = use_signal(|| String::new()); + let mut search_results = use_signal(|| Vec::::new()); + let mut file_filter = use_signal(|| String::new()); + + // Store serialized graph JSON in signals so use_effect can react to them + let mut nodes_json = use_signal(|| String::new()); + let mut edges_json = use_signal(|| String::new()); + let mut graph_ready = use_signal(|| false); + + // When resource resolves, serialize the data into signals + let graph_data_read = graph_data.read(); + if let Some(Some(data)) = &*graph_data_read { + if !data.data.nodes.is_empty() && !graph_ready() { + let nj = serde_json::to_string(&data.data.nodes).unwrap_or_else(|_| "[]".into()); + let ej = serde_json::to_string(&data.data.edges).unwrap_or_else(|_| "[]".into()); + nodes_json.set(nj); + edges_json.set(ej); + graph_ready.set(true); + } + } + + // Derive stats and file tree + let (node_count, edge_count, community_count, languages, file_tree_data) = + if let Some(Some(data)) = &*graph_data_read { + let build = data.data.build.clone().unwrap_or_default(); + let nc = build + .get("node_count") + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let ec = build + .get("edge_count") + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let cc = build + .get("community_count") + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let langs: Vec = build + .get("languages_parsed") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let tree = build_file_tree(&data.data.nodes); + (nc, ec, cc, langs, tree) + } else { + (0, 0, 0, Vec::new(), Vec::new()) + }; + + let has_graph_data = matches!(&*graph_data_read, Some(Some(d)) if !d.data.nodes.is_empty()); + + // Drop the read guard before rendering + drop(graph_data_read); + + // use_effect runs AFTER DOM commit — this is when #graph-canvas exists + use_effect(move || { + let ready = graph_ready(); + if !ready { + return; + } + let nj = nodes_json(); + let ej = edges_json(); + if nj.is_empty() { + return; + } + spawn(async move { + // Register the click callback + load graph with a small delay for DOM paint + let js = format!( + r#" + window.__onNodeClick = function(nodeJson) {{ + var el = document.getElementById('graph-node-click-data'); + if (el) {{ + el.value = nodeJson; + el.dispatchEvent(new Event('input', {{ bubbles: true }})); + }} + }}; + setTimeout(function() {{ + if (window.__loadGraph) {{ + window.__loadGraph({nj}, {ej}); + }} else {{ + console.error('[graph-viz] __loadGraph not found — vis-network may not be loaded'); + }} + }}, 300); + "# + ); + let _ = document::eval(&js); + }); + }); + + // Extract selected node fields + let sel = selected_node(); + let sel_file = sel + .as_ref() + .and_then(|n| n.get("file_path").and_then(|v| v.as_str())) + .unwrap_or("") + .to_string(); + let sel_name = sel + .as_ref() + .and_then(|n| n.get("name").and_then(|v| v.as_str())) + .unwrap_or("") + .to_string(); + let sel_kind = sel + .as_ref() + .and_then(|n| n.get("kind").and_then(|v| v.as_str())) + .unwrap_or("") + .to_string(); + let sel_start = sel + .as_ref() + .and_then(|n| n.get("start_line").and_then(|v| v.as_u64())) + .unwrap_or(0) as u32; + let sel_end = sel + .as_ref() + .and_then(|n| n.get("end_line").and_then(|v| v.as_u64())) + .unwrap_or(0) as u32; + rsx! { PageHeader { title: "Code Knowledge Graph", @@ -29,10 +154,10 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { if repo_id.is_empty() { div { class: "card", p { "Select a repository to view its code graph." } - p { "You can trigger a graph build from the Repositories page." } } } else { - div { style: "margin-bottom: 16px;", + // Toolbar + div { class: "graph-toolbar", button { class: "btn btn-primary", disabled: building(), @@ -41,6 +166,9 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { move |_| { let rid = rid.clone(); building.set(true); + graph_ready.set(false); + nodes_json.set(String::new()); + edges_json.set(String::new()); spawn(async move { match trigger_graph_build(rid).await { Ok(_) => toasts.push(ToastType::Success, "Graph build triggered"), @@ -53,52 +181,266 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { }, if building() { "Building..." } else { "Build Graph" } } - } - div { class: "card", - h3 { "Graph Explorer \u{2014} {repo_id}" } - - match &*graph_data.read() { - Some(Some(data)) => { - let build = data.data.build.clone().unwrap_or_default(); - let node_count = build.get("node_count").and_then(|n| n.as_u64()).unwrap_or(0); - let edge_count = build.get("edge_count").and_then(|n| n.as_u64()).unwrap_or(0); - let community_count = build.get("community_count").and_then(|n| n.as_u64()).unwrap_or(0); - rsx! { - div { class: "grid grid-cols-3 gap-4 mb-4", - div { class: "stat-card", - div { class: "stat-value", "{node_count}" } - div { class: "stat-label", "Nodes" } - } - div { class: "stat-card", - div { class: "stat-value", "{edge_count}" } - div { class: "stat-label", "Edges" } - } - div { class: "stat-card", - div { class: "stat-value", "{community_count}" } - div { class: "stat-label", "Communities" } + div { class: "graph-search-bar", + input { + r#type: "text", + placeholder: "Search nodes...", + value: "{search_query}", + oninput: { + let rid = repo_id.clone(); + move |e: FormEvent| { + let val = e.value(); + search_query.set(val.clone()); + if val.len() >= 2 { + let rid = rid.clone(); + spawn(async move { + if let Ok(resp) = search_nodes(rid, val).await { + search_results.set(resp.data); + } + }); + } else { + search_results.set(Vec::new()); } } - - div { - id: "graph-container", - style: "width: 100%; height: 600px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary);", + }, + onkeypress: { + let rid = repo_id.clone(); + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + search_results.set(Vec::new()); + let q = search_query(); + let rid = rid.clone(); + if !q.is_empty() { + spawn(async move { + match search_nodes(rid, q).await { + Ok(resp) => { + let names: Vec = resp.data.iter() + .filter_map(|n| n.get("qualified_name").and_then(|v| v.as_str()).map(String::from)) + .collect(); + let names_json = serde_json::to_string(&names).unwrap_or_else(|_| "[]".into()); + let js = format!("if (window.__highlightNodes) {{ window.__highlightNodes({names_json}); }}"); + let _ = document::eval(&js); + }, + Err(e) => { + toasts.push(ToastType::Error, e.to_string()); + } + } + }); + } else { + spawn(async move { + let _ = document::eval("if (window.__clearGraphSelection) { window.__clearGraphSelection(); }"); + }); + } + } } - - script { - r#" - console.log('Graph explorer loaded'); - "# + }, + } + if !search_results().is_empty() { + div { class: "search-suggestions", + for node in search_results() { + { + let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let kind = node.get("kind").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let file_path = node.get("file_path").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let node_clone = node.clone(); + let qname = node.get("qualified_name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + rsx! { + div { + class: "search-suggestion-item", + onclick: move |_| { + let nc = node_clone.clone(); + let qn = qname.clone(); + selected_node.set(Some(nc)); + inspector_open.set(true); + search_results.set(Vec::new()); + spawn(async move { + let names_json = serde_json::to_string(&vec![&qn]).unwrap_or_else(|_| "[]".into()); + let js = format!("if (window.__highlightNodes) {{ window.__highlightNodes({names_json}); }}"); + let _ = document::eval(&js); + }); + }, + span { class: "search-suggestion-name", "{name}" } + span { class: "search-suggestion-kind", "{kind}" } + span { class: "search-suggestion-path", "{file_path}" } + } + } + } } } - }, - Some(None) => rsx! { - p { "No graph data available. Build the graph first." } - }, - None => rsx! { - p { "Loading graph data..." } - }, + } } + + button { + class: "btn-sm", + onclick: move |_| { + spawn(async move { + let _ = document::eval("if (window.__fitGraph) { window.__fitGraph(); }"); + }); + }, + "Fit View" + } + } + + // Hidden input for JS → Rust node click communication + input { + id: "graph-node-click-data", + r#type: "hidden", + value: "", + oninput: move |e| { + let val = e.value(); + if !val.is_empty() { + if let Ok(node) = serde_json::from_str::(&val) { + selected_node.set(Some(node)); + inspector_open.set(true); + } + } + }, + } + + // 3-panel layout: file-tree | [code-inspector] | graph-canvas + div { class: if inspector_open() { "graph-explorer-layout inspector-open" } else { "graph-explorer-layout" }, + + // Left: File Tree + div { class: "file-tree-panel", + div { class: "file-tree-header", + h4 { "Files" } + } + div { class: "file-tree-search", + input { + r#type: "text", + placeholder: "Filter files...", + value: "{file_filter}", + oninput: move |e| file_filter.set(e.value()), + } + } + div { class: "file-tree-content", + if file_tree_data.is_empty() { + div { class: "file-tree-empty", "No files. Build the graph first." } + } else { + FileTree { + tree: file_tree_data, + filter: file_filter(), + on_file_click: move |path: String| { + // Open code inspector for this file + let file_node = serde_json::json!({ + "file_path": &path, + "name": path.rsplit('/').next().unwrap_or(&path), + "kind": "file", + "start_line": 1, + "end_line": 0, + }); + selected_node.set(Some(file_node)); + inspector_open.set(true); + // Also highlight in graph + spawn(async move { + let js = format!( + "if (window.__highlightFileNodes) {{ window.__highlightFileNodes('{path}'); }}" + ); + let _ = document::eval(&js); + }); + }, + } + } + } + } + + // Left-center: Code Inspector (between file tree and graph) + if inspector_open() && selected_node().is_some() { + CodeInspector { + repo_id: repo_id.clone(), + file_path: sel_file, + node_name: sel_name, + node_kind: sel_kind, + start_line: sel_start, + end_line: sel_end, + on_close: move |_| { + inspector_open.set(false); + selected_node.set(None); + }, + } + } + + // Right: Graph Canvas + div { class: "graph-canvas-panel", + if has_graph_data { + div { + id: "graph-canvas", + class: "graph-canvas", + } + // Stabilization overlay + div { + id: "graph-stabilization-overlay", + class: "graph-stab-overlay", + div { class: "graph-stab-content", + // Orbital rings + div { class: "graph-stab-rings", + div { class: "graph-stab-ring graph-stab-ring-1" } + div { class: "graph-stab-ring graph-stab-ring-2" } + div { class: "graph-stab-ring graph-stab-ring-3" } + div { class: "graph-stab-core" } + } + div { class: "graph-stab-text", + div { class: "graph-stab-title", "Rendering Graph" } + div { class: "graph-stab-subtitle", + id: "graph-stab-node-info", + "{node_count} nodes \u{00B7} {edge_count} edges" + } + } + // Progress bar + div { class: "graph-stab-progress", + div { + class: "graph-stab-progress-fill", + id: "graph-stab-progress-fill", + } + } + div { + class: "graph-stab-pct", + id: "graph-stab-pct", + "Initializing\u{2026}" + } + } + } + } else if node_count > 0 { + // Data exists but nodes array was empty (shouldn't happen) + div { class: "loading", "Loading graph visualization..." } + } else if matches!(&*graph_data.read(), None) { + div { class: "loading", "Loading graph data..." } + } else { + div { class: "graph-empty-state", + div { class: "graph-empty-icon", "\u{29BB}" } + h3 { "No Graph Data" } + p { "Click \"Build Graph\" to analyze the repository's code structure." } + } + } + + // Stats bar + if node_count > 0 { + div { class: "graph-stats-bar", + span { class: "graph-stat", + span { class: "graph-stat-value", "{node_count}" } + span { class: "graph-stat-label", " nodes" } + } + span { class: "graph-stat-divider", "|" } + span { class: "graph-stat", + span { class: "graph-stat-value", "{edge_count}" } + span { class: "graph-stat-label", " edges" } + } + span { class: "graph-stat-divider", "|" } + span { class: "graph-stat", + span { class: "graph-stat-value", "{community_count}" } + span { class: "graph-stat-label", " communities" } + } + if !languages.is_empty() { + span { class: "graph-stat-divider", "|" } + span { class: "graph-stat", + span { class: "graph-stat-label", "{languages.join(\", \")}" } + } + } + } + } + } + } } } diff --git a/compliance-dashboard/src/pages/graph_index.rs b/compliance-dashboard/src/pages/graph_index.rs index ab7ba51..fedf10a 100644 --- a/compliance-dashboard/src/pages/graph_index.rs +++ b/compliance-dashboard/src/pages/graph_index.rs @@ -14,28 +14,61 @@ pub fn GraphIndexPage() -> Element { description: "Select a repository to explore its code graph", } - div { class: "card", - h3 { "Repositories" } - match &*repos.read() { - Some(Some(data)) => { - let repo_list = &data.data; - if repo_list.is_empty() { - rsx! { p { "No repositories found. Add a repository first." } } - } else { - rsx! { - div { class: "grid grid-cols-1 gap-3", - for repo in repo_list { - { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let name = repo.name.clone(); - let url = repo.git_url.clone(); - rsx! { - Link { - to: Route::GraphExplorerPage { repo_id: repo_id }, - class: "card hover:bg-gray-800 transition-colors cursor-pointer", - h4 { "{name}" } - if !url.is_empty() { - p { class: "text-sm text-muted", "{url}" } + match &*repos.read() { + Some(Some(data)) => { + let repo_list = &data.data; + if repo_list.is_empty() { + rsx! { + div { class: "card", + p { "No repositories found. Add a repository first." } + } + } + } else { + rsx! { + div { class: "graph-index-grid", + for repo in repo_list { + { + let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + let name = repo.name.clone(); + let url = repo.git_url.clone(); + let branch = repo.default_branch.clone(); + let findings = repo.findings_count; + let findings_label = if findings != 1 { format!("{findings} findings") } else { "1 finding".to_string() }; + let updated = { + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(repo.updated_at); + if diff.num_minutes() < 1 { + "just now".to_string() + } else if diff.num_hours() < 1 { + format!("{}m ago", diff.num_minutes()) + } else if diff.num_days() < 1 { + format!("{}h ago", diff.num_hours()) + } else if diff.num_days() < 30 { + format!("{}d ago", diff.num_days()) + } else { + repo.updated_at.format("%Y-%m-%d").to_string() + } + }; + rsx! { + Link { + to: Route::GraphExplorerPage { repo_id }, + class: "graph-repo-card", + div { class: "graph-repo-card-header", + div { class: "graph-repo-card-icon", "\u{29BB}" } + h3 { class: "graph-repo-card-name", "{name}" } + } + if !url.is_empty() { + p { class: "graph-repo-card-url", "{url}" } + } + div { class: "graph-repo-card-meta", + span { class: "graph-repo-card-tag", + "\u{E0A0} {branch}" + } + span { class: "graph-repo-card-tag graph-repo-card-tag-findings", + "{findings_label}" + } + span { class: "graph-repo-card-tag", + "Updated {updated}" } } } @@ -44,10 +77,14 @@ pub fn GraphIndexPage() -> Element { } } } - }, - Some(None) => rsx! { p { "Failed to load repositories." } }, - None => rsx! { p { "Loading repositories..." } }, - } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load repositories." } } + }, + None => rsx! { + div { class: "loading", "Loading repositories..." } + }, } } }