-
Notifications
You must be signed in to change notification settings - Fork 0
a11y(console): retire duplicate loading-state interaction lane #1205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| --- scanner/dashboard/console.html | ||
| +++ scanner/dashboard/console.html | ||
| @@ -46,6 +46,7 @@ | ||
| .err{color:var(--crit);font-weight:600} | ||
| code{background:var(--bg);padding:1px 5px;border-radius:4px} | ||
| .hidden{display:none} | ||
| + [aria-busy="true"]{pointer-events:none;opacity:0.6} | ||
| </style> | ||
| </head> | ||
| <body> | ||
| @@ -135,8 +136,8 @@ | ||
| <td>${s.total}</td><td>${pill(s.deploy_blocking,"var(--crit)")}</td><td>${pill(s.new_blocking,"var(--high)")}</td></tr>`).join("")||'<tr><td colspan="6" class="muted">No scans. POST to /api/v1/scans from CI.</td></tr>'; | ||
| document.querySelectorAll("tr.scan").forEach(tr=>{ | ||
| - tr.onclick=()=>detail(tr.dataset.id,tr); | ||
| - tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); detail(tr.dataset.id,tr); } }); | ||
| + tr.onclick=()=>{ if(tr.getAttribute("aria-busy")==="true")return; detail(tr.dataset.id,tr); }; | ||
| + tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if(tr.getAttribute("aria-busy")==="true")return; detail(tr.dataset.id,tr); } }); | ||
| }); | ||
| }catch(e){ $("#msg").classList.remove("hidden");$("#app").classList.add("hidden"); | ||
| $("#msg").innerHTML=`<span class="err">${esc(e.message)}</span>`; } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>AppGuardrail Console</title> | ||
| <!-- | ||
| Org console for the AppGuardrail control plane (`appguardrail serve`). | ||
| Paste an org API key, then it calls GET /api/v1/scans (same origin) to show | ||
| scan history, the deploy-blocking trend, and per-scan detail. | ||
| ponytail: no framework, no build step. Consumes the API in controlplane.py. | ||
| --> | ||
| <style> | ||
| :root{ | ||
| --bg:#F4F5F7; --surface:#FFFFFF; --text:#1A1D24; --muted:#5B6472; | ||
| --border:#D6DAE0; --divider:#E7EAEF; --primary:#256EF4; --on-primary:#fff; | ||
| --crit:#D93B3B; --high:#E06C00; --warn:#B7791F; --info:#5B6472; --ok:#1E874B; | ||
| --radius:12px; | ||
| } | ||
| *{box-sizing:border-box} | ||
| body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text)} | ||
| header{display:flex;gap:12px;align-items:center;padding:14px 20px;background:var(--surface);border-bottom:1px solid var(--border);flex-wrap:wrap} | ||
| .logo{width:26px;height:26px;border-radius:7px;background:var(--primary)} | ||
| h1{font-size:16px;margin:0;font-weight:700} | ||
| main{max-width:1000px;margin:0 auto;padding:20px} | ||
| input{font:inherit;padding:8px 10px;border:1px solid var(--border);border-radius:8px;min-width:280px} | ||
| button{font:inherit;font-weight:600;padding:8px 14px;border:0;border-radius:8px;background:var(--primary);color:var(--on-primary);cursor:pointer} | ||
| button.ghost{background:var(--surface);color:var(--primary);border:1px solid var(--border)} | ||
| .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:16px} | ||
| .stats{display:flex;gap:12px;flex-wrap:wrap} | ||
| .stat{flex:1;min-width:130px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px} | ||
| .stat .n{font-size:26px;font-weight:800} | ||
| .stat .l{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.03em} | ||
| table{width:100%;border-collapse:collapse} | ||
| th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--divider);font-size:13px} | ||
| th{font-size:11px;color:var(--muted);text-transform:uppercase} | ||
| tr.scan{cursor:pointer} | ||
| tr.scan:hover{background:var(--bg)} | ||
| input:focus-visible, button:focus-visible, tr.scan:focus-visible, .bar:focus-visible, #detail:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } | ||
| .close-btn{float:right;border:0;background:transparent;font-size:16px;cursor:pointer;color:var(--muted);padding:0 4px;margin-top:-2px} | ||
| .close-btn:hover{color:var(--text)} | ||
| .pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;color:#fff} | ||
| .trend{display:flex;align-items:flex-end;gap:4px;height:60px;margin-top:8px} | ||
| .bar{flex:1;min-width:6px;border-radius:3px 3px 0 0} | ||
| .muted{color:var(--muted)} | ||
| .err{color:var(--crit);font-weight:600} | ||
| code{background:var(--bg);padding:1px 5px;border-radius:4px} | ||
| .hidden{display:none} | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <header> | ||
| <div class="logo"></div> | ||
| <h1>AppGuardrail Console</h1> | ||
| <span class="muted" id="conn" role="status" aria-live="polite" aria-atomic="true"></span> | ||
| <span style="flex:1"></span> | ||
| <input id="key" type="password" placeholder="Org API key (agk_…)" autocomplete="off" aria-label="Organization API key"> | ||
| <button id="connect">Connect</button> | ||
| <button id="logout" class="ghost hidden">Sign out</button> | ||
| </header> | ||
| <main> | ||
| <div id="msg" class="card muted" role="alert" aria-live="polite">Paste your org API key and connect to view scan history.</div> | ||
| <div id="app" class="hidden"> | ||
| <div class="stats" id="stats"></div> | ||
| <div class="card"> | ||
| <strong>Deploy-blocking trend</strong> <span class="muted">(oldest → newest)</span> | ||
| <div class="trend" id="trend"></div> | ||
| </div> | ||
| <div class="card"> | ||
| <strong>Scan history</strong> | ||
| <table id="history"><thead><tr><th scope="col">When</th><th scope="col">Repo</th><th scope="col">Commit</th><th scope="col">Total</th><th scope="col">Blocking</th><th scope="col">New</th></tr></thead><tbody></tbody></table> | ||
| </div> | ||
| <div class="card hidden" id="detail"></div> | ||
| </div> | ||
| </main> | ||
| <script> | ||
| const $=s=>document.querySelector(s); | ||
| const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); | ||
| const SEV={CRITICAL:"var(--crit)",HIGH:"var(--high)",WARNING:"var(--warn)",INFO:"var(--info)"}; | ||
| let KEY=sessionStorage.getItem("ag_key")||""; | ||
| let currentDetailRequest=0; | ||
| let lastDetailFocus=null; | ||
|
|
||
| function closeDetail(){ | ||
| currentDetailRequest+=1; | ||
| const detail=$("#detail"); | ||
| detail.classList.add("hidden"); | ||
| detail.innerHTML=""; | ||
| if(lastDetailFocus instanceof HTMLElement && lastDetailFocus.isConnected){ | ||
| lastDetailFocus.removeAttribute("aria-busy"); | ||
| delete lastDetailFocus.dataset.detailRequest; | ||
| lastDetailFocus.focus(); | ||
| } | ||
| lastDetailFocus=null; | ||
| } | ||
| document.addEventListener("keydown",e=>{ | ||
| if(e.key==="Escape"&&!$("#detail").classList.contains("hidden"))closeDetail(); | ||
| }); | ||
|
|
||
| async function api(path){ | ||
| const r=await fetch(path,{headers:{Authorization:"Bearer "+KEY}}); | ||
| if(r.status===401)throw new Error("Invalid API key."); | ||
| if(!r.ok)throw new Error("Request failed ("+r.status+")."); | ||
| return r.json(); | ||
| } | ||
| function pill(n,color){return n>0?`<span class="pill" style="background:${color}">${n}</span>`:`<span class="muted">0</span>`;} | ||
| function scrollDetailIntoView(element){ | ||
| if(window.matchMedia("(prefers-reduced-motion: reduce)").matches){ | ||
| element.scrollIntoView(); | ||
| }else{ | ||
| element.scrollIntoView({behavior:"smooth"}); | ||
| } | ||
| } | ||
|
|
||
| async function load(){ | ||
| try{ | ||
| const {scans}=await api("/api/v1/scans"); | ||
| $("#msg").classList.add("hidden");$("#app").classList.remove("hidden"); | ||
| $("#logout").classList.remove("hidden"); | ||
| $("#conn").textContent=scans.length+" scan"+(scans.length===1?"":"s"); | ||
| const latest=scans[0]||{severity_counts:{}}; | ||
| const c=latest.severity_counts||{}; | ||
| $("#stats").innerHTML=[ | ||
| ["Latest deploy-blocking",latest.deploy_blocking||0], | ||
| ["New since last scan",latest.new_blocking||0], | ||
| ["Critical",c.CRITICAL||0], | ||
| ["Scans stored",scans.length], | ||
| ].map(([l,n])=>`<div class="stat"><div class="l">${l}</div><div class="n">${n}</div></div>`).join(""); | ||
| const ord=[...scans].reverse(); | ||
| const max=Math.max(1,...ord.map(s=>s.deploy_blocking||0)); | ||
| $("#trend").innerHTML=ord.map(s=>{const h=Math.round(6+((s.deploy_blocking||0)/max)*54); | ||
| const col=(s.deploy_blocking||0)>0?"var(--crit)":"var(--ok)"; | ||
| return `<div class="bar" tabindex="0" role="img" aria-label="${esc(s.created_at)}: ${esc(String(s.deploy_blocking||0))} blocking" title="${esc(s.created_at)}: ${esc(String(s.deploy_blocking||0))} blocking" style="height:${h}px;background:${col}"></div>`;}).join("")||'<span class="muted">No scans yet.</span>'; | ||
| $("#history tbody").innerHTML=scans.map(s=>`<tr class="scan" data-id="${s.id}" tabindex="0" role="button" title="View scan details"> | ||
| <td>${esc(s.created_at)}</td><td>${esc(s.repo||"—")}</td><td><code>${esc((s.commit||"—").slice(0,10))}</code></td> | ||
| <td>${s.total}</td><td>${pill(s.deploy_blocking,"var(--crit)")}</td><td>${pill(s.new_blocking,"var(--high)")}</td></tr>`).join("")||'<tr><td colspan="6" class="muted">No scans. POST to /api/v1/scans from CI.</td></tr>'; | ||
| document.querySelectorAll("tr.scan").forEach(tr=>{ | ||
| tr.onclick=()=>detail(tr.dataset.id,tr); | ||
| tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); detail(tr.dataset.id,tr); } }); | ||
| }); | ||
| }catch(e){ $("#msg").classList.remove("hidden");$("#app").classList.add("hidden"); | ||
| $("#msg").innerHTML=`<span class="err">${esc(e.message)}</span>`; } | ||
| } | ||
| async function detail(id,tr){ | ||
| const requestId=++currentDetailRequest; | ||
| const d=$("#detail"); | ||
| lastDetailFocus=tr||document.activeElement; | ||
| d.setAttribute("tabindex","-1"); | ||
| if(tr){ | ||
| tr.dataset.detailRequest=String(requestId); | ||
| tr.setAttribute("aria-busy","true"); | ||
| } | ||
| d.classList.remove("hidden"); | ||
| d.innerHTML='<div aria-live="polite" class="muted">Loading scan details...</div>'; | ||
| try{ | ||
| const s=await api("/api/v1/scans/"+id); | ||
| if(requestId!==currentDetailRequest)return; | ||
| const rows=(s.findings||[]).map(f=>`<tr><td><span class="pill" style="background:${SEV[f.severity]||'var(--info)'}">${esc(f.severity)}</span></td> | ||
| <td><code>${esc(f.rule_id)}</code></td><td>${esc((f.message||"").split("\n")[0].slice(0,120))}</td> | ||
| <td><code>${esc(f.file)}:${esc(f.line)}</code></td></tr>`).join(""); | ||
| d.innerHTML=`<button type="button" class="close-btn" aria-label="Close details">✕</button><strong>Scan #${esc(s.id)}</strong> <span class="muted">${esc(s.created_at)} · ${esc(s.repo||"—")}</span> | ||
| <table style="margin-top:8px"><thead><tr><th scope="col">Severity</th><th scope="col">Rule</th><th scope="col">Finding</th><th scope="col">Location</th></tr></thead> | ||
| <tbody>${rows||'<tr><td colspan="4" class="muted">No findings.</td></tr>'}</tbody></table>`; | ||
| d.querySelector(".close-btn").addEventListener("click",closeDetail); | ||
| scrollDetailIntoView(d); | ||
| d.focus({preventScroll:true}); | ||
| }catch(e){ | ||
| if(requestId!==currentDetailRequest)return; | ||
| d.innerHTML='<button type="button" class="close-btn" aria-label="Close details">✕</button><div role="alert" class="err">Error loading details: '+esc(e.message)+'</div>'; | ||
| d.querySelector(".close-btn").addEventListener("click",closeDetail); | ||
| scrollDetailIntoView(d); | ||
| d.focus({preventScroll:true}); | ||
| }finally{ | ||
| if(tr&&tr.dataset.detailRequest===String(requestId)){ | ||
| tr.removeAttribute("aria-busy"); | ||
| delete tr.dataset.detailRequest; | ||
| } | ||
| } | ||
| } | ||
| $("#connect").onclick=async()=>{KEY=$("#key").value.trim();if(!KEY)return;sessionStorage.setItem("ag_key",KEY);$("#key").value="";$("#connect").disabled=true;$("#connect").setAttribute("aria-busy","true");$("#connect").textContent="Connecting...";await load();$("#connect").disabled=false;$("#connect").removeAttribute("aria-busy");$("#connect").textContent="Connect";}; | ||
| $("#key").addEventListener("keydown",e=>{if(e.key==="Enter")$("#connect").click();}); | ||
| $("#logout").onclick=()=>{sessionStorage.removeItem("ag_key");location.reload();}; | ||
| if(KEY)load(); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,12 @@ | ||||||
| from playwright.sync_api import sync_playwright | ||||||
|
|
||||||
| def run(): | ||||||
| with sync_playwright() as p: | ||||||
| browser = p.chromium.launch() | ||||||
| page = browser.new_page() | ||||||
| page.goto("file:///app/scanner/dashboard/console.html") | ||||||
| page.wait_for_timeout(1000) | ||||||
| print("Page title:", page.title()) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 4000 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- test_ui.py ---'
cat -n test_ui.py
printf '%s\n' '--- dependency/config files ---'
git ls-files | rg '(^|/)(package(-lock)?\.json|pyproject\.toml|requirements[^/]*\.txt|Pipfile|poetry\.lock|pytest\.ini|tox\.ini|Makefile|README(\.md)?)$' | while IFS= read -r f; do
printf '\n--- %s ---\n' "$f"
sed -n '1,220p' "$f"
doneRepository: ContextualWisdomLab/appguardrail Length of output: 42933 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- console.html title and nearby metadata ---'
rg -n -C 3 '<title|AppGuardrail Console|console' scanner/dashboard/console.html
printf '%s\n' '--- test references and browser-test setup ---'
rg -n -C 3 'test_ui|playwright|sync_playwright|page\.title|console\.html' . -g '!requirements-release.txt' -g '!requirements-test.txt'Repository: ContextualWisdomLab/appguardrail Length of output: 6000
- print("Page title:", page.title())
+ assert page.title() == "AppGuardrail Console"📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| browser.close() | ||||||
|
|
||||||
| run() | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify the configured test collector imports test_ui.py and whether
# Playwright is declared as a project or test dependency.
fd -a -t f '^(pyproject\.toml|pytest\.ini|tox\.ini|setup\.cfg|requirements.*\.txt)$' . |
while IFS= read -r file; do
rg -n -i -C 2 'playwright|python_files|testpaths|addopts' "$file" || true
done
rg -n -C 3 'sync_playwright|^def run|^run\(\)$' test_ui.pyRepository: ContextualWisdomLab/appguardrail Length of output: 501 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository files relevant to Python tests and dependencies ---'
git ls-files | rg '(^|/)(test_ui\.py|pytest\.ini|tox\.ini|setup\.cfg|setup\.py|pyproject\.toml|requirements[^/]*(\.txt)?|Pipfile|environment\.yml|\.github/workflows/)' || true
printf '%s\n' '--- test_ui.py ---'
cat -n test_ui.py
printf '%s\n' '--- pytest configuration and dependency declarations ---'
for file in pytest.ini tox.ini setup.cfg setup.py pyproject.toml Pipfile; do
if [ -f "$file" ]; then
printf '\n[%s]\n' "$file"
cat -n "$file"
fi
done
for file in $(git ls-files | rg '(^|/)requirements[^/]*(\.txt)?$|(^|/)environment\.yml$' || true); do
printf '\n[%s]\n' "$file"
cat -n "$file"
doneRepository: ContextualWisdomLab/appguardrail Length of output: 50388 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Playwright declarations ---'
for file in requirements-test.in requirements-test.txt .github/workflows/tests.yml; do
if [ -f "$file" ]; then
printf '\n[%s]\n' "$file"
rg -n -i -C 2 'playwright|pytest|test_ui|pytest ' "$file" || true
fi
done
printf '%s\n' '--- pytest discovery references ---'
rg -n -i -C 2 'pytest|test_ui|test_.*\.py|python_files|testpaths' README.md CONTRIBUTING.md .github/workflows/tests.yml 2>/dev/null || trueRepository: ContextualWisdomLab/appguardrail Length of output: 4838
CI는 독립 실행 스크립트이면 🤖 Prompt for AI Agents |
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: ContextualWisdomLab/appguardrail
Length of output: 3993
🏁 Script executed:
Repository: ContextualWisdomLab/appguardrail
Length of output: 4644
🏁 Script executed:
Repository: ContextualWisdomLab/appguardrail
Length of output: 10296
Sensitive Data Exposure
Reachability: External
Exploitability: Moderate
CWE: CWE-319 — Cleartext Transmission of Sensitive Information
HTTPS를 강제한 뒤 조직 API 키를 전송하십시오.
appguardrail serve는 TLS 없이http://주소로 제어 플레인을 제공합니다. 콘솔은 같은 scheme으로 요청을 만들고Authorization: Bearer ${KEY}를 전송하므로, 원격 호스트에서 실행하면 조직 API 키가 평문으로 노출될 수 있습니다. HTTPS redirect와 HSTS를 적용하고 HTTP에서는 콘솔과 API 키 인증을 제공하지 마십시오.🤖 Prompt for AI Agents