feat(dashboard): M8 admin control-plane UI — read-only live dashboard (scale #2 GATE3)#56
Conversation
scale #2 M8/GATE3. read API(M7, PR #49) 위 thin client 운영자 대시보드. 정적 HTML+vanilla JS(빌드·node·새 dep 0), loopback HTTP 서빙. staleness가 항상 1급 신호로 렌더 → silent-staleness 사고의 구조적 수정("침묵 불가능"). - runtime/dashboard.py: ThreadingWSGIServer + 정적 서빙(null-byte/traversal 가드, resolve+is_relative_to, ext allowlist, nosniff, index no-store), SIGTERM/SIGINT 별도 스레드 graceful shutdown. - cli `dashboard`: loopback bind validate(비-loopback은 require_auth 없이 거부, exit 2), store_from_args 재사용, --host/--port. - read_api.py: GET /findings 라우트(app() 본문 분기, QUERY_STRING disposition 멀티필터, 무효 400, 서버 정렬 권위 order_findings_for_dashboard, secretHash만/raw secret 0). - ui/static: index.html/app.js/logic.mjs/style.css — 4패널(헬스 rollup/커버리지 org N·M/ 큐 백로그/findings disposition 필터), 6상태(미평가≠0), dark WCAG AA 토큰+never-color-alone, aria-live 티어, 폴링 5s+timeout+backoff+keep-last-good, prefers-reduced-motion. - 테스트: dashboard server/static/a11y-static, /findings route, FR-A1..A12 trace(TRACE.md), node --test 순수 로직(logic.mjs). 게이트: uv run pytest 1113 passed/4 skipped(env-gated smoke), node --test 43/43, ruff clean. 제품/설계 출처: grill-to-spec requirements.md/design.md(rev2, 멀티에이전트 리뷰 반영). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt
There was a problem hiding this comment.
CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
There was a problem hiding this comment.
Code Review
This pull request implements the live admin control-plane UI (M8) for the security-scanner, introducing a loopback WSGI dashboard server, a static thin client, and comprehensive test suites including static accessibility checks and requirement tracing. The review feedback highlights a critical thread-safety concern when sharing the store instance across concurrent threads, a polling bug where failing requests trigger exponential backoff without clearing the active interval (causing overlapping requests), and opportunities to improve error state handling in the findings panel logic and UI.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| """ | ||
| validate_read_api_server_config(config) | ||
|
|
||
| app = make_dashboard_app(store, static_dir) |
There was a problem hiding this comment.
🧵 Thread-Safety Issue: 공유 store 인스턴스 사용 위험
ThreadingWSGIServer는 여러 스레드에서 동시에 요청을 처리합니다. 하지만 단일 store 인스턴스가 모든 요청 스레드 간에 공유되고 있습니다.
만약 store 내부에서 스레드 안전하지 않은 boto3 Table 리소스 등을 사용하고 있다면, 동시 요청 발생 시 세션/커넥션 충돌로 인해 예외가 발생하거나 데이터가 오염될 수 있습니다.
해결 방안:
- 각 요청 스레드별로 독립된
store인스턴스(또는boto3세션 및 테이블)를 생성하여 사용하도록make_dashboard_app내부 구조를 개선하거나, - 동시성 요구량이 크지 않다면 단일 스레드 기반의 동기식 서버 사용을 검토해 주세요.
References
- Do not share boto3 Table resource instances across multiple threads because they are not thread-safe. For concurrent operations, use a low-level DynamoDB client, create a new boto3 Session and Table instance per thread, or fall back to serial execution.
| async function fetchSnapshot() { | ||
| if (state.pollInFlight) { | ||
| return; // re-entrancy guard (no overlapping polls) | ||
| } | ||
| state.pollInFlight = true; | ||
| setRefreshDisabled(true); | ||
| try { | ||
| const snapshot = await fetchWithTimeout("/snapshot", FETCH_TIMEOUT_MS); | ||
| state.lastGoodSnapshot = snapshot; | ||
| state.failures = 0; | ||
| state.stale = false; | ||
| applySnapshot(snapshot); | ||
| renderStaleBanner(false); | ||
| } catch (_err) { | ||
| state.failures += 1; | ||
| state.stale = true; | ||
| // keep-last-good: re-render the last successful snapshot, mark stale. | ||
| if (state.lastGoodSnapshot) { | ||
| applySnapshot(state.lastGoodSnapshot); | ||
| } | ||
| renderStaleBanner(true); | ||
| scheduleBackoff(); | ||
| } finally { | ||
| state.pollInFlight = false; | ||
| setRefreshDisabled(false); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔄 Bug: 폴링 실패 시 지수 백오프(Exponential Backoff)가 무시되고 요청 폭풍이 발생하는 문제
현재 구조에서는 fetchSnapshot이 실패할 경우 scheduleBackoff()를 통해 재시도 setTimeout을 예약합니다. 하지만 startPolling()에서 생성된 5초 주기의 setInterval이 해제되지 않고 계속 실행됩니다.
이로 인해 서버가 다운되었을 때 정기 폴링(5초 주기)과 백오프 재시도 타이머가 동시에 작동하여 서버에 더 많은 요청을 보내는 '요청 폭풍(Request Storm)' 현상이 발생하며, 지수 백오프 메커니즘이 완전히 무력화됩니다.
해결 방안:
에러 발생 시 기존 setInterval을 중지(clearInterval)하고, 성공적으로 데이터를 복구했을 때 다시 정기 폴링을 시작하도록 개선해야 합니다.
async function fetchSnapshot() {
if (state.pollInFlight) {
return; // re-entrancy guard (no overlapping polls)
}
state.pollInFlight = true;
setRefreshDisabled(true);
try {
const snapshot = await fetchWithTimeout("/snapshot", FETCH_TIMEOUT_MS);
state.lastGoodSnapshot = snapshot;
if (state.failures > 0) {
state.failures = 0;
startPolling();
}
state.failures = 0;
state.stale = false;
applySnapshot(snapshot);
renderStaleBanner(false);
} catch (_err) {
state.failures += 1;
state.stale = true;
if (state.intervalId !== null) {
clearInterval(state.intervalId);
state.intervalId = null;
}
if (state.lastGoodSnapshot) {
applySnapshot(state.lastGoodSnapshot);
}
renderStaleBanner(true);
scheduleBackoff();
} finally {
state.pollInFlight = false;
setRefreshDisabled(false);
}
}| if (!hasFindings(resp)) { | ||
| return { fields: [], severity: "muted", state: "loading", rows: [] }; | ||
| } |
There was a problem hiding this comment.
⚠️ Improvement: Findings 패널의 에러 상태 처리 누락
현재 findingsPanelModel은 loading, empty, ok 상태만 반환할 수 있으며 error 상태를 처리하는 로직이 없습니다. 이로 인해 최초 로드 시 /findings API 호출이 실패하면 화면이 에러 메시지 대신 평생 '불러오는 중…' 상태에 갇히게 됩니다.
resp에 에러 정보가 포함되어 있을 때 state: "error"를 반환하도록 개선하는 것이 좋습니다.
if (resp && resp.error) {
return { fields: [], severity: "muted", state: "error", rows: [] };
}
if (!hasFindings(resp)) {
return { fields: [], severity: "muted", state: "loading", rows: [] };
}| } catch (_err) { | ||
| // findings are non-critical for the live signal; keep last-good rows. | ||
| if (state.lastFindings) { | ||
| renderFindingsPanel(state.lastFindings); |
There was a problem hiding this comment.
⚠️ Improvement: Findings 패널 에러 발생 시 에러 상태 전달
앞서 logic.mjs에서 제안한 에러 상태 처리를 지원하기 위해, 캐시된 lastFindings가 없는 상태에서 API 호출이 실패하면 { error: true } 객체를 전달하여 화면에 에러 UI가 표시되도록 합니다.
// findings are non-critical for the live signal; keep last-good rows.
if (state.lastFindings) {
renderFindingsPanel(state.lastFindings);
} else {
renderFindingsPanel({ error: true });
}…ror state
gemini-code-assist 리뷰 후속(M8 UI):
- app.js: 폴링 실패 시 setInterval을 clearInterval하고 backoff 타이머만 재시도를 구동,
성공 시 정기 폴 재개. 기존엔 5s interval과 backoff가 동시에 돌아 지수 backoff가
무력화되고 장애 시 요청 폭주(HIGH).
- logic.mjs + app.js: findingsPanelModel에 error 상태 추가 + fetchFindings 실패 시
캐시 없으면 {error:true} 전달 → 최초 /findings 실패가 'loading' 영구 고착되지 않고
기존 error UI(index.html/style.css) 노출(MEDIUM ×2).
- test_ui_logic.mjs: findingsPanelModel error 케이스 추가(node --test 44 pass).
thread-safety(공유 boto3 store)는 loopback 단일 운영자 trust model로 위험이 제한되어
별도 추적 이슈로 분리. CodeQL red는 전부 pre-existing non-M8 alert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt
리뷰 후속 (gemini-code-assist + CodeQL)커스텀 github 에이전트로 트리아지 후 처리했습니다. 수정은
CodeQL (red): open alert 6건은 전부 pre-existing non-M8( 검증: |
PR #56 리뷰의 마지막 항목(공유 boto3 store thread-safety)을 PR 내에서 해결. make_dashboard_app/run_dashboard가 단일 store 대신 store_factory를 받아 read 요청마다 자체 store(boto3 resource)를 생성 → ThreadingWSGIServer 요청 스레드 간 boto3 resource/Session 공유 제거. 정적 서빙은 store 미접근(무영향). CLI는 lambda: store_from_args(args) factory 전달. 테스트 호출부 factory로 갱신. 게이트: pytest 1149 passed/4 skipped, node --test 44/44, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt
|
✅ thread-safety도 PR 내에서 해결( |
M8 — 관리자 컨트롤플레인 UI (scale #2 / GATE3)
read API(M7, #49) 위에서 운영자가 500-repo 스캔 현황을 한 화면에서 보는 read-only 라이브 관리자 대시보드. 정적 HTML+vanilla JS thin client(빌드툴·node·새 런타임 의존성 0), loopback HTTP 서빙.
왜
원래 silent-staleness 사고(daily scan이 죽었는데 publish가 계속 성공 → 갱신 안 된 DB 재발행, 아무도 못 알아챔)의 구조적 수정: 대시보드가 스토어를 라이브 read하므로 staleness/breach가 항상 1급 시각 신호로 렌더링되어 "침묵이 구조적으로 불가능".
무엇 (아키텍처 — 경계 분리)
runtime/dashboard.py—ThreadingWSGIServer+ 정적 서빙. 가드: null-byte 400(resolve 전),resolve()+is_relative_totraversal 403, ext allowlist,X-Content-Type-Options: nosniff,index.htmlno-store. SIGTERM/SIGINT 별도 스레드 graceful shutdown.security-scanner dashboard— loopback bind validate(비-loopback은require_auth없이 거부 → exit 2),store_from_args재사용,--host/--port(기본 127.0.0.1:8787).read_api.pyGET /findings—app()본문 분기,QUERY_STRINGdisposition 멀티필터, 무효값 사전검증 400, 서버 정렬 권위(order_findings_for_dashboard), redacted(secretHash만, raw secret 0).ui/static—index.html/app.js/logic.mjs/style.css. 4패널(헬스 rollup·커버리지 org N/M·큐 백로그·findings disposition 필터), 6상태(available=false→"미평가", 0 아님), dark WCAG AA 토큰 + never-color-alone,aria-live티어, 폴링 5s + timeout(AbortController) + 지수 backoff + keep-last-good + STALE 배너,prefers-reduced-motion.검증
uv run pytest→ 1113 passed, 4 skipped(env-gated live smoke/ddb), 회귀 0node --test(logic.mjs 순수 로직) → 43/43uv run ruff check(M8 파일) → cleanTRACE.md,tests/test_fr_trace.py)범위
ssh -L로 충분), wheelforce-include(dev는 source 트리)출처
grill-to-spec(
requirements.md→design.mdrev2, 멀티에이전트 리뷰 18건+gap 4건 반영) → 오토파일럿 빌드(M1–M6) → UI 파인튜닝. 비주얼: 로컬security-scanner dashboard(또는 in-memory 프리뷰)로 확인.🤖 Generated with Claude Code
https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt