SIMON Master Console Rebuild
True command center for SIMON + WEB360
This rebuild gives you a cleaner production spine: interactive tree, live viewer, guarded editor, dynamic graphics, architecture map, and note storage. It is designed to sit beside your existing
console.php
and become the stronger replacement path.
Dashboard
Tree
Viewer
System Map
File Registry
Notes
Files
15,025
Folders
377
Scanned Size
3.79 GB
PHP Files
791
Editable Text Files
12,309
File viewer
guarded to /htdocs
/simon/old/simon-console.html
<!doctype html> <html lang="en"><head> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <title>SIMON · Operator Console</title> <style> :root{ --ink:#eef2ff;--dim:#a8b0cc;--mute:#6b7394; --ok:#59e3c0;--warn:#ffb86b;--danger:#ff6b8a; --accent:#7c9cff;--accent2:#c48bff;--accent3:#59e3c0; --stroke:rgba(255,255,255,.08);--stroke2:rgba(255,255,255,.14); --glass:rgba(255,255,255,.03);--glass2:rgba(255,255,255,.05); --glow:0 0 30px rgba(124,156,255,.15), inset 0 0 0 1px rgba(255,255,255,.06); } *{box-sizing:border-box} html,body{margin:0;height:100%;overflow:hidden;color:var(--ink); font:13px/1.5 ui-sans-serif,system-ui,-apple-system,Inter,Segoe UI,Roboto,sans-serif; background: radial-gradient(1100px 700px at 85% -10%, rgba(124,156,255,.22), transparent 60%), radial-gradient(900px 600px at -10% 10%, rgba(196,139,255,.2), transparent 55%), radial-gradient(700px 700px at 50% 110%, rgba(89,227,192,.14), transparent 55%), linear-gradient(180deg,#05060b,#0a0d1a 40%,#121633); } @keyframes drift{0%,100%{transform:translate(0,0) rotate(0)}50%{transform:translate(20px,-10px) rotate(8deg)}} .bg-orb{position:fixed;top:-200px;right:-200px;width:520px;height:520px;border-radius:50%; background:conic-gradient(from 120deg,rgba(124,156,255,.18),rgba(196,139,255,.22),rgba(89,227,192,.14),rgba(124,156,255,.18)); filter:blur(80px);animation:drift 18s ease-in-out infinite;pointer-events:none;z-index:0} .app{position:fixed;inset:0;display:grid;grid-template-columns:280px 1fr;z-index:1} aside{padding:16px;overflow-y:auto;border-right:1px solid var(--stroke); background:linear-gradient(180deg,rgba(10,13,26,.7),rgba(10,13,26,.4));backdrop-filter:blur(18px) saturate(140%)} main{overflow-y:auto;padding:16px 20px} /* Brand */ .brand{display:flex;align-items:center;gap:10px;margin-bottom:14px} .brand .m{width:34px;height:34px;border-radius:10px; background:conic-gradient(from 180deg,var(--accent),var(--accent2),var(--accent3),var(--accent)); box-shadow:0 0 22px rgba(124,156,255,.45)} .brand b{display:block;font-size:15px;letter-spacing:.3px} .brand small{color:var(--dim);font-size:10px;letter-spacing:1.8px;text-transform:uppercase} /* Controls */ .ctrl{display:flex;flex-direction:column;gap:6px;margin-bottom:12px} .ctrl label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--mute);margin-bottom:2px} .input,select,textarea,button{font:inherit;color:var(--ink);background:var(--glass);border:1px solid var(--stroke);border-radius:10px;padding:7px 10px;font-size:12px;outline:none} .input:focus,select:focus,textarea:focus{border-color:var(--accent)} textarea{resize:vertical;min-height:48px} button{cursor:pointer;background:var(--glass2)} button.primary{background:linear-gradient(135deg,rgba(124,156,255,.4),rgba(196,139,255,.4));border-color:rgba(124,156,255,.45);font-weight:600} button.ok{background:linear-gradient(135deg,rgba(89,227,192,.3),rgba(124,156,255,.3));border-color:rgba(89,227,192,.45)} button.warn{background:rgba(255,184,107,.18);border-color:rgba(255,184,107,.4);color:var(--warn)} button.danger{background:rgba(255,107,138,.15);border-color:rgba(255,107,138,.4);color:var(--danger)} button:hover{filter:brightness(1.15)} button:disabled{opacity:.45;cursor:not-allowed} /* Drop zone */ .drop{padding:10px 12px;border-radius:12px;border:1px dashed var(--stroke2);background:var(--glass);text-align:center;cursor:pointer;font-size:11px;color:var(--dim)} .drop:hover{color:var(--ink);background:var(--glass2);border-color:var(--accent)} .drop b{display:block;font-size:12px;color:var(--ink);margin-bottom:2px} .drop.loaded{border-style:solid;border-color:rgba(89,227,192,.45);background:rgba(89,227,192,.06);color:var(--ok)} .drop.error{border-color:rgba(255,107,138,.5);background:rgba(255,107,138,.08);color:var(--danger)} /* Nav */ .nav{display:flex;flex-direction:column;gap:2px;margin:12px 0} .nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:9px;cursor:pointer;color:var(--dim);font-size:12px;text-decoration:none} .nav a:hover{background:var(--glass2);color:var(--ink)} .nav a.on{background:linear-gradient(135deg,rgba(124,156,255,.2),rgba(196,139,255,.16));color:var(--ink);border:1px solid rgba(124,156,255,.3)} .nav a .dot{width:6px;height:6px;border-radius:50%;background:var(--accent)} .nav a .dot.p{background:var(--accent2)} .nav a .dot.g{background:var(--accent3)} .nav a .dot.w{background:var(--warn)} .nav a .dot.d{background:var(--danger)} .nav a .ct{margin-left:auto;font-size:10px;background:var(--glass2);padding:1px 7px;border-radius:999px;color:var(--mute)} /* Sidebar cards */ .stat{padding:10px 12px;border-radius:12px;background:var(--glass);border:1px solid var(--stroke);margin-top:10px} .stat h5{margin:0 0 6px;font-size:10px;text-transform:uppercase;letter-spacing:1.2px;color:var(--mute)} .kpis{display:grid;grid-template-columns:1fr 1fr;gap:6px} .k{padding:6px 8px;border-radius:9px;background:var(--glass2);border:1px solid var(--stroke)} .k b{display:block;font-size:15px} .k span{font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.8px} .ring{height:4px;background:var(--glass2);border-radius:3px;overflow:hidden;margin-bottom:6px} .ring i{display:block;height:100%;background:linear-gradient(90deg,var(--accent3),var(--accent));transition:width .8s} /* Main */ .topbar{display:flex;gap:10px;align-items:center;padding:10px 12px;border-radius:14px;background:var(--glass);border:1px solid var(--stroke);backdrop-filter:blur(14px) saturate(140%)} .search{flex:1;display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:10px;background:rgba(0,0,0,.2);border:1px solid var(--stroke)} .search input{flex:1;background:transparent;border:0;color:var(--ink);outline:none;font-size:13px} .pill{font-size:11px;padding:5px 10px;border-radius:999px;background:var(--glass2);border:1px solid var(--stroke);color:var(--dim)} .pill.ok{color:var(--ok);border-color:rgba(89,227,192,.3);background:rgba(89,227,192,.08)} .pill.warn{color:var(--warn);border-color:rgba(255,184,107,.3);background:rgba(255,184,107,.08)} .pill.err{color:var(--danger);border-color:rgba(255,107,138,.3);background:rgba(255,107,138,.08)} /* Grid layout */ .grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px;margin-top:14px} .card{grid-column:span 12;padding:14px 16px;border-radius:16px;background:var(--glass);border:1px solid var(--stroke);backdrop-filter:blur(16px) saturate(140%);box-shadow:var(--glow)} .card.sm{grid-column:span 6} .card.md{grid-column:span 6} .card.lg{grid-column:span 8} .card.xs{grid-column:span 4} .card.quarter{grid-column:span 3} .card h3{margin:0 0 8px;font-size:13px;letter-spacing:.3px;color:var(--ink);display:flex;align-items:center;gap:8px} .card h3 .tag{font-size:10px;color:var(--dim);padding:2px 7px;border-radius:999px;background:var(--glass2);border:1px solid var(--stroke)} .card h3 .tag.ok{color:var(--ok);background:rgba(89,227,192,.1);border-color:rgba(89,227,192,.3)} .card h3 .tag.warn{color:var(--warn);background:rgba(255,184,107,.1);border-color:rgba(255,184,107,.3)} .card h3 .tag.err{color:var(--danger);background:rgba(255,107,138,.1);border-color:rgba(255,107,138,.3)} .card h3 .tag.accent{color:var(--accent);background:rgba(124,156,255,.12);border-color:rgba(124,156,255,.3)} .card p{margin:4px 0;color:var(--dim);font-size:12px} /* Tables */ table{width:100%;border-collapse:collapse;font-size:12px} th{text-align:left;padding:6px 8px;font-weight:600;color:var(--mute);font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid var(--stroke);position:sticky;top:0;background:rgba(10,13,26,.8);backdrop-filter:blur(8px)} td{padding:7px 8px;border-bottom:1px solid var(--stroke)} tr:hover td{background:var(--glass2)} tr.sel td{background:rgba(124,156,255,.12)} code{font:11px/1.4 ui-monospace,Menlo,monospace;background:var(--glass2);padding:1px 5px;border-radius:4px} .mono{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dim)} /* Tree */ .tree{font-family:ui-monospace,Menlo,monospace;font-size:11px;max-height:60vh;overflow:auto} .tree-node{padding:2px 4px;display:flex;align-items:center;gap:5px;cursor:pointer;border-radius:4px;user-select:none} .tree-node:hover{background:var(--glass2)} .tree-node.sel{background:rgba(124,156,255,.18)} .tree-node .caret{display:inline-block;width:10px;color:var(--mute);font-size:9px;transition:transform .15s} .tree-node .caret.open{transform:rotate(90deg)} .tree-node .role{margin-left:auto;font-size:9px;color:var(--mute);padding:1px 6px;border-radius:4px;background:var(--glass2)} .tree-node .sf{font-size:9px;padding:1px 5px;border-radius:3px} .sf.s{background:rgba(89,227,192,.15);color:var(--ok)} .sf.r{background:rgba(255,184,107,.15);color:var(--warn)} .sf.x{background:rgba(255,107,138,.15);color:var(--danger)} .tree-children{padding-left:16px;border-left:1px dashed rgba(255,255,255,.08);margin-left:4px} /* Selection */ .selcard{padding:14px 16px;border-radius:14px;background:var(--glass2);border:1px solid rgba(124,156,255,.25);margin-top:10px} .selcard h4{margin:0 0 6px;font-size:13px} .kv{display:grid;grid-template-columns:140px 1fr;gap:3px 10px;font-size:11px;margin-bottom:10px} .kv b{color:var(--mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.8px} .snip{background:#05060b;border-radius:10px;border:1px solid var(--stroke);padding:10px;font:11px/1.45 ui-monospace,Menlo,monospace;overflow:auto;max-height:340px;white-space:pre;color:var(--dim)} /* Widgets */ .widget{padding:12px;border-radius:12px;background:var(--glass2);border:1px solid var(--stroke);position:relative;overflow:hidden} .widget b{display:block;font-size:24px;line-height:1.1} .widget span{display:block;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.8px;margin-top:4px} .widget .trend{position:absolute;right:10px;top:10px;font-size:10px;color:var(--dim)} .widget.ok b{color:var(--ok)} .widget.warn b{color:var(--warn)} .widget.err b{color:var(--danger)} .widget.accent b{color:var(--accent)} /* Misc */ .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0} .badge{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border-radius:999px;font-size:10px;background:var(--glass2);border:1px solid var(--stroke)} .badge::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent)} .badge.ok::before{background:var(--ok)} .badge.warn::before{background:var(--warn)} .badge.err::before{background:var(--danger)} .empty{padding:36px 20px;text-align:center;color:var(--dim);font-size:12px} .dock{position:fixed;bottom:12px;right:16px;padding:6px 12px;font-size:11px;color:var(--dim);background:rgba(10,13,26,.7);border:1px solid var(--stroke);border-radius:999px;backdrop-filter:blur(12px);z-index:10;max-width:60vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* Review flag */ .flag{font-size:10px;color:var(--warn);background:rgba(255,184,107,.15);border:1px solid rgba(255,184,107,.3);padding:1px 6px;border-radius:4px;cursor:pointer} .flag.on{background:rgba(255,184,107,.35);color:var(--ink)} /* Checks */ .chk{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;background:var(--glass2);border:1px solid var(--stroke);margin-bottom:6px;font-size:12px} .chk .dot{width:8px;height:8px;border-radius:50%;background:var(--mute)} .chk.ok .dot{background:var(--ok);box-shadow:0 0 8px var(--ok)} .chk.err .dot{background:var(--danger);box-shadow:0 0 8px var(--danger)} .chk b{font-weight:600;font-size:11px} .chk span{color:var(--dim);font-size:11px;margin-left:auto} /* Tooltip / collapsible */ details{background:var(--glass2);border-radius:10px;border:1px solid var(--stroke);padding:8px 12px;margin:6px 0;font-size:12px} details summary{cursor:pointer;color:var(--ink);font-weight:600;font-size:12px} details[open] summary{margin-bottom:8px} /* Context menu */ .ctxmenu{position:fixed;z-index:100;min-width:200px;padding:6px;border-radius:10px; background:rgba(14,18,34,.95);border:1px solid var(--stroke2);backdrop-filter:blur(16px); box-shadow:0 12px 40px rgba(0,0,0,.45);display:none;font-size:12px} .ctxmenu.show{display:block} .ctxmenu b{display:block;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:1px;padding:4px 10px} .ctxmenu a{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:6px;cursor:pointer;color:var(--ink);text-decoration:none} .ctxmenu a:hover{background:var(--glass2)} .ctxmenu a.danger{color:var(--danger)} .ctxmenu a.warn{color:var(--warn)} .ctxmenu hr{border:0;border-top:1px solid var(--stroke);margin:4px 0} /* Modal (confirmation) */ .modal{position:fixed;inset:0;background:rgba(5,6,11,.7);backdrop-filter:blur(6px);display:none;z-index:200;align-items:center;justify-content:center} .modal.show{display:flex} .modal .box{background:linear-gradient(180deg,rgba(15,20,40,.95),rgba(10,13,26,.95)); border:1px solid var(--stroke2);border-radius:16px;padding:20px 24px;max-width:460px;width:90%;box-shadow:0 20px 80px rgba(0,0,0,.5)} .modal h4{margin:0 0 8px;font-size:15px} .modal p{margin:6px 0 14px;color:var(--dim);font-size:12px} .modal .row{justify-content:flex-end;gap:8px} /* Flow graph (SVG) */ .flowHost{width:100%;height:62vh;border-radius:12px;border:1px solid var(--stroke);background:radial-gradient(600px 300px at 50% 50%, rgba(124,156,255,.06), transparent 60%);overflow:hidden;position:relative} .flowHost svg{width:100%;height:100%;cursor:grab} .flowHost svg:active{cursor:grabbing} .node-file{fill:rgba(124,156,255,.22);stroke:var(--accent);stroke-width:1.2;cursor:pointer} .node-var{fill:rgba(89,227,192,.22);stroke:var(--accent3);stroke-width:1.2;cursor:pointer} .node-fn{fill:rgba(196,139,255,.22);stroke:var(--accent2);stroke-width:1.2;cursor:pointer} .node-sel{stroke-width:2.5;filter:drop-shadow(0 0 8px rgba(124,156,255,.6))} .node-label{fill:var(--ink);font:11px ui-monospace,Menlo,monospace;pointer-events:none;text-anchor:middle} .edge{stroke:rgba(168,176,204,.3);stroke-width:1;fill:none} .edge.hi{stroke:var(--accent);stroke-width:2} /* Tab bar (for multi-manifest compare) */ .tabs{display:flex;gap:4px;margin-bottom:10px;padding:4px;border-radius:12px;background:var(--glass);border:1px solid var(--stroke);overflow-x:auto} .tab{padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--dim);border:1px solid transparent;white-space:nowrap;display:flex;align-items:center;gap:8px} .tab.on{background:linear-gradient(135deg,rgba(124,156,255,.2),rgba(196,139,255,.16));color:var(--ink);border-color:rgba(124,156,255,.3)} .tab .x{color:var(--mute);cursor:pointer;font-size:14px} .tab .x:hover{color:var(--danger)} /* FTP file list */ .ftpList .row{padding:4px 8px;border-radius:6px;font-family:ui-monospace,Menlo,monospace;font-size:11px;display:grid;grid-template-columns:16px 1fr 90px 140px 60px;gap:8px;cursor:pointer} .ftpList .row:hover{background:var(--glass2)} .ftpList .row.dir{color:var(--accent)} .ftpList .row.sens{color:var(--danger)} .ftpList .row.rev{color:var(--warn)} .ftpList .row.saf{color:var(--ok)} </style></head><body> <div class="bg-orb"></div> <div class="app"> <aside> <div class="brand"><div class="m"></div><div><b>SIMON</b><small>GS · Operator Console</small></div></div> <!-- Drop + connect --> <div class="drop" id="drop" tabindex="0" role="button"> <b id="dropTitle">Load manifest.json</b> <span id="dropSub">drop file · click · or connect scanner</span> <input id="fileIn" type="file" accept="application/json,.json" hidden> </div> <details style="margin-top:8px" open> <summary>Scanner connection</summary> <div class="ctrl" style="margin-top:8px"> <label>Scanner URL</label> <input class="input" id="scannerIn" placeholder="https://site.com/simon/simon-scanner.php"> <label>Token</label> <input class="input" id="tokenIn" placeholder="SIMON_TOKEN"> <div class="row"> <button class="primary" id="btnConnect" style="flex:1">Connect</button> <button id="btnRescan">Rescan</button> </div> <button id="btnRunTest">Run diagnostics</button> <button id="btnDedupe">Run duplicate detection</button> </div> </details> <details style="margin-top:8px"> <summary>Console backend (required for FTP / DB)</summary> <div class="ctrl" style="margin-top:8px"> <label>simon-console.php URL</label> <input class="input" id="consoleIn" placeholder="https://site.com/simon/simon-console.php"> <button id="btnConsolePing" class="ok">Ping backend</button> <small id="consolePingOut" style="color:var(--mute);font-size:10px"></small> </div> </details> <details style="margin-top:8px"> <summary>SFTP / FTP credentials</summary> <p style="color:var(--mute);font-size:10px;margin:4px 0">Encrypted at rest by the console backend vault. Password never stored in browser.</p> <div class="ctrl"> <label>Profile label</label><input class="input" id="ftpLabel" placeholder="gaylordsinclair.com"> <label>Protocol</label> <select class="input" id="ftpProto"><option value="sftp">SFTP (ssh2)</option><option value="ftp">FTP (passive)</option></select> <label>Host</label><input class="input" id="ftpHost" placeholder="access-...ionos.com"> <label>User</label><input class="input" id="ftpUser" placeholder="u123456789"> <label>Password</label><input class="input" id="ftpPass" type="password" placeholder="••••••••"> <label>Port</label><input class="input" id="ftpPort" placeholder="22"> <label>Default path</label><input class="input" id="ftpPath" placeholder="/homepages/.../htdocs/"> <label>Notes</label><textarea class="input" id="ftpNotes" placeholder="Key file, 2FA steps…"></textarea> <div class="row"> <button id="btnSaveFtp" class="ok" style="flex:1">Save to vault</button> <button id="btnListFtp" class="primary">Explore</button> </div> </div> </details> <details style="margin-top:8px"> <summary>MySQL / IONOS database</summary> <div class="ctrl"> <label>Profile label (any name — e.g., "comics-prod")</label><input class="input" id="dbLabel" placeholder="comics-prod"> <label>Host (from IONOS → Databases → Database info)</label><input class="input" id="dbHost" placeholder="db1234.hosting-data.io"> <label>User (starts with dbu…, NOT "User name dbu…")</label><input class="input" id="dbUser" placeholder="dbu5152366"> <label>Password</label><input class="input" id="dbPass" type="password"> <label>Database NAME (starts with dbs… — NOT engine version like "MariaDB 10.11")</label><input class="input" id="dbName" placeholder="dbs5152366"> <label>Port</label><input class="input" id="dbPort" placeholder="3306"> <div class="row"> <button id="btnSaveDb" class="ok" style="flex:1">Save to vault</button> <button id="btnProbeDb" class="primary">Probe</button> </div> </div> </details> <details style="margin-top:8px"> <summary>API providers</summary> <div class="ctrl"> <label>One per line (name=key)</label> <textarea class="input" id="apiProfiles" style="min-height:80px;font-family:ui-monospace,Menlo,monospace" placeholder="openai=sk-... anthropic=sk-ant-... google=AIza..."></textarea> <button id="btnSaveConns" class="ok">Save to vault</button> <button id="btnExportConns">Export profile JSON</button> </div> </details> <details style="margin-top:8px"> <summary>Saved vault profiles</summary> <div id="vaultList" style="font-size:11px;color:var(--dim);padding:4px 0">—</div> <button id="btnRefreshVault" style="width:100%;font-size:11px">Refresh</button> </details> <nav class="nav" id="nav"> <a data-v="overview" class="on"><i class="dot"></i>Overview</a> <a data-v="explorer"><i class="dot p"></i>Explorer<span class="ct" id="ctFiles">—</span></a> <a data-v="flow"><i class="dot p"></i>Flow Graph</a> <a data-v="vars"><i class="dot g"></i>Variable Map<span class="ct" id="ctVars">—</span></a> <a data-v="routes"><i class="dot g"></i>Routes<span class="ct" id="ctRoutes">—</span></a> <a data-v="endpoints"><i class="dot"></i>Endpoints<span class="ct" id="ctEndpoints">—</span></a> <a data-v="htaccess"><i class="dot w"></i>.htaccess</a> <a data-v="ftp"><i class="dot"></i>FTP Browser</a> <a data-v="db"><i class="dot g"></i>Database</a> <a data-v="compare"><i class="dot p"></i>Compare / Tabs</a> <a data-v="duplicates"><i class="dot w"></i>Duplicates<span class="ct" id="ctDupes">—</span></a> <a data-v="risks"><i class="dot d"></i>Risks<span class="ct" id="ctSecrets">—</span></a> <a data-v="testing"><i class="dot"></i>Testing</a> <a data-v="health"><i class="dot g"></i>Health</a> <a data-v="ai"><i class="dot p"></i>AI Review</a> <a data-v="snippet"><i class="dot"></i>Snippet Viewer</a> <a data-v="review"><i class="dot w"></i>Review Queue<span class="ct" id="ctReview">0</span></a> <a data-v="activity"><i class="dot g"></i>Activity</a> <a data-v="framework"><i class="dot"></i>Framework</a> </nav> <div class="stat"> <h5>Ecosystem health</h5> <div class="ring"><i id="healthRing" style="width:0%"></i></div> <div class="kpis"> <div class="k"><b id="kEntities">—</b><span>entities</span></div> <div class="k"><b id="kRisks">—</b><span>risks</span></div> </div> </div> <div class="stat"> <h5>Safety</h5> <div class="kpis"> <div class="k"><b id="kSafe">—</b><span>safe</span></div> <div class="k"><b id="kReview">—</b><span>review</span></div> </div> <div class="kpis" style="margin-top:6px"> <div class="k"><b id="kSens">—</b><span>sensitive</span></div> <div class="k"><b id="kBlock">—</b><span>blocked</span></div> </div> </div> <div class="stat"> <h5>Last scan</h5> <div id="lastScan" style="font-size:11px;color:var(--dim)">—</div> </div> <div class="stat"> <h5>Export</h5> <button id="btnExport" style="width:100%">Download current view as JSON</button> </div> </aside> <main> <div class="topbar"> <div class="search"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><input id="q" placeholder="filter files, folders, routes, secrets, variables…"></div> <span class="pill" id="statePill">no manifest</span> <span class="pill" id="rootPill">—</span> </div> <div id="host"></div> </main> </div> <div class="dock" id="dockMsg">waiting for manifest · drop file or Connect scanner</div> <!-- Right-click context menu for files/folders/variables --> <div class="ctxmenu" id="ctx"> <b id="ctxTitle">context</b> <a data-act="preview">🔍 Preview snippet</a> <a data-act="flow">🕸 Trace in flow graph</a> <a data-act="review">⚑ Mark for review</a> <a data-act="unreview" class="warn">Remove from review</a> <a data-act="download">⬇ Download entity</a> <hr> <a data-act="ftp-read" class="warn">Pull from SFTP</a> <a data-act="ftp-move" class="warn">Move on server</a> <a data-act="ftp-delete" class="danger">Delete on server</a> </div> <!-- Confirmation modal --> <div class="modal" id="modal"> <div class="box"> <h4 id="mdlTitle">Confirm</h4> <p id="mdlBody">Are you sure?</p> <div id="mdlExtra"></div> <div class="row" style="justify-content:flex-end;margin-top:10px"> <button id="mdlCancel">Cancel</button> <button id="mdlOk" class="danger">Confirm</button> </div> </div> </div> <script> // ================================================================ // State // ================================================================ const state = { m: null, view: 'overview', selId: null, query: '', byId: { files:{}, folders:{} }, edgesOutBySrc: {}, edgesInByTgt: {}, secretsByFile: {}, routesBySrc: {}, endpointsBySrc: {}, varsByFile: {}, fnsByFile: {}, clsByFile: {}, cstByFile: {}, childFoldersByRel: {}, filesByFolder: {}, expanded: new Set(['.']), review: new Set(), lastSnippet: null, }; const $ = s => document.querySelector(s); const $$ = s => [...document.querySelectorAll(s)]; const esc = s => (s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmt = n => n==null?'0':Number(n).toLocaleString(); const setDock = m => $('#dockMsg').textContent = m; // ================================================================ // Storage // ================================================================ function ss(k,v){ try { if (v === undefined) return sessionStorage.getItem(k); if (v === null) sessionStorage.removeItem(k); else sessionStorage.setItem(k,v); } catch(e){ return null; } } function ls(k,v){ try { if (v === undefined) return localStorage.getItem(k); if (v === null) localStorage.removeItem(k); else localStorage.setItem(k,v); } catch(e){ return null; } } // ================================================================ // Drop + scanner connection // ================================================================ const drop = $('#drop'), fileIn = $('#fileIn'), scannerIn = $('#scannerIn'), tokenIn = $('#tokenIn'); // Restore scanner config scannerIn.value = ls('simon_scanner_url') || ''; tokenIn.value = ls('simon_scanner_token') || ''; $('#ftpHost').value = ls('simon_ftp_host') || ''; $('#ftpUser').value = ls('simon_ftp_user') || ''; $('#ftpPort').value = ls('simon_ftp_port') || ''; $('#ftpPath').value = ls('simon_ftp_path') || ''; $('#ftpNotes').value = ls('simon_ftp_notes') || ''; $('#apiProfiles').value = ls('simon_api_profiles') || ''; // Restore review queue try { const r = ls('simon_review') || '[]'; JSON.parse(r).forEach(id => state.review.add(id)); updateReviewCount(); } catch(e){} function normalizeBase(raw){ let u = (raw||'').trim(); if (!u) return null; u = u.split('#')[0]; const q = u.indexOf('?'); if (q>=0) u = u.slice(0,q); if (!/^https?:\/\//i.test(u)) u = 'https://' + u; try { new URL(u); } catch(e){ return null; } return u; } function scannerUrl(mode, extra){ const base = normalizeBase(scannerIn.value); const tok = tokenIn.value.trim(); if (!base) return null; let u = base + '?t=' + encodeURIComponent(tok) + '&mode=' + mode; if (extra) for (const [k,v] of Object.entries(extra)) u += '&' + encodeURIComponent(k) + '=' + encodeURIComponent(v); return u; } function diagnoseHttp(st, url){ if (st===403) return 'scanner rejected token · check SIMON_TOKEN'; if (st===404) return '404 · file not found · check name & case (simon-scanner.php lowercase)'; if (st===500) return '500 · PHP error · open URL directly to see the error'; return 'HTTP ' + st + ' · ' + url; } function setDropState(title, sub, cls){ $('#dropTitle').textContent = title; $('#dropSub').textContent = sub; drop.classList.remove('loaded','error'); if (cls) drop.classList.add(cls); } function bannerError(msg){ setDropState('Failed', msg, 'error'); setDock('error · ' + msg); } drop.addEventListener('click', ()=> fileIn.click()); drop.addEventListener('keydown', e => { if(e.key==='Enter'||e.key===' ') fileIn.click(); }); drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor='var(--accent)'; }); drop.addEventListener('dragleave', ()=> drop.style.borderColor=''); drop.addEventListener('drop', e => { e.preventDefault(); drop.style.borderColor=''; const f = e.dataTransfer?.files?.[0]; if (f) loadFile(f); }); fileIn.addEventListener('change', e => { if(e.target.files[0]) loadFile(e.target.files[0]); }); async function loadFile(f){ setDock('reading ' + f.name + ' · ' + (f.size/1024/1024).toFixed(2) + ' MB'); setDropState('reading…', f.name, null); try { const j = JSON.parse(await f.text()); if (!j?.stats){ bannerError('no stats{} in file · not a manifest'); return; } bind(j); setDropState('Manifest loaded', fmt(j.stats.files||0)+' files · '+fmt(j.stats.folders||0)+' folders', 'loaded'); setDock('bound from file · ' + new Date().toLocaleTimeString()); } catch(e){ bannerError('parse error · ' + e.message); } } async function loadUrl(url, verbose){ setDock('GET ' + url); if (verbose) setDropState('fetching…', url, null); try { const r = await fetch(url, {cache:'no-store'}); if (!r.ok){ if(verbose) bannerError(diagnoseHttp(r.status, url)); return false; } const ct = r.headers.get('content-type')||''; const txt = await r.text(); let j; try { j = JSON.parse(txt); } catch(e){ if (verbose) bannerError('server returned ' + (ct||'non-JSON') + ' · likely an error page'); return false; } if (!j?.stats){ if(verbose) bannerError('JSON has no stats{}'); return false; } bind(j); setDropState('Manifest loaded · fetched', fmt(j.stats.files||0)+' files · '+fmt(j.stats.folders||0)+' folders', 'loaded'); setDock('bound ' + fmt(j.stats.files) + ' files'); return true; } catch(e){ if (verbose) bannerError('fetch failed · ' + e.message); return false; } } $('#btnConnect').addEventListener('click', async ()=>{ if (location.protocol==='file:'){ bannerError('file:// mode blocks fetches · upload this HTML to your server'); return; } const base = normalizeBase(scannerIn.value); if (!base){ bannerError('enter scanner URL'); return; } scannerIn.value = base; ls('simon_scanner_url', scannerIn.value); ls('simon_scanner_token', tokenIn.value); const statusU = scannerUrl('status'); setDock('ping ' + statusU); try { const s = await fetch(statusU, {cache:'no-store'}); if (!s.ok){ bannerError(diagnoseHttp(s.status, statusU)); return; } const info = await s.json(); if (!info.exists){ bannerError('scanner reached · no manifest yet · click Rescan'); return; } await loadUrl(scannerUrl('manifest'), true); } catch(e){ bannerError('unreachable · ' + e.message); } }); $('#btnRescan').addEventListener('click', async ()=>{ if (location.protocol==='file:'){ bannerError('file:// blocks fetches'); return; } const u = scannerUrl('run'); if (!u){ bannerError('enter scanner URL + token'); return; } if (!confirm('Run a fresh scan? Can take 30–90 seconds.')) return; setDock('POST ' + u); setDropState('scanning…','do not close',null); try { const r = await fetch(u,{cache:'no-store'}); if (!r.ok){ bannerError(diagnoseHttp(r.status,u)); return; } setDock('scan complete · pulling manifest'); await loadUrl(scannerUrl('manifest'), true); } catch(e){ bannerError('scan failed · ' + e.message); } }); $('#btnRunTest').addEventListener('click', ()=>{ state.view='testing'; navigate(); runDiagnostics(); }); $('#btnDedupe').addEventListener('click', async ()=>{ const u = scannerUrl('dedupe'); if(!u){ bannerError('enter scanner URL + token'); return; } setDock('POST ' + u); try { const r = await fetch(u,{cache:'no-store'}); const j = await r.json(); if (j.ok){ setDock('dedupe complete · ' + j.counts.content + ' content dups · pulling manifest'); await loadUrl(scannerUrl('manifest'), true); state.view='duplicates'; navigate(); } else bannerError('dedupe error · ' + (j.error||'?')); } catch(e){ bannerError('dedupe failed · ' + e.message); } }); $('#btnSaveConns').addEventListener('click', ()=>{ ls('simon_ftp_host', $('#ftpHost').value); ls('simon_ftp_user', $('#ftpUser').value); ls('simon_ftp_port', $('#ftpPort').value); ls('simon_ftp_path', $('#ftpPath').value); ls('simon_ftp_notes', $('#ftpNotes').value); ls('simon_api_profiles', $('#apiProfiles').value); setDock('connections saved locally'); }); $('#btnExportConns').addEventListener('click', ()=>{ const data = { scanner: {url: scannerIn.value, token: tokenIn.value ? '***' : ''}, ftp: {host: $('#ftpHost').value, user: $('#ftpUser').value, port: $('#ftpPort').value, path: $('#ftpPath').value, notes: $('#ftpNotes').value}, api_profiles: $('#apiProfiles').value.split('\n').filter(x=>x.trim()).map(l=>{const [n,v]=l.split('=');return {name:n,key:v?'***':''}}), saved_at: new Date().toISOString() }; downloadJson(data, 'simon-connections.json'); }); // Auto-connect flow (async ()=>{ if (location.protocol==='file:'){ setTimeout(()=>{ setDropState('running locally', 'file:// blocks network · drop a manifest.json to preview', null); setDock('file:// mode · fetches blocked · drop a manifest.json file to preview'); }, 50); return; } const p = new URLSearchParams(location.search); const mo = p.get('manifest'); if (mo) { await loadUrl(mo,true); return; } const so = p.get('scanner'), to = p.get('token'); if (so){ scannerIn.value = so; if(to) tokenIn.value = to; ls('simon_scanner_url',so); if(to)ls('simon_scanner_token',to); $('#btnConnect').click(); return; } if (scannerIn.value && tokenIn.value){ setDock('reconnecting to saved scanner…'); if (await loadUrl(scannerUrl('manifest'), false)) return; } for (const u of ['./manifest.json','../_data/simon/manifest.json','/_data/simon/manifest.json']){ if (await loadUrl(u,false)) return; } setDock('no manifest auto-found · drop file, paste URL, or connect scanner'); })(); // ================================================================ // Bind manifest // ================================================================ function bind(m){ state.m = m; state.selId = null; const b = {files:{}, folders:{}}; (m.folders||[]).forEach(f => b.folders[f.id] = f); (m.files||[]).forEach(f => b.files[f.id] = f); state.byId = b; const oBy = {}, iBy = {}; (m.edges||[]).forEach(e => { (oBy[e.source] = oBy[e.source]||[]).push(e); if (e.target) (iBy[e.target] = iBy[e.target]||[]).push(e); }); state.edgesOutBySrc = oBy; state.edgesInByTgt = iBy; const groupBy = (arr, k) => (arr||[]).reduce((a,x)=>{ (a[x[k]]=a[x[k]]||[]).push(x); return a; }, {}); state.secretsByFile = groupBy(m.secrets,'file'); state.routesBySrc = groupBy(m.routes,'source'); state.endpointsBySrc = groupBy(m.endpoints,'source'); state.varsByFile = groupBy(m.variables,'file'); state.fnsByFile = groupBy(m.functions,'file'); state.clsByFile = groupBy(m.classes,'file'); state.cstByFile = groupBy(m.constants,'file'); const cf = {}, ff = {}; (m.folders||[]).forEach(f => { const parent = f.rel==='.' ? null : (f.rel.includes('/') ? f.rel.split('/').slice(0,-1).join('/') : '.'); if (parent!=null) (cf[parent]=cf[parent]||[]).push(f); }); (m.files||[]).forEach(f => { const key = f.folder || '.'; (ff[key]=ff[key]||[]).push(f); }); state.childFoldersByRel = cf; state.filesByFolder = ff; // Sidebar stats const s = m.stats||{}, sf = s.safety||{}; const total = (s.files||0) + (s.folders||0) + (s.routes||0) + (s.endpoints||0); const risks = (s.secrets||0) + (sf.sensitive||0) + (sf.review||0); const hp = total ? Math.max(0, Math.min(100, Math.round(100 - (risks/total*100)))) : 0; $('#healthRing').style.width = hp + '%'; $('#kEntities').textContent = fmt(total); $('#kRisks').textContent = fmt(risks); $('#kSafe').textContent = fmt(sf.safe||0); $('#kReview').textContent = fmt(sf.review||0); $('#kSens').textContent = fmt(sf.sensitive||0); $('#kBlock').textContent = fmt(sf.blocked||0); $('#ctFiles').textContent = fmt(s.files||0); $('#ctVars').textContent = fmt(s.variables||0); $('#ctRoutes').textContent = fmt(s.routes||0); $('#ctEndpoints').textContent = fmt(s.endpoints||0); $('#ctSecrets').textContent = fmt(s.secrets||0); const dupTotal = (m.duplicates?.by_content?.length||0) + (m.duplicates?.by_name?.length||0); $('#ctDupes').textContent = fmt(dupTotal); $('#lastScan').textContent = s.generated_at ? new Date(s.generated_at).toLocaleString() : (s.scanned_at || '—'); $('#rootPill').textContent = (m.meta?.root||'—').split('/').slice(-3).join('/'); $('#statePill').textContent = fmt(s.files||0) + ' files · ' + fmt(s.folders||0) + ' folders'; $('#statePill').className = 'pill ok'; render(); } // ================================================================ // Navigation // ================================================================ function navigate(){ $$('.nav a').forEach(a => a.classList.toggle('on', a.dataset.v === state.view)); render(); } $$('.nav a').forEach(a => a.addEventListener('click', ()=>{ state.view = a.dataset.v; navigate(); })); $('#q').addEventListener('input', e => { state.query = e.target.value.toLowerCase(); render(); }); // ================================================================ // Views // ================================================================ function render(){ const host = $('#host'); if (!state.m){ host.innerHTML = '<div class="grid"><div class="card empty">Drop manifest.json or Connect scanner to begin.</div></div>'; return; } const v = state.view; if (v==='overview') return host.innerHTML = viewOverview(); if (v==='explorer') return renderExplorer(host); if (v==='vars') return host.innerHTML = viewVars(); if (v==='routes') return host.innerHTML = viewRoutes(); if (v==='endpoints') return host.innerHTML = viewEndpoints(); if (v==='duplicates') return host.innerHTML = viewDupes(); if (v==='risks') return host.innerHTML = viewRisks(); if (v==='testing') return host.innerHTML = viewTesting(); if (v==='health') return host.innerHTML = viewHealth(); if (v==='ai') return host.innerHTML = viewAi(); if (v==='snippet') return host.innerHTML = viewSnippet(); if (v==='review') return host.innerHTML = viewReview(); if (v==='activity') return host.innerHTML = viewActivity(); if (v==='framework') return host.innerHTML = viewFramework(); if (v==='flow') return renderFlow(host); if (v==='htaccess') return renderHtaccess(host); if (v==='ftp') return renderFtp(host); if (v==='db') return renderDb(host); if (v==='compare') return renderCompare(host); } // ---------- Overview ---------- function viewOverview(){ const m = state.m, s = m.stats||{}, sf = s.safety||{}; const trust = trustScore(); const rec = recommendations(); return `<div class="grid"> <div class="card quarter"><div class="widget accent"><b>${fmt(s.files||0)}</b><span>Files</span></div></div> <div class="card quarter"><div class="widget"><b>${fmt(s.folders||0)}</b><span>Folders</span></div></div> <div class="card quarter"><div class="widget ok"><b>${fmt(s.routes||0)}</b><span>Routes</span></div></div> <div class="card quarter"><div class="widget ${s.secrets>0?'err':'ok'}"><b>${fmt(s.secrets||0)}</b><span>Secret findings</span></div></div> <div class="card quarter"><div class="widget ok"><b>${fmt(sf.safe||0)}</b><span>Safe</span></div></div> <div class="card quarter"><div class="widget warn"><b>${fmt(sf.review||0)}</b><span>Review</span></div></div> <div class="card quarter"><div class="widget err"><b>${fmt(sf.sensitive||0)}</b><span>Sensitive</span></div></div> <div class="card quarter"><div class="widget ${trust>=70?'ok':trust>=40?'warn':'err'}"><b>${trust}</b><span>Trust score /100</span></div></div> <div class="card lg"> <h3>Top recommendations <span class="tag accent">${rec.length}</span></h3> ${rec.length? '<table><thead><tr><th>#</th><th>Finding</th><th>Target</th><th>Why</th><th></th></tr></thead><tbody>' + rec.slice(0,12).map((r,i)=>`<tr><td>${i+1}</td><td><span class="badge ${r.sev}">${r.kind}</span></td><td class="mono">${esc(r.target||'—')}</td><td>${esc(r.why)}</td><td>${r.id?`<button data-jump="${esc(r.id)}" style="font-size:10px;padding:3px 8px">view</button>`:''}</td></tr>`).join('') + '</tbody></table>' : '<p>No recommendations — looks clean.</p>'} </div> <div class="card xs"> <h3>System map</h3> <div class="kv"> <b>Scan root</b><div class="mono">${esc(m.meta?.root||'—')}</div> <b>Elapsed</b><div>${fmt(s.elapsed_ms||0)} ms</div> <b>Variables</b><div>${fmt(s.variables||0)}</div> <b>Functions</b><div>${fmt(s.functions||0)}</div> <b>Classes</b><div>${fmt(s.classes||0)}</div> <b>Constants</b><div>${fmt(s.constants||0)}</div> <b>Edges</b><div>${fmt(s.edges||0)}</div> </div> </div> </div>`; } // Wire recommendation jump document.addEventListener('click', e => { const j = e.target.closest('[data-jump]'); if (j){ state.selId = j.dataset.jump; state.view = 'explorer'; navigate(); } }); // ---------- Explorer (real expand/collapse tree + selection card) ---------- function renderExplorer(host){ host.innerHTML = `<div class="grid"> <div class="card md"> <h3>Explorer <span class="tag">${fmt(state.m.stats?.files||0)} files</span></h3> <div class="row" style="margin:6px 0 10px"> <button id="xExpandAll" style="font-size:10px">expand all</button> <button id="xCollapseAll" style="font-size:10px">collapse all</button> </div> <div id="treeHost" class="tree"></div> </div> <div class="card md" id="selHost"><h3>Selection</h3><p>Click a file in the tree. Double-click to preview its content.</p></div> </div>`; renderTree(); renderSelection(); $('#xExpandAll').onclick = ()=>{ (state.m.folders||[]).forEach(f => state.expanded.add(f.rel)); renderTree(); }; $('#xCollapseAll').onclick = ()=>{ state.expanded.clear(); state.expanded.add('.'); renderTree(); }; } function renderTree(){ const host = $('#treeHost'); if (!host) return; host.innerHTML = ''; const root = (state.m.folders||[]).find(f => f.rel==='.') || {id:'root',rel:'.',role:'root'}; host.appendChild(renderTreeNode(root, 0)); } function renderTreeNode(folder, depth){ const wrap = document.createElement('div'); const node = document.createElement('div'); node.className = 'tree-node'; const open = state.expanded.has(folder.rel); const hasKids = (state.childFoldersByRel[folder.rel]||[]).length>0 || (state.filesByFolder[folder.rel]||[]).length>0; node.innerHTML = `<span class="caret ${open?'open':''}">${hasKids?'▸':'·'}</span>📁 ${esc(folder.rel==='.'?'(root)':folder.rel.split('/').pop())}<span class="role">${esc(folder.role||'')}</span>`; node.addEventListener('click', ()=>{ if (state.expanded.has(folder.rel)) state.expanded.delete(folder.rel); else state.expanded.add(folder.rel); renderTree(); }); wrap.appendChild(node); if (open){ const kids = document.createElement('div'); kids.className = 'tree-children'; const subFolders = (state.childFoldersByRel[folder.rel]||[]).sort((a,b)=>a.rel.localeCompare(b.rel)); subFolders.forEach(sf => kids.appendChild(renderTreeNode(sf, depth+1))); const files = (state.filesByFolder[folder.rel]||[]).slice(0, 500); files.forEach(f => { const fn = document.createElement('div'); fn.className = 'tree-node' + (state.selId===f.id?' sel':''); const sc = f.safety==='SAFE'?'s' : (f.safety==='REVIEW'?'r' : (f.safety==='SENSITIVE'||f.safety==='BLOCKED'?'x':'')); fn.innerHTML = `<span class="caret">·</span>📄 ${esc(f.rel.split('/').pop())}<span class="role">${esc(f.role||'')}</span>${sc?`<span class="sf ${sc}">${f.safety[0]}</span>`:''}`; fn.addEventListener('click', e => { e.stopPropagation(); state.selId = f.id; renderTree(); renderSelection(); }); fn.addEventListener('dblclick', e => { e.stopPropagation(); state.selId = f.id; state.view='snippet'; navigate(); loadSnippet(f.rel); }); fn.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); state.selId = f.id; showCtx(e.pageX, e.pageY, { kind:'file', id:f.id, rel:f.rel }); }); kids.appendChild(fn); }); if ((state.filesByFolder[folder.rel]||[]).length>500){ const more = document.createElement('div'); more.className = 'tree-node'; more.style.color='var(--mute)'; more.textContent = `… ${(state.filesByFolder[folder.rel]||[]).length-500} more`; kids.appendChild(more); } wrap.appendChild(kids); } return wrap; } function renderSelection(){ const host = $('#selHost'); if (!host) return; const f = state.byId.files[state.selId]; if (!f){ host.innerHTML = '<h3>Selection</h3><p>Click a file. Double-click to preview.</p>'; return; } const secrets = state.secretsByFile[f.id]||[]; const outs = state.edgesOutBySrc[f.id]||[]; const ins = state.edgesInByTgt[f.id]||[]; const rts = state.routesBySrc[f.id]||[]; const eps = state.endpointsBySrc[f.id]||[]; const vars = state.varsByFile[f.id]||[]; const fns = state.fnsByFile[f.id]||[]; const cls = state.clsByFile[f.id]||[]; const csts = state.cstByFile[f.id]||[]; const isRev = state.review.has(f.id); host.innerHTML = `<h3>Selection <span class="tag ${f.safety==='SAFE'?'ok':(f.safety==='REVIEW'?'warn':'err')}">${esc(f.safety||'?')}</span></h3> <div class="selcard"> <h4>${esc(f.rel)}</h4> <div class="kv"> <b>Role</b><div>${esc(f.role||'—')}</div> <b>Kind</b><div>${esc(f.kind||'—')}</div> <b>Bytes</b><div>${fmt(f.bytes||0)}</div> <b>Modified</b><div>${f.mtime?new Date(f.mtime*1000).toLocaleString():'—'}</div> <b>Summary</b><div>${esc(f.summary||'—')}</div> </div> <div class="row"> <button class="primary" onclick="state.view='snippet'; navigate(); loadSnippet('${esc(f.rel).replace(/'/g,"\\\\'")}')">Preview content</button> <button class="flag ${isRev?'on':''}" id="flagBtn">${isRev?'✓ Marked for review':'⚑ Mark for review'}</button> <button onclick="downloadJson(${JSON.stringify({file:f,secrets,outs,ins,routes:rts,endpoints:eps,variables:vars,functions:fns,classes:cls,constants:csts}).replace(/"/g,'"')}, 'file-${f.id}.json')">Download entity</button> </div> </div> ${secrets.length? section('Secrets', ['kind','line','masked'], secrets.map(s=>[s.kind,s.line,s.masked])):''} ${outs.length? section('Outbound includes / calls', ['kind','target'], outs.map(e=>[e.kind,(e.target_hint||e.target_url||e.target||'—')])):''} ${ins.length? section('Inbound refs', ['kind','from'], ins.map(e=>[e.kind, state.byId.files[e.source]?.rel||e.source])):''} ${rts.length? section('Routes', ['kind','url'], rts.map(r=>[r.kind,r.url||r.action||r.href||'—'])):''} ${eps.length? section('Endpoints', ['kind','url'], eps.map(e=>[e.kind,e.url])):''} ${vars.length? section('Variables ('+vars.length+')', ['kind','name','line'], vars.slice(0,60).map(v=>[v.kind,v.name,v.line])):''} ${fns.length? section('Functions ('+fns.length+')', ['name','line'], fns.slice(0,40).map(fn=>[fn.name,fn.line])):''} ${cls.length? section('Classes', ['name','line'], cls.map(c=>[c.name,c.line])):''} ${csts.length? section('Constants', ['name','line'], csts.map(c=>[c.name,c.line])):''} `; const fb = $('#flagBtn'); if (fb) fb.onclick = ()=>{ toggleReview(f.id); renderSelection(); }; } function section(title, cols, rows){ return `<details open><summary>${esc(title)} <span class="tag">${rows.length}</span></summary> <table><thead><tr>${cols.map(c=>`<th>${esc(c)}</th>`).join('')}</tr></thead> <tbody>${rows.map(r=>`<tr>${r.map(v=>`<td class="mono">${esc(String(v??'—'))}</td>`).join('')}</tr>`).join('')}</tbody></table></details>`; } // ---------- Variable Map ---------- function viewVars(){ const m = state.m, list = (m.variables||[]).slice(); const q = state.query; const flt = q? list.filter(v => (v.name||'').toLowerCase().includes(q)||(v.file||'').toLowerCase().includes(q)) : list; const byName = {}; flt.forEach(v => { (byName[v.name]=byName[v.name]||[]).push(v); }); const grouped = Object.entries(byName).sort((a,b)=>b[1].length - a[1].length).slice(0, 500); return `<div class="grid"><div class="card"> <h3>Variable Map <span class="tag">${fmt(Object.keys(byName).length)} unique · ${fmt(flt.length)} refs</span></h3> <p>Global variables, superglobals, and assignments. Type filter above to narrow.</p> <table><thead><tr><th>name</th><th>refs</th><th>kind(s)</th><th>files</th><th>sample line</th></tr></thead><tbody> ${grouped.map(([name,refs])=>{ const files = [...new Set(refs.map(r => state.byId.files[r.file]?.rel || r.file))]; const kinds = [...new Set(refs.map(r => r.kind))]; return `<tr><td class="mono">${esc(name)}</td><td>${refs.length}</td><td><span class="badge">${esc(kinds.join(', '))}</span></td><td class="mono">${files.slice(0,3).map(esc).join('<br>')}${files.length>3?' <span class="badge">+'+(files.length-3)+'</span>':''}</td><td class="mono">${refs[0].line||'—'}</td></tr>`; }).join('')} </tbody></table></div></div>`; } // ---------- Routes ---------- function viewRoutes(){ const rts = state.m.routes||[]; const q = state.query; const flt = q? rts.filter(r => JSON.stringify(r).toLowerCase().includes(q)):rts; const rows = flt.slice(0,500).map(r=>{ const src = state.byId.files[r.source]?.rel || r.source; return `<tr><td><span class="badge">${esc(r.kind)}</span></td><td class="mono">${esc(r.url||r.action||r.href||'—')}</td><td class="mono">${esc(src)}</td></tr>`; }).join(''); return `<div class="grid"><div class="card"><h3>Routes <span class="tag">${fmt(flt.length)} of ${fmt(rts.length)}</span></h3> <p>Anchors, form actions, and nav hrefs. Where users and apps go.</p> <table><thead><tr><th>kind</th><th>url</th><th>source</th></tr></thead><tbody>${rows}</tbody></table></div></div>`; } // ---------- Endpoints ---------- function viewEndpoints(){ const eps = state.m.endpoints||[]; const q = state.query; const flt = q? eps.filter(e => JSON.stringify(e).toLowerCase().includes(q)):eps; const rows = flt.slice(0,500).map(e=>{ const src = state.byId.files[e.source]?.rel || e.source; return `<tr><td><span class="badge">${esc(e.kind)}</span></td><td class="mono">${esc(e.url)}</td><td class="mono">${esc(src)}</td></tr>`; }).join(''); return `<div class="grid"><div class="card"><h3>Endpoints <span class="tag">${fmt(flt.length)}</span></h3> <p>Outbound URLs: fetch, axios, XHR, <code>new URL</code>. Where the app calls external services.</p> <table><thead><tr><th>kind</th><th>url</th><th>source</th></tr></thead><tbody>${rows}</tbody></table></div></div>`; } // ---------- Duplicates ---------- function viewDupes(){ const d = state.m.duplicates; if (!d){ return `<div class="grid"><div class="card"><h3>Duplicates</h3><p>No duplicate analysis yet. Click <b>Run duplicate detection</b> in the sidebar — it hashes file contents and finds identical or near-identical files.</p></div></div>`; } const cSec = d.by_content?.length ? `<div class="card lg"><h3>Same content <span class="tag err">${d.by_content.length}</span></h3> <p>Byte-for-byte duplicates. Safe to delete all but one.</p> <table><thead><tr><th>hash</th><th>paths</th></tr></thead><tbody> ${d.by_content.slice(0,100).map(g=>`<tr><td class="mono">${esc(g.hash.slice(0,12))}…</td><td class="mono">${g.paths.map(esc).join('<br>')}</td></tr>`).join('')} </tbody></table></div>` : ''; const nSec = d.by_name?.length ? `<div class="card md"><h3>Same filename <span class="tag warn">${d.by_name.length}</span></h3> <p>Multiple files with the same basename — may be legitimate or copies.</p> <table><thead><tr><th>name</th><th>paths</th></tr></thead><tbody> ${d.by_name.slice(0,60).map(g=>`<tr><td class="mono">${esc(g.name)}</td><td class="mono">${g.paths.map(esc).join('<br>')}</td></tr>`).join('')} </tbody></table></div>`:''; const rSec = d.by_role_size?.length ? `<div class="card md"><h3>Same role + size <span class="tag">${d.by_role_size.length}</span></h3> <p>Files with identical role classification and byte count — possible duplicate purpose.</p> <table><thead><tr><th>signature</th><th>paths</th></tr></thead><tbody> ${d.by_role_size.slice(0,60).map(g=>`<tr><td class="mono">${esc(g.signature)}</td><td class="mono">${g.paths.map(esc).join('<br>')}</td></tr>`).join('')} </tbody></table></div>`:''; return `<div class="grid">${cSec}${nSec}${rSec}${!cSec&&!nSec&&!rSec?'<div class="card empty">No duplicates detected.</div>':''}</div>`; } // ---------- Risks ---------- function viewRisks(){ const secs = state.m.secrets||[]; const sens = (state.m.files||[]).filter(f => f.safety==='REVIEW' || f.safety==='SENSITIVE' || f.safety==='BLOCKED'); const q = state.query; const fSec = q? secs.filter(x => JSON.stringify(x).toLowerCase().includes(q)):secs; const fSens = q? sens.filter(x => (x.rel||'').toLowerCase().includes(q)):sens; return `<div class="grid"> <div class="card lg"><h3>Secret findings <span class="tag err">${fmt(fSec.length)}</span></h3> <table><thead><tr><th>kind</th><th>file</th><th>line</th><th>masked</th></tr></thead><tbody> ${fSec.slice(0,300).map(s=>{const r = state.byId.files[s.file]?.rel||s.file; return `<tr><td><span class="badge err">${esc(s.kind)}</span></td><td class="mono">${esc(r)}</td><td>${s.line}</td><td class="mono">${esc(s.masked)}</td></tr>`;}).join('')} </tbody></table></div> <div class="card xs"><h3>Review / Sensitive files <span class="tag warn">${fmt(fSens.length)}</span></h3> <table><thead><tr><th>safety</th><th>file</th></tr></thead><tbody> ${fSens.slice(0,120).map(f=>`<tr><td><span class="badge ${f.safety==='SENSITIVE'?'err':'warn'}">${f.safety[0]}</span></td><td class="mono">${esc(f.rel)}</td></tr>`).join('')} </tbody></table></div> </div>`; } // ---------- Testing (diagnostics) ---------- function viewTesting(){ return `<div class="grid"><div class="card"> <h3>Diagnostics <span class="tag" id="diagStatus">—</span></h3> <p>Runs on your scanner host. Click run to ping <code>?mode=test</code>.</p> <div class="row"><button class="primary" onclick="runDiagnostics()">Run diagnostics</button> <button onclick="runManifestPing()">Ping manifest</button> <button onclick="runMixedTests()">Test browser origin & CORS</button></div> <div id="diagResults" style="margin-top:10px"></div> </div></div>`; } async function runDiagnostics(){ const host = $('#diagResults'); const sp = $('#diagStatus'); if (!host) return; host.innerHTML = '<p>Running…</p>'; if (sp) sp.textContent = 'running'; const u = scannerUrl('test'); if (!u){ host.innerHTML = '<p>Enter scanner URL + token in the sidebar first.</p>'; return; } try { const r = await fetch(u,{cache:'no-store'}); if (!r.ok){ host.innerHTML = `<p class="mono">HTTP ${r.status}</p>`; if(sp) sp.textContent='fail'; return; } const j = await r.json(); const rows = (j.checks||[]).map(c => `<div class="chk ${c.ok?'ok':'err'}"><span class="dot"></span><b>${esc(c.name)}</b><span>${esc(c.info)}</span></div>`).join(''); host.innerHTML = rows; if (sp) sp.textContent = (j.checks||[]).every(c=>c.ok) ? 'all checks passed' : 'issues'; } catch(e){ host.innerHTML = '<p>Network error · '+esc(e.message)+'</p>'; if(sp) sp.textContent='error'; } } async function runManifestPing(){ const host = $('#diagResults'); if (!host) return; const u = scannerUrl('status'); if (!u){ host.innerHTML='<p>set scanner first</p>'; return; } try { const r = await fetch(u,{cache:'no-store'}); const j = await r.json(); host.innerHTML = `<div class="chk ok"><span class="dot"></span><b>status</b><span>${esc(JSON.stringify(j))}</span></div>`; } catch(e){ host.innerHTML = `<div class="chk err"><span class="dot"></span><b>status</b><span>${esc(e.message)}</span></div>`; } } function runMixedTests(){ const host = $('#diagResults'); if (!host) return; const origin = location.origin || location.protocol; const protocol = location.protocol; const fileMode = protocol === 'file:'; host.innerHTML = ` <div class="chk ${fileMode?'err':'ok'}"><span class="dot"></span><b>browser origin</b><span>${esc(origin)}</span></div> <div class="chk ${fileMode?'err':'ok'}"><span class="dot"></span><b>protocol</b><span>${esc(protocol)} ${fileMode?'(blocks fetch)':'(ok)'}</span></div> <div class="chk ok"><span class="dot"></span><b>user agent</b><span>${esc(navigator.userAgent.slice(0,80))}</span></div>`; } // ---------- Health ---------- function viewHealth(){ const m = state.m, s = m.stats||{}, sf = s.safety||{}; const trust = trustScore(); const falseFactor = Math.max(0, 100 - trust); const stale = staleCount(); const orphans = orphanCandidates().length; const dupeCount = (m.duplicates?.by_content?.length||0); return `<div class="grid"> <div class="card xs"><div class="widget ${trust>=70?'ok':trust>=40?'warn':'err'}"><b>${trust}</b><span>Trust score</span></div></div> <div class="card xs"><div class="widget ${falseFactor<30?'ok':falseFactor<60?'warn':'err'}"><b>${falseFactor}</b><span>False factor</span></div></div> <div class="card xs"><div class="widget ${stale<50?'ok':stale<200?'warn':'err'}"><b>${fmt(stale)}</b><span>Stale files (>365d)</span></div></div> <div class="card xs"><div class="widget ${orphans<10?'ok':orphans<50?'warn':'err'}"><b>${fmt(orphans)}</b><span>Orphan candidates</span></div></div> <div class="card"> <h3>Score breakdown</h3> <p>Trust = 100 − penalties for secret findings, sensitive files, orphans, stale files, and duplicates. False factor = 100 − trust.</p> <table><thead><tr><th>signal</th><th>raw</th><th>penalty</th></tr></thead><tbody> <tr><td>Secret findings</td><td>${fmt(s.secrets||0)}</td><td>-${Math.min(30,(s.secrets||0)*2)}</td></tr> <tr><td>Sensitive files</td><td>${fmt(sf.sensitive||0)}</td><td>-${Math.min(15,Math.round((sf.sensitive||0)/10))}</td></tr> <tr><td>Orphan candidates</td><td>${fmt(orphans)}</td><td>-${Math.min(15,Math.round(orphans/20))}</td></tr> <tr><td>Stale files</td><td>${fmt(stale)}</td><td>-${Math.min(10,Math.round(stale/100))}</td></tr> <tr><td>Content duplicates</td><td>${fmt(dupeCount)}</td><td>-${Math.min(10,dupeCount)}</td></tr> </tbody></table> </div> </div>`; } function trustScore(){ const m = state.m, s = m.stats||{}, sf = s.safety||{}; let t = 100; t -= Math.min(30,(s.secrets||0)*2); t -= Math.min(15,Math.round((sf.sensitive||0)/10)); t -= Math.min(15,Math.round(orphanCandidates().length/20)); t -= Math.min(10,Math.round(staleCount()/100)); t -= Math.min(10,(m.duplicates?.by_content?.length||0)); return Math.max(0, Math.min(100, t)); } function staleCount(){ const cutoff = Date.now()/1000 - 365*86400; return (state.m.files||[]).filter(f => f.mtime && f.mtime < cutoff).length; } function orphanCandidates(){ return (state.m.files||[]).filter(f => { if (f.skipped) return false; if (!['server-code','markup','script'].includes(f.role)) return false; if (['entry-page','error-page','static-page'].includes(f.role)) return false; return !(state.edgesOutBySrc[f.id]||[]).length && !(state.routesBySrc[f.id]||[]).length; }); } // ---------- AI Review ---------- function viewAi(){ const rec = recommendations(); return `<div class="grid"><div class="card"> <h3>AI Review <span class="tag accent">${rec.length}</span></h3> <p>Heuristic review based on your scan. No external AI calls. (Connect an API key in the sidebar for future deep-review runs.)</p> ${rec.length? '<table><thead><tr><th>#</th><th>sev</th><th>kind</th><th>target</th><th>recommendation</th></tr></thead><tbody>' + rec.map((r,i)=>`<tr><td>${i+1}</td><td><span class="badge ${r.sev}">${r.sev}</span></td><td>${esc(r.kind)}</td><td class="mono">${esc(r.target||'—')}</td><td>${esc(r.why)}</td></tr>`).join('') + '</tbody></table>' : '<p>Nothing urgent — nice.</p>'} </div></div>`; } function recommendations(){ const m = state.m; if (!m) return []; const out = []; const secByFile = {}; (m.secrets||[]).forEach(s => (secByFile[s.file]=secByFile[s.file]||[]).push(s)); const topSec = Object.entries(secByFile).sort((a,b)=>b[1].length-a[1].length).slice(0,5); topSec.forEach(([fid,arr]) => out.push({sev:'err', kind:'secrets', id:fid, target: state.byId.files[fid]?.rel, why: arr.length + ' secret finding(s): ' + [...new Set(arr.map(s=>s.kind))].join(', ')})); (m.files||[]).filter(f => f.safety==='SENSITIVE').slice(0,5).forEach(f => out.push({sev:'warn', kind:'sensitive', id:f.id, target:f.rel, why:'SENSITIVE class · ' + (f.role||'unknown role')})); orphanCandidates().slice(0,5).forEach(f => out.push({sev:'warn', kind:'orphan', id:f.id, target:f.rel, why:'No inbound or outbound code refs — possibly unused'})); const cutoff = Date.now()/1000 - 730*86400; (m.files||[]).filter(f => f.mtime && f.mtime<cutoff).slice(0,5).forEach(f => out.push({sev:'warn', kind:'stale', id:f.id, target:f.rel, why:'Not modified in 2+ years'})); if (m.duplicates?.by_content?.length) m.duplicates.by_content.slice(0,3).forEach(g => out.push({sev:'warn', kind:'duplicate', target:g.paths[0], why:'Byte-identical copies: ' + g.paths.length + ' files'})); return out; } // ---------- Snippet ---------- function viewSnippet(){ const f = state.byId.files[state.selId]; return `<div class="grid"><div class="card"> <h3>Snippet viewer <span class="tag">${f? esc(f.rel):'no file'}</span></h3> <p>Pulls file content from the scanner (<code>?mode=snippet</code>). Capped at 200KB.</p> <div class="row"><input class="input" id="snipPath" placeholder="relative path" value="${esc(f?.rel||'')}" style="flex:1"> <input class="input" id="snipFrom" placeholder="from line" value="1" style="width:90px"> <input class="input" id="snipTo" placeholder="to line" value="200" style="width:90px"> <button class="primary" onclick="loadSnippet(document.getElementById('snipPath').value, +document.getElementById('snipFrom').value, +document.getElementById('snipTo').value)">Load</button> </div> <div id="snipHost" style="margin-top:10px">${state.lastSnippet? renderSnippet(state.lastSnippet) : '<p>No snippet loaded.</p>'}</div> </div></div>`; } async function loadSnippet(path, from, to){ if (!path) return; const u = scannerUrl('snippet', {path, from: from||1, to: to||200}); if (!u){ setDock('need scanner URL + token'); return; } setDock('GET ' + u); try { const r = await fetch(u,{cache:'no-store'}); if (!r.ok){ setDock(diagnoseHttp(r.status,u)); return; } const j = await r.json(); state.lastSnippet = j; if (state.view==='snippet') render(); else { const host = document.querySelector('#snipHost'); if (host) host.innerHTML = renderSnippet(j); } } catch(e){ setDock('snippet fail · ' + e.message); } } function renderSnippet(j){ if (j.error) return `<p class="mono">${esc(j.error)}</p>`; const lines = (j.content||'').split('\n').map((l,i)=>`<span style="color:var(--mute);display:inline-block;width:40px;text-align:right;padding-right:8px">${j.from+i}</span>${esc(l)}`).join('\n'); return `<div class="kv" style="grid-template-columns:120px 1fr"> <b>path</b><div class="mono">${esc(j.path)}</div> <b>lines</b><div>${j.from}–${j.to} of ${fmt(j.total_lines)}</div> <b>bytes</b><div>${fmt(j.bytes)}${j.truncated?' <span class="badge warn">truncated</span>':''}</div> <b>mtime</b><div>${j.mtime?new Date(j.mtime*1000).toLocaleString():'—'}</div> </div><pre class="snip">${lines}</pre>`; } // ---------- Review queue ---------- function viewReview(){ const files = [...state.review].map(id => state.byId.files[id]).filter(Boolean); return `<div class="grid"><div class="card"> <h3>Review queue <span class="tag warn">${files.length}</span></h3> <p>Files you've flagged. Toggle via Mark-for-review button on any selection card.</p> ${files.length? '<table><thead><tr><th>file</th><th>role</th><th>safety</th><th></th></tr></thead><tbody>' + files.map(f=>`<tr><td class="mono">${esc(f.rel)}</td><td>${esc(f.role||'—')}</td><td><span class="badge ${f.safety==='SAFE'?'ok':'warn'}">${esc(f.safety||'?')}</span></td><td><button onclick="state.selId='${esc(f.id)}';state.view='explorer';navigate()">view</button> <button onclick="toggleReview('${esc(f.id)}');render()">remove</button></td></tr>`).join('') + '</tbody></table>' + `<div class="row" style="margin-top:10px"><button onclick="downloadJson(${JSON.stringify(files).replace(/"/g,'"')}, 'review-queue.json')">Download queue</button><button onclick="clearReview()">Clear all</button></div>` : '<p>Empty. Mark files for review from the Explorer.</p>'} </div></div>`; } function toggleReview(id){ if (state.review.has(id)) state.review.delete(id); else state.review.add(id); ls('simon_review', JSON.stringify([...state.review])); updateReviewCount(); } function clearReview(){ state.review.clear(); ls('simon_review','[]'); updateReviewCount(); render(); } function updateReviewCount(){ $('#ctReview').textContent = state.review.size; } // ---------- Activity ---------- function viewActivity(){ const files = (state.m.files||[]).filter(f => f.mtime).sort((a,b)=>b.mtime-a.mtime).slice(0,120); return `<div class="grid"><div class="card"><h3>Scan activity <span class="tag">most recent</span></h3> <table><thead><tr><th>when</th><th>file</th><th>role</th><th>safety</th></tr></thead><tbody> ${files.map(f=>`<tr><td class="mono">${new Date(f.mtime*1000).toLocaleString()}</td><td class="mono">${esc(f.rel)}</td><td>${esc(f.role||'—')}</td><td><span class="badge ${f.safety==='SAFE'?'ok':'warn'}">${esc(f.safety||'?')}</span></td></tr>`).join('')} </tbody></table></div></div>`; } // ---------- Framework ---------- function viewFramework(){ return `<div class="grid"> <div class="card"><h3>Operator contract</h3> <p>Observe → Hypothesize → Propose → Preview → Approve → Apply → Verify → Rollback. Every AI or human action flows through this loop.</p></div> <div class="card md"><h3>Roles (separation of opinion & keys)</h3> <table><thead><tr><th>role</th><th>can</th><th>cannot</th></tr></thead><tbody> <tr><td>Scout</td><td>observe, fingerprint</td><td>propose or edit</td></tr> <tr><td>Analyst</td><td>hypothesize, score</td><td>edit or ship</td></tr> <tr><td>Drafter</td><td>propose diffs</td><td>approve own work</td></tr> <tr><td>Reviewer</td><td>approve / reject</td><td>draft or apply</td></tr> <tr><td>Operator</td><td>apply, rollback</td><td>draft or approve</td></tr> <tr><td>Auditor</td><td>read logs</td><td>mutate anything</td></tr> </tbody></table></div> <div class="card md"><h3>Proposal envelope</h3> <pre class="snip">{ intent, scope, evidence[], risk_class, confidence, diff_preview, impact, rollback_plan, required_approvals }</pre></div> </div>`; } // ================================================================ // Export // ================================================================ function downloadJson(data, name){ const blob = new Blob([typeof data==='string'?data:JSON.stringify(data,null,2)], {type:'application/json'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name || 'simon-export.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 5000); } $('#btnExport').addEventListener('click', ()=>{ if (!state.m){ setDock('nothing to export'); return; } const v = state.view; const data = v==='risks' ? state.m.secrets : v==='routes' ? state.m.routes : v==='endpoints' ? state.m.endpoints : v==='vars' ? state.m.variables : v==='duplicates' ? state.m.duplicates : v==='review' ? [...state.review].map(id=>state.byId.files[id]).filter(Boolean) : state.m; downloadJson(data, 'simon-' + v + '.json'); }); // ================================================================ // Console backend bridge (simon-console.php) // ================================================================ function consoleUrl(mode, extra={}){ const base = ls('simon_console_url') || ''; const tok = ls('simon_token') || $('#tokenIn')?.value || ''; if (!base) throw new Error('set console backend URL first'); const u = new URL(base); u.searchParams.set('mode', mode); u.searchParams.set('token', tok); Object.entries(extra).forEach(([k,v])=>{ if (v!=null) u.searchParams.set(k, v); }); return u.toString(); } async function consoleCall(mode, body){ let url; try { url = consoleUrl(mode); } catch(e){ throw new Error('backend URL not set — open Sidebar → "Console backend" → paste simon-console.php URL → Ping backend'); } const init = { method:'GET' }; if (body) { init.method='POST'; init.headers={'Content-Type':'application/json'}; init.body=JSON.stringify(body); } let r; try { r = await fetch(url, init); } catch(e){ throw new Error('network fail — is simon-console.php uploaded at '+url.split('?')[0]+' ? open that URL in a new tab; it should return JSON'); } const txt = await r.text(); if (r.status >= 400 && txt.trim().startsWith('<')) { throw new Error('HTTP '+r.status+' · server returned HTML not JSON — simon-console.php probably errored. Check PHP error log on IONOS.'); } let j; try { j = JSON.parse(txt); } catch { throw new Error('non-JSON from backend ('+r.status+'): '+txt.slice(0,160)); } if (!j.ok) throw new Error(j.error||'backend error'); return j; } // ================================================================ // Context menu // ================================================================ function showCtx(x, y, target){ const ctx = $('#ctx'); if (!ctx) return; $('#ctxTitle').textContent = (target.rel || target.name || target.kind || 'item'); ctx.dataset.payload = JSON.stringify(target); ctx.style.left = Math.min(x, window.innerWidth-220) + 'px'; ctx.style.top = Math.min(y, window.innerHeight-300) + 'px'; ctx.classList.add('show'); } function hideCtx(){ $('#ctx')?.classList.remove('show'); } document.addEventListener('click', e=>{ if (!e.target.closest('.ctxmenu')) hideCtx(); }); $('#ctx').addEventListener('click', e=>{ const a = e.target.closest('a'); if (!a) return; const t = JSON.parse($('#ctx').dataset.payload || '{}'); const act = a.dataset.act; hideCtx(); if (act==='preview'){ state.view='snippet'; navigate(); if (t.rel) loadSnippet(t.rel); } if (act==='flow'){ state.flowSeed = t.id || t.rel; state.view='flow'; navigate(); } if (act==='review'){ if (t.id){ state.review.add(t.id); ls('simon_review', JSON.stringify([...state.review])); updateReviewCount(); } } if (act==='unreview'){ if (t.id){ state.review.delete(t.id); ls('simon_review', JSON.stringify([...state.review])); updateReviewCount(); } } if (act==='download'){ const f = state.byId.files[t.id]; if (f) downloadJson(f, 'entity-'+f.id+'.json'); } if (act==='ftp-read') confirmAction(`Pull ${t.rel} from SFTP?`, async()=>{ const j = await consoleCall('sftp-read',{profile: ls('simon_ftp_active'), path: t.rel}); alert('Got ' + j.bytes + ' bytes. See console.'); console.log('[SFTP read]', j); }); if (act==='ftp-move') confirmAction(`Move ${t.rel} on server?`, async()=>{ const dest=prompt('New path:', t.rel); if(!dest) return; await consoleCall('sftp-move',{profile:ls('simon_ftp_active'),path:t.rel,dest,confirm:1}); alert('Moved.'); }); if (act==='ftp-delete') confirmAction(`DELETE ${t.rel} on server? This cannot be undone.`, async()=>{ await consoleCall('sftp-delete',{profile:ls('simon_ftp_active'),path:t.rel,confirm:1}); alert('Deleted.'); }, true); }); // Confirmation modal function confirmAction(msg, fn, destructive=false){ $('#mdlTitle').textContent = destructive ? 'Destructive action' : 'Confirm'; $('#mdlBody').textContent = msg; $('#mdlExtra').innerHTML = '<label style="font-size:11px;color:var(--mute)">Type YES to continue:</label><input class="input" id="mdlWord" style="width:100%;margin-top:4px">'; const m = $('#modal'); m.classList.add('show'); $('#mdlOk').className = destructive ? 'danger' : 'primary'; $('#mdlCancel').onclick = ()=> m.classList.remove('show'); $('#mdlOk').onclick = async()=>{ if ($('#mdlWord').value.trim() !== 'YES') { alert('Type YES to confirm.'); return; } m.classList.remove('show'); try { await fn(); } catch(e) { alert('Failed: '+e.message); } }; } // ================================================================ // View: Flow Graph (SVG, variable/function/file flow) // ================================================================ function renderFlow(host){ host.innerHTML = `<div class="grid"> <div class="card"> <h3>Flow Graph <span class="tag accent">interactive</span></h3> <p style="font-size:11px;color:var(--dim)">Pick an entity then click a node to trace its edges. Double-click to focus. Drag empty space to pan.</p> <div class="row"> <label style="font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:1px">Seed:</label> <select class="input" id="flowSeedSel" style="flex:1"></select> <button id="flowFocus" class="primary">Focus</button> <button id="flowAll">All files</button> </div> <div class="flowHost" id="flowSvgHost"></div> <div id="flowInfo" style="margin-top:10px;font-size:11px;color:var(--dim)">Click a node for details.</div> </div> </div>`; const sel = $('#flowSeedSel'); const files = (state.m.files||[]).slice(0,300); sel.innerHTML = '<option value="">— choose file —</option>' + files.map(f=>`<option value="${esc(f.id)}">${esc(f.rel)}</option>`).join(''); if (state.flowSeed) sel.value = state.flowSeed; $('#flowFocus').onclick = ()=>{ state.flowSeed = sel.value; drawFlow(sel.value); }; $('#flowAll').onclick = ()=> drawFlow(null); drawFlow(sel.value || (files[0] && files[0].id)); } function drawFlow(seedId){ const host = $('#flowSvgHost'); if (!host) return; host.innerHTML = ''; const svgNS = 'http://www.w3.org/2000/svg'; const W = host.clientWidth || 900, H = host.clientHeight || 500; const svg = document.createElementNS(svgNS,'svg'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); host.appendChild(svg); const g = document.createElementNS(svgNS,'g'); svg.appendChild(g); // Collect neighborhood const files = state.m.files || []; const seed = seedId ? state.byId.files[seedId] : null; let nodes = []; if (seed){ const outs = state.edgesOutBySrc[seed.id]||[]; const ins = state.edgesInByTgt[seed.id]||[]; nodes.push(seed); outs.forEach(e=>{ const f = files.find(x=>x.rel===e.target_hint || x.id===e.target); if (f && !nodes.includes(f)) nodes.push(f); }); ins.forEach(e=>{ const f = state.byId.files[e.source]; if (f && !nodes.includes(f)) nodes.push(f); }); // Add variables owned by seed (state.varsByFile[seed.id]||[]).slice(0,12).forEach(v=>{ nodes.push({id:'v:'+v.name, rel:'$'+v.name, _kind:'var'}); }); (state.fnsByFile[seed.id]||[]).slice(0,8).forEach(fn=>{ nodes.push({id:'f:'+fn.name, rel:fn.name+'()', _kind:'fn'}); }); } else { nodes = files.slice(0, 40); } // Layout: seed center, others circle const cx = W/2, cy = H/2, R = Math.min(W,H)/2 - 60; const pos = {}; if (seed){ pos[seed.id] = {x:cx, y:cy}; nodes.filter(n=>n.id!==seed.id).forEach((n,i,a)=>{ const ang = (i / a.length) * Math.PI*2; pos[n.id] = { x: cx + Math.cos(ang)*R, y: cy + Math.sin(ang)*R }; }); } else { nodes.forEach((n,i,a)=>{ const ang = (i / a.length) * Math.PI*2; pos[n.id] = { x: cx + Math.cos(ang)*R, y: cy + Math.sin(ang)*R }; }); } // Edges if (seed){ (state.edgesOutBySrc[seed.id]||[]).forEach(e=>{ const f = files.find(x=>x.rel===e.target_hint || x.id===e.target); if (!f || !pos[f.id]) return; const line = document.createElementNS(svgNS,'line'); line.setAttribute('x1',pos[seed.id].x); line.setAttribute('y1',pos[seed.id].y); line.setAttribute('x2',pos[f.id].x); line.setAttribute('y2',pos[f.id].y); line.setAttribute('class','edge'); g.appendChild(line); }); } // Nodes nodes.forEach(n=>{ const p = pos[n.id]; if (!p) return; const isSeed = seed && n.id===seed.id; const cls = n._kind==='var'?'node-var': n._kind==='fn'?'node-fn':'node-file'; const c = document.createElementNS(svgNS,'circle'); c.setAttribute('cx',p.x); c.setAttribute('cy',p.y); c.setAttribute('r', isSeed?18:10); c.setAttribute('class', cls + (isSeed?' node-sel':'')); c.addEventListener('click', ()=>{ $('#flowInfo').innerHTML = `<b>${esc(n.rel||n.id)}</b> — ${esc(n.role||n._kind||'')} · ${esc(n.summary||'')}`; }); c.addEventListener('dblclick', ()=>{ state.flowSeed = n.id; drawFlow(n.id); $('#flowSeedSel').value = n.id; }); g.appendChild(c); const label = document.createElementNS(svgNS,'text'); label.setAttribute('x',p.x); label.setAttribute('y',p.y + (isSeed?32:20)); label.setAttribute('class','node-label'); label.textContent = (n.rel||n.id).split('/').pop().slice(0,22); g.appendChild(label); }); // Basic pan let dragging=false,ox=0,oy=0,tx=0,ty=0; svg.addEventListener('mousedown',e=>{ dragging=true;ox=e.clientX-tx;oy=e.clientY-ty; }); svg.addEventListener('mousemove',e=>{ if(!dragging) return; tx=e.clientX-ox;ty=e.clientY-oy; g.setAttribute('transform',`translate(${tx},${ty})`); }); document.addEventListener('mouseup',()=>dragging=false); } // ================================================================ // View: .htaccess // ================================================================ async function renderHtaccess(host){ host.innerHTML = `<div class="grid"> <div class="card"> <h3>.htaccess review <span class="tag accent">backend</span></h3> <div class="row"> <input class="input" id="htPath" value=".htaccess" style="flex:1"> <button class="primary" id="btnHtLoad">Parse</button> </div> <div id="htOut" style="margin-top:12px"></div> </div> </div>`; $('#btnHtLoad').onclick = async()=>{ try { const j = await consoleCall('htaccess', { path: $('#htPath').value }); renderHtAnalysis(j.analysis, j.raw); } catch(e) { $('#htOut').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+'</p>'; } }; try { const j = await consoleCall('htaccess', { path: '.htaccess' }); renderHtAnalysis(j.analysis, j.raw); } catch(e){ $('#htOut').innerHTML = '<p style="color:var(--warn)">backend not connected: '+esc(e.message)+'</p>'; } } function renderHtAnalysis(a, raw){ if (!a) return; const flagRow = r => `<tr><td>${r.line}</td><td>${esc(r.warn||r.ok||r.info||'')}</td><td>${r.warn?'<span class="badge warn">warn</span>':(r.info?'<span class="badge">info</span>':'<span class="badge ok">ok</span>')}</td></tr>`; $('#htOut').innerHTML = ` <div class="row"> <div class="widget accent" style="flex:1"><b>${a.rules_total}</b><span>Total rules</span></div> <div class="widget ${a.redirects.length?'warn':''}" style="flex:1"><b>${a.redirects.length}</b><span>Redirects</span></div> <div class="widget ${a.rewrites.length?'accent':''}" style="flex:1"><b>${a.rewrites.length}</b><span>Rewrites</span></div> <div class="widget ${a.flags.length?'err':'ok'}" style="flex:1"><b>${a.flags.length}</b><span>Flags</span></div> </div> <h4 style="margin:12px 0 4px">Flags & security notes</h4> ${a.flags.length ? `<table><thead><tr><th>Line</th><th>Note</th><th>Level</th></tr></thead><tbody>${a.flags.map(flagRow).join('')}</tbody></table>` : '<p>No warnings.</p>'} <h4 style="margin:12px 0 4px">Rewrite rules</h4> ${a.rewrites.length ? `<table><thead><tr><th>Line</th><th>Pattern / Cond</th><th>Target / Match</th><th>Flags</th></tr></thead><tbody>${a.rewrites.map(r=>`<tr><td>${r.line}</td><td class="mono">${esc(r.pattern||r.test||'')}</td><td class="mono">${esc(r.target||r.match||'')}</td><td>${esc(r.flags||'')}</td></tr>`).join('')}</tbody></table>` : '<p>None.</p>'} <h4 style="margin:12px 0 4px">Raw</h4> <pre class="snip">${esc(raw||'')}</pre> `; } // ================================================================ // View: FTP Browser // ================================================================ async function renderFtp(host){ const active = ls('simon_ftp_active')||''; host.innerHTML = `<div class="grid"> <div class="card"> <h3>Server explorer <span class="tag ${active?'ok':'warn'}">${active?'profile: '+esc(active):'no profile selected'}</span></h3> <div class="row"> <select id="ftpProfile" class="input" style="min-width:200px"></select> <input class="input" id="ftpPathLive" placeholder="/homepages/.../htdocs/" style="flex:1"> <button class="primary" id="btnFtpGo">List</button> <button id="btnFtpUp">↑ Up</button> </div> <div id="ftpOut" style="margin-top:10px" class="ftpList"></div> </div> </div>`; // Load profile list try { const j = await consoleCall('vault-list'); const ftps = (j.profiles||[]).filter(p=>p.kind==='sftp'||p.kind==='ftp'); $('#ftpProfile').innerHTML = ftps.map(p=>`<option value="${esc(p.id)}" ${p.id===active?'selected':''}>${esc(p.label)} (${p.kind})</option>`).join('') || '<option>— none saved —</option>'; } catch(e){ $('#ftpOut').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+'</p>'; return; } $('#btnFtpGo').onclick = async()=>{ const id = $('#ftpProfile').value; const path = $('#ftpPathLive').value || '/'; ls('simon_ftp_active', id); try { const list = await consoleCall('sftp-list', { profile:id, path }); drawFtp(list); } catch(e) { try { const list = await consoleCall('ftp-list', { profile:id, path }); drawFtp(list); } catch(e2){ $('#ftpOut').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+' / '+esc(e2.message)+'</p>'; } } }; $('#btnFtpUp').onclick = ()=>{ const p=$('#ftpPathLive').value||'/'; const parts=p.split('/').filter(Boolean); parts.pop(); $('#ftpPathLive').value = '/'+parts.join('/')+(parts.length?'/':''); $('#btnFtpGo').click(); }; } function drawFtp(list){ const out = $('#ftpOut'); if (!out) return; const items = (list.items||[]).sort((a,b)=>(b.is_dir?1:0)-(a.is_dir?1:0) || a.name.localeCompare(b.name)); out.innerHTML = `<div style="font-size:10px;color:var(--mute);padding:4px 8px">${esc(list.path||'')} · ${items.length} items</div>` + items.map(it=>{ const cls = it.is_dir?'dir': (it.safety==='SENSITIVE'?'sens': it.safety==='REVIEW'?'rev':'saf'); const icon = it.is_dir?'📁':'📄'; return `<div class="row ${cls}" data-n="${esc(it.name)}" data-d="${it.is_dir?1:0}"> <span>${icon}</span> <span>${esc(it.name)}</span> <span>${it.size!=null?fmt(it.size):''}</span> <span>${it.mtime?new Date(it.mtime*1000).toLocaleString():(it.date||'')}</span> <span>${esc(it.safety||'')}</span> </div>`; }).join(''); out.querySelectorAll('.row[data-n]').forEach(r=>{ r.addEventListener('click', ()=>{ if (r.dataset.d === '1') { $('#ftpPathLive').value = ($('#ftpPathLive').value||'/').replace(/\/?$/,'/') + r.dataset.n + '/'; $('#btnFtpGo').click(); } }); r.addEventListener('contextmenu', e=>{ e.preventDefault(); const full = ($('#ftpPathLive').value||'/').replace(/\/?$/,'/') + r.dataset.n; showCtx(e.pageX, e.pageY, { kind:'ftp', name: r.dataset.n, rel: full }); }); }); } // ================================================================ // View: Database // ================================================================ async function renderDb(host){ host.innerHTML = `<div class="grid"> <div class="card"> <h3>Database explorer <span class="tag accent">mysqli</span></h3> <div class="row"> <select id="dbProfile" class="input" style="min-width:200px"></select> <button class="primary" id="btnDbTables">List tables</button> </div> <div id="dbTables" style="margin-top:10px"></div> <h4 style="margin:12px 0 4px">Read-only query</h4> <textarea class="input" id="dbQ" style="width:100%;min-height:70px;font-family:ui-monospace,Menlo,monospace" placeholder="SELECT * FROM users LIMIT 20"></textarea> <div class="row"><button id="btnDbRun" class="primary">Run SELECT</button><small style="color:var(--mute);font-size:11px">Only SELECT allowed. LIMIT enforced.</small></div> <div id="dbOut" style="margin-top:10px"></div> </div> </div>`; try { const j = await consoleCall('vault-list'); const dbs = (j.profiles||[]).filter(p=>p.kind==='mysql'); $('#dbProfile').innerHTML = dbs.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}</option>`).join('') || '<option>— none saved —</option>'; } catch(e){ $('#dbOut').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+'</p>'; } $('#btnDbTables').onclick = async()=>{ try { const j = await consoleCall('db-tables',{profile:$('#dbProfile').value}); $('#dbTables').innerHTML = '<table><thead><tr><th>Table</th><th></th></tr></thead><tbody>'+j.tables.map(t=>`<tr><td class="mono">${esc(t)}</td><td><button onclick="dbDescribe('${esc(t)}')" style="font-size:10px;padding:3px 8px">describe</button></td></tr>`).join('')+'</tbody></table>'; } catch(e){ $('#dbTables').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+'</p>'; } }; $('#btnDbRun').onclick = async()=>{ try { const j = await consoleCall('db-query',{profile:$('#dbProfile').value, q: $('#dbQ').value}); drawRows(j.rows); } catch(e){ $('#dbOut').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+'</p>'; } }; } async function dbDescribe(t){ try { const j = await consoleCall('db-describe',{profile:$('#dbProfile').value, table:t}); $('#dbOut').innerHTML = '<h4>'+esc(t)+'</h4><table><thead><tr>'+Object.keys(j.columns[0]||{}).map(k=>'<th>'+esc(k)+'</th>').join('')+'</tr></thead><tbody>'+j.columns.map(r=>'<tr>'+Object.values(r).map(v=>'<td class="mono">'+esc(v)+'</td>').join('')+'</tr>').join('')+'</tbody></table>'; } catch(e){ $('#dbOut').innerHTML = '<p style="color:var(--danger)">'+esc(e.message)+'</p>'; } } function drawRows(rows){ if (!rows || !rows.length){ $('#dbOut').innerHTML = '<p>No rows.</p>'; return; } const cols = Object.keys(rows[0]); $('#dbOut').innerHTML = '<table><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>'+rows.slice(0,500).map(r=>'<tr>'+cols.map(c=>'<td class="mono">'+esc(r[c])+'</td>').join('')+'</tr>').join('')+'</tbody></table>'; } // ================================================================ // View: Compare / Multi-manifest tabs // ================================================================ if (!state.tabs) state.tabs = []; // {id,label,manifest} if (!state.tabActive) state.tabActive = null; function renderCompare(host){ host.innerHTML = `<div class="grid"> <div class="card"> <h3>Multi-environment compare <span class="tag accent">${state.tabs.length} tabs</span></h3> <div class="tabs" id="tabBar"></div> <div class="row"> <input class="input" id="tabUrl" placeholder="https://other-site.com/simon/simon-scanner.php?mode=manifest&token=..." style="flex:1"> <button class="primary" id="btnAddTab">Add tab</button> <button id="btnUseCurrent">Use current</button> </div> <div id="cmpOut" style="margin-top:12px"></div> </div> </div>`; const bar = $('#tabBar'); const renderBar = ()=>{ bar.innerHTML = state.tabs.map(t=>`<div class="tab ${t.id===state.tabActive?'on':''}" data-id="${t.id}">${esc(t.label)}<span class="x" data-x="${t.id}">×</span></div>`).join('') || '<small style="color:var(--mute)">no tabs</small>'; bar.querySelectorAll('.tab').forEach(el=>{ el.addEventListener('click', e=>{ if (e.target.dataset.x){ state.tabs = state.tabs.filter(t=>t.id!==e.target.dataset.x); renderBar(); return; } state.tabActive = el.dataset.id; renderBar(); compareViews(); }); }); }; renderBar(); $('#btnAddTab').onclick = async()=>{ const url = $('#tabUrl').value.trim(); if (!url) return; try { let m; // Direct if scanner URL allows CORS; else through backend fetcher try { const r = await fetch(url); m = await r.json(); } catch { const j = await consoleCall('manifest-fetch',{url}); m = j.manifest; } const id = 't'+Date.now(); state.tabs.push({id, label: new URL(url).host, manifest:m, url}); state.tabActive = id; renderBar(); compareViews(); } catch(e) { alert('Load failed: '+e.message); } }; $('#btnUseCurrent').onclick = ()=>{ if (!state.m) return alert('No current manifest'); const id='t'+Date.now(); state.tabs.push({id,label:'current',manifest:state.m}); state.tabActive=id; renderBar(); compareViews(); }; compareViews(); } function compareViews(){ const out = $('#cmpOut'); if (!out) return; if (state.tabs.length < 2){ out.innerHTML = '<p>Add at least two tabs to diff.</p>'; return; } const [a,b] = state.tabs.slice(-2); const sa = a.manifest.stats||{}, sb = b.manifest.stats||{}; const row = (k,va,vb) => `<tr><td>${esc(k)}</td><td>${fmt(va)}</td><td>${fmt(vb)}</td><td>${fmt((vb||0)-(va||0))}</td></tr>`; const keys = ['files','folders','routes','endpoints','secrets','functions','classes','constants']; out.innerHTML = `<h4>${esc(a.label)} vs ${esc(b.label)}</h4> <table><thead><tr><th>Metric</th><th>${esc(a.label)}</th><th>${esc(b.label)}</th><th>Δ</th></tr></thead> <tbody>${keys.map(k=>row(k, sa[k], sb[k])).join('')}</tbody></table> ${diffList('Routes', (a.manifest.routes||[]).map(r=>r.url), (b.manifest.routes||[]).map(r=>r.url))} ${diffList('Files', (a.manifest.files||[]).map(f=>f.rel), (b.manifest.files||[]).map(f=>f.rel))}`; } function diffList(title, A, B){ const sa = new Set(A), sb = new Set(B); const onlyA = [...sa].filter(x=>!sb.has(x)).slice(0,50); const onlyB = [...sb].filter(x=>!sa.has(x)).slice(0,50); return `<h4>${esc(title)} diff</h4> <div class="row" style="align-items:flex-start;gap:14px"> <div style="flex:1"><b style="font-size:11px;color:var(--danger)">Only in A (${onlyA.length})</b>${onlyA.map(x=>`<div class="mono">${esc(x)}</div>`).join('')||'—'}</div> <div style="flex:1"><b style="font-size:11px;color:var(--ok)">Only in B (${onlyB.length})</b>${onlyB.map(x=>`<div class="mono">${esc(x)}</div>`).join('')||'—'}</div> </div>`; } // ================================================================ // Vault UI wiring // ================================================================ $('#consoleIn')?.addEventListener('change', e => ls('simon_console_url', e.target.value.trim())); $('#consoleIn').value = ls('simon_console_url') || ''; $('#btnConsolePing')?.addEventListener('click', async()=>{ try { const j = await consoleCall('ping'); $('#consolePingOut').textContent = 'OK · ssh2='+j.ssh2+' ftp='+j.ftp+' mysqli='+j.mysqli; } catch(e){ $('#consolePingOut').textContent = 'FAIL · '+e.message; } }); $('#btnSaveFtp')?.addEventListener('click', async()=>{ const label = $('#ftpLabel').value || 'ftp-'+Date.now(); const data = {host:$('#ftpHost').value,user:$('#ftpUser').value,pass:$('#ftpPass').value,port:+$('#ftpPort').value||22,path:$('#ftpPath').value,notes:$('#ftpNotes').value}; try { await consoleCall('vault-put', {id:label, label, kind:$('#ftpProto').value, data}); $('#ftpPass').value=''; alert('Saved to vault.'); loadVaultList(); } catch(e){ alert(e.message); } }); $('#btnListFtp')?.addEventListener('click', ()=>{ const id = $('#ftpLabel').value; if (id) ls('simon_ftp_active', id); state.view='ftp'; navigate(); }); $('#btnSaveDb')?.addEventListener('click', async()=>{ const label = $('#dbLabel').value || 'db-'+Date.now(); const data = {host:$('#dbHost').value,user:$('#dbUser').value,pass:$('#dbPass').value,db:$('#dbName').value,port:+$('#dbPort').value||3306}; try { await consoleCall('vault-put', {id:label, label, kind:'mysql', data}); $('#dbPass').value=''; alert('Saved.'); loadVaultList(); } catch(e){ alert(e.message); } }); $('#btnProbeDb')?.addEventListener('click', async()=>{ try { const j = await consoleCall('db-probe', {host:$('#dbHost').value,user:$('#dbUser').value,pass:$('#dbPass').value,db:$('#dbName').value,port:+$('#dbPort').value||3306}); alert('OK · '+(j.v||'')+' · db='+(j.d||'')); } catch(e){ alert(e.message); } }); $('#btnRefreshVault')?.addEventListener('click', loadVaultList); async function loadVaultList(){ const el = $('#vaultList'); if (!el) return; try { const j = await consoleCall('vault-list'); el.innerHTML = (j.profiles||[]).map(p=>`<div style="display:flex;align-items:center;gap:6px;padding:3px 0"><span class="badge ${p.kind==='mysql'?'ok':'warn'}">${esc(p.kind)}</span><span style="flex:1">${esc(p.label)}</span><button data-del="${esc(p.id)}" style="font-size:10px;padding:2px 6px;color:var(--danger)">×</button></div>`).join('') || '<i>empty</i>'; el.querySelectorAll('[data-del]').forEach(b=>b.addEventListener('click', async()=>{ await consoleCall('vault-delete',{id:b.dataset.del}); loadVaultList(); })); } catch(e){ el.innerHTML = '<span style="color:var(--danger)">'+esc(e.message)+'</span>'; } } setTimeout(loadVaultList, 500); // Expose for inline onclick handlers window.state = state; window.navigate = navigate; window.render = render; window.loadSnippet = loadSnippet; window.downloadJson = downloadJson; window.toggleReview = toggleReview; window.clearReview = clearReview; window.runDiagnostics = runDiagnostics; window.runManifestPing = runManifestPing; window.runMixedTests = runMixedTests; window.dbDescribe = dbDescribe; render(); </script> </body></html>
Save file
Quick jump
open a path
Open