관리자 빈 화면 진단 로그 추가

This commit is contained in:
Yun Chan 2026-07-03 23:24:09 +09:00
parent 85608b41ee
commit 64e06a1185
6 changed files with 224 additions and 5 deletions

View file

@ -23,6 +23,7 @@ from .persona_repository import materialize_seed_personas
from .session_persistence import ensure_review_tables from .session_persistence import ensure_review_tables
from .routes import auth as auth_routes from .routes import auth as auth_routes
from .routes import admin as admin_routes from .routes import admin as admin_routes
from .routes import client_diagnostics as client_diagnostics_routes
from .routes import eval as eval_routes from .routes import eval as eval_routes
from .routes import kb as kb_routes from .routes import kb as kb_routes
from .routes import personas as persona_routes from .routes import personas as persona_routes
@ -96,6 +97,7 @@ _upload_root.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(_upload_root)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(_upload_root)), name="uploads")
app.include_router(auth_routes.router) app.include_router(auth_routes.router)
app.include_router(client_diagnostics_routes.router)
app.include_router(admin_routes.router) app.include_router(admin_routes.router)
app.include_router(persona_routes.router) app.include_router(persona_routes.router)
app.include_router(session_routes.router) app.include_router(session_routes.router)

View file

@ -0,0 +1,101 @@
"""Client-side boot/render diagnostic telemetry.
The route is intentionally public: it is used when the SPA cannot render enough
UI to let an authenticated operator report the failure from inside the app.
Only structural counts and short error summaries are logged; page text is not
accepted or persisted.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Request, status
from pydantic import BaseModel, ConfigDict, Field
router = APIRouter(tags=["diagnostics"])
logger = logging.getLogger("vignette.client_diagnostics")
class ClientDiagnosticError(BaseModel):
model_config = ConfigDict(extra="ignore")
kind: str = ""
detail: str = ""
at: str | None = None
class ClientDiagnosticRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
reason: str = "unknown"
path: str = ""
href: str = ""
asset: str = ""
elapsed_ms: int | None = None
body_text_length: int | None = None
root_text_length: int | None = None
root_child_count: int | None = None
visible_nodes: int | None = None
document_ready_state: str = ""
viewport: str = ""
user_agent: str = ""
errors: list[ClientDiagnosticError] = Field(default_factory=list)
def _short(value: Any, limit: int) -> str:
text = "" if value is None else str(value)
return text[:limit]
def _bounded_int(value: int | None, upper: int = 1_000_000) -> int | None:
if value is None:
return None
return max(0, min(int(value), upper))
def _diagnostic_log_payload(payload: ClientDiagnosticRequest, request: Request) -> dict[str, Any]:
client_host = request.client.host if request.client else ""
return {
"reason": _short(payload.reason, 80),
"path": _short(payload.path, 300),
"href": _short(payload.href, 800),
"asset": _short(payload.asset, 160),
"elapsed_ms": _bounded_int(payload.elapsed_ms),
"body_text_length": _bounded_int(payload.body_text_length),
"root_text_length": _bounded_int(payload.root_text_length),
"root_child_count": _bounded_int(payload.root_child_count, 50_000),
"visible_nodes": _bounded_int(payload.visible_nodes, 50_000),
"document_ready_state": _short(payload.document_ready_state, 40),
"viewport": _short(payload.viewport, 80),
"user_agent": _short(payload.user_agent, 300),
"origin": _short(request.headers.get("origin"), 300),
"referer": _short(request.headers.get("referer"), 800),
"cf_ray": _short(request.headers.get("cf-ray"), 80),
"client_host": _short(client_host, 80),
"errors": [
{
"kind": _short(error.kind, 80),
"detail": _short(error.detail, 900),
"at": _short(error.at, 80),
}
for error in payload.errors[:5]
],
}
@router.post("/client-diagnostics", status_code=status.HTTP_202_ACCEPTED)
async def record_client_diagnostic(
payload: ClientDiagnosticRequest, request: Request
) -> dict[str, bool]:
"""Log a minimal browser-side boot/render failure report."""
safe_payload = _diagnostic_log_payload(payload, request)
logger.warning(
"client_diagnostic %s",
json.dumps(safe_payload, ensure_ascii=False, separators=(",", ":")),
)
return {"ok": True}

View file

@ -0,0 +1,56 @@
"""Client diagnostics telemetry tests."""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from .routes import client_diagnostics
class ClientDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
async def test_client_diagnostic_logs_safe_structural_payload(self) -> None:
payload = client_diagnostics.ClientDiagnosticRequest(
reason="watchdog",
path="/admin/users",
href="https://vignette.chanpaca.net/admin/users",
asset="index-R5KK7hZI.js",
elapsed_ms=3510,
body_text_length=0,
root_text_length=0,
root_child_count=1,
visible_nodes=0,
document_ready_state="complete",
viewport="1920x1080",
user_agent="Playwright",
errors=[
client_diagnostics.ClientDiagnosticError(
kind="unhandledrejection",
detail="Cannot read properties of null",
at="2026-07-03T00:00:00.000Z",
)
],
)
request = SimpleNamespace(
client=SimpleNamespace(host="127.0.0.1"),
headers={
"origin": "https://vignette.chanpaca.net",
"referer": "https://vignette.chanpaca.net/admin/users",
"cf-ray": "test-ray",
},
)
with self.assertLogs("vignette.client_diagnostics", level="WARNING") as logs:
result = await client_diagnostics.record_client_diagnostic(payload, request)
joined = "\n".join(logs.output)
self.assertEqual(result, {"ok": True})
self.assertIn("client_diagnostic", joined)
self.assertIn("/admin/users", joined)
self.assertIn("body_text_length", joined)
self.assertIn("Cannot read properties of null", joined)
self.assertNotIn("body_text=", joined)
if __name__ == "__main__":
unittest.main()

View file

@ -67,6 +67,7 @@
(function () { (function () {
var errors = []; var errors = [];
var loadedAt = Date.now(); var loadedAt = Date.now();
var sentDiagnostics = {};
function short(value) { function short(value) {
return String(value || "").slice(0, 900); return String(value || "").slice(0, 900);
} }
@ -109,6 +110,58 @@
); );
}).length; }).length;
} }
function diagnosticApiBase() {
var host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
return "https://api-vignette.chanpaca.net";
}
if (host === "vnet.18ka.net") {
return "https://api-vnet.18ka.net";
}
return "/api";
}
function sendDiagnostic(reason, textLength, visible) {
var key = reason + ":" + window.location.pathname;
if (sentDiagnostics[key]) return;
sentDiagnostics[key] = true;
var root = document.getElementById("root");
var payload = {
reason: reason,
path: window.location.pathname,
href: window.location.href,
asset: assetLabel(),
elapsed_ms: Date.now() - loadedAt,
body_text_length: textLength,
root_text_length: root && root.innerText ? root.innerText.trim().length : 0,
root_child_count: root ? root.childElementCount : 0,
visible_nodes: visible,
document_ready_state: document.readyState,
viewport: window.innerWidth + "x" + window.innerHeight,
user_agent: short(window.navigator && window.navigator.userAgent),
errors: errors.slice(-5),
};
var url = diagnosticApiBase().replace(/\/$/, "") + "/client-diagnostics";
var body = JSON.stringify(payload);
try {
if (window.navigator && navigator.sendBeacon) {
var blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon(url, blob)) return;
}
} catch (error) {
/* fetch fallback below */
}
try {
fetch(url, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: body,
}).catch(function () {});
} catch (error) {
/* diagnostics must never affect app boot */
}
}
function render(reason) { function render(reason) {
var path = window.location.pathname; var path = window.location.pathname;
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0; var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
@ -122,9 +175,16 @@
if (old) old.remove(); if (old) old.remove();
return; return;
} }
sendDiagnostic(reason, text.length, visible);
var panel = document.getElementById("vignette-boot-diagnostic"); var panel = document.getElementById("vignette-boot-diagnostic");
if (!panel) { if (!panel) {
if (!document.body) {
window.setTimeout(function () {
render(reason);
}, 100);
return;
}
panel = document.createElement("section"); panel = document.createElement("section");
panel.id = "vignette-boot-diagnostic"; panel.id = "vignette-boot-diagnostic";
panel.setAttribute("role", "alert"); panel.setAttribute("role", "alert");

View file

@ -10,8 +10,8 @@
- 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099` - 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099`
- 공개 웹: `https://vignette.chanpaca.net` - 공개 웹: `https://vignette.chanpaca.net`
- 공개 API: `https://api-vignette.chanpaca.net` - 공개 API: `https://api-vignette.chanpaca.net`
- 최신 앱 배포 소스: 2026-07-03 commit `b67fd5d4`. - 최신 앱 배포 소스: 2026-07-03 client diagnostics rollout.
- 최신 Cloudflare Pages production deploy: 2026-07-03 manual deploy `https://8f6aba1c.vignette-b1q.pages.dev`, branch `main`, custom domain assets `assets/index-R5KK7hZI.js` + `assets/index-DzdAkRsn.css`, boot diagnostic HTML 포함. - 최신 Cloudflare Pages production deploy: 2026-07-03 manual deploy `https://a948284e.vignette-b1q.pages.dev`, branch `main`, custom domain assets `assets/index-R5KK7hZI.js` + `assets/index-DzdAkRsn.css`, boot diagnostic HTML`/client-diagnostics` 송신 포함.
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr` - Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
- 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q``305 passed` - 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q``305 passed`
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부. - X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
@ -30,7 +30,7 @@
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속. - M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
- 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas``P1..P7` 반환. - 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas``P1..P7` 반환.
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다. - Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
- 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users``/admin/tickets` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시한다. 앱 route-level error boundary도 렌더 예외를 full white page 대신 진단 화면으로 대체하며, 0-height main도 진단 패널로 노출한다. `index.html`에는 React/module 실행 자체가 실패해도 3.5초 뒤 `Vignette 화면 진단`을 body에 직접 띄우는 boot watchdog이 있다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 5 passed, dist file JS-missing watchdog smoke passed, custom domain 새 asset 확인. - 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users``/admin/tickets` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시한다. 앱 route-level error boundary도 렌더 예외를 full white page 대신 진단 화면으로 대체하며, 0-height main도 진단 패널로 노출한다. `index.html`에는 React/module 실행 자체가 실패해도 3.5초 뒤 `Vignette 화면 진단`을 body에 직접 띄우는 boot watchdog이 있고, 같은 진단을 `/client-diagnostics`로 보내 API 로그에 남긴다. 검증: `npm run typecheck`, `npm run build`, `python -X utf8 -B -m pytest -p no:cacheprovider app/test_client_diagnostics.py -q`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 5 passed, dist file JS-missing watchdog smoke passed, custom domain 새 asset 확인.
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010``ENVIRONMENT=dev`로 떠 있다. - 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010``ENVIRONMENT=dev`로 떠 있다.

View file

@ -963,7 +963,7 @@
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>11 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed, super_admin learner review principal is promoted to admin for evaluation/worksheet/review-status loaders, admin_access-only learner remains blocked, and learner worksheet write remains 403</td></tr> <tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>11 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed, super_admin learner review principal is promoted to admin for evaluation/worksheet/review-status loaders, admin_access-only learner remains blocked, and learner worksheet write remains 403</td></tr>
<tr><td>Admin access delegation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / <code>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 5 passed + web typecheck/build passed. 실제 <code>admin</code> role은 <code>admin_access=false</code> 세션이어도 <code>can_access_role(Role.ADMIN)</code>으로 관리자 API를 열고, 승인된 관리자 또는 <code>admin_access</code> 계정은 <code>/admin*</code>에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 <code>initialPathForUser</code>가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 <code>/</code> 또는 로그인 완료 후 <code>/admin</code>으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다.</td></tr> <tr><td>Admin access delegation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / <code>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 5 passed + web typecheck/build passed. 실제 <code>admin</code> role은 <code>admin_access=false</code> 세션이어도 <code>can_access_role(Role.ADMIN)</code>으로 관리자 API를 열고, 승인된 관리자 또는 <code>admin_access</code> 계정은 <code>/admin*</code>에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 <code>initialPathForUser</code>가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 <code>/</code> 또는 로그인 완료 후 <code>/admin</code>으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다.</td></tr>
<tr><td>SPA route scroll reset</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"</code> / Pages deploy</td><td>Typecheck passed and focused E2E 1 passed. <code>App.tsx</code> resets document scroll to top on <code>pathname</code> changes, so moving from a long admin page to short <code>/admin/access</code> no longer preserves stale <code>scrollY</code> and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous <code>scrollY=1000</code>, after clicking <code>권한</code> <code>scrollY=0</code>, heading <code>역할, 그룹, 접근 범위</code> in viewport. Live custom domain now serves <code>assets/index-R5KK7hZI.js</code>/<code>assets/index-DzdAkRsn.css</code>.</td></tr> <tr><td>SPA route scroll reset</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"</code> / Pages deploy</td><td>Typecheck passed and focused E2E 1 passed. <code>App.tsx</code> resets document scroll to top on <code>pathname</code> changes, so moving from a long admin page to short <code>/admin/access</code> no longer preserves stale <code>scrollY</code> and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous <code>scrollY=1000</code>, after clicking <code>권한</code> <code>scrollY=0</code>, heading <code>역할, 그룹, 접근 범위</code> in viewport. Live custom domain now serves <code>assets/index-R5KK7hZI.js</code>/<code>assets/index-DzdAkRsn.css</code>.</td></tr>
<tr><td>Admin silent-blank diagnostics</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / dist watchdog smoke</td><td>Typecheck passed and route-guard E2E 5 passed. Admin now normalizes malformed <code>/admin/users</code> payload fields such as <code>cohort_ids=null</code>, <code>active_sessions=null</code>, and <code>created_at=null</code>, plus malformed <code>/admin/tickets</code> payloads such as <code>summary=null</code>, instead of throwing during render. The screen shows <code>관리자 데이터 진단</code> with section/path/asset and broken field keys. App-level route render errors now fall back to a full-page diagnostic instead of a white screen, and zero-height main content still falls back to the admin diagnostic panel. If the React module itself fails before mount, <code>index.html</code> boot watchdog appends <code>Vignette 화면 진단</code> directly to body; file-based JS-missing smoke confirmed the panel appears with path/asset/bodyText/visibleNodes.</td></tr> <tr><td>Admin silent-blank diagnostics</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>python -X utf8 -B -m pytest -p no:cacheprovider app/test_client_diagnostics.py -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / dist watchdog smoke</td><td>Typecheck passed, build passed, client diagnostic endpoint test passed, and route-guard E2E 5 passed. Admin now normalizes malformed <code>/admin/users</code> payload fields such as <code>cohort_ids=null</code>, <code>active_sessions=null</code>, and <code>created_at=null</code>, plus malformed <code>/admin/tickets</code> payloads such as <code>summary=null</code>, instead of throwing during render. The screen shows <code>관리자 데이터 진단</code> with section/path/asset and broken field keys. App-level route render errors now fall back to a full-page diagnostic instead of a white screen, and zero-height main content still falls back to the admin diagnostic panel. If the React module itself fails before mount, <code>index.html</code> boot watchdog appends <code>Vignette 화면 진단</code> directly to body and also posts structural counts, current path, asset name, viewport, and short JS error summaries to public <code>/client-diagnostics</code>; the API logs this as <code>client_diagnostic</code> without accepting page body text.</td></tr>
<tr><td>OAuth/SAML admin landing</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q</code> / public runtime restart</td><td>36 passed; OAuth/SAML callback now normalizes generic saved <code>next</code> paths such as <code>/learn</code> and <code>/teach</code> to <code>/admin</code> for admin-entitled users while preserving deep links such as <code>/learn/session/...</code>. Live proof after commits <code>998acb44</code>/<code>6b6241f4</code> and latest boot-diagnostic deploy <code>b67fd5d4</code>: local/public <code>/health</code> returned <code>prod</code>, <code>db=true</code>, <code>engine=true</code>; custom domain now serves <code>assets/index-R5KK7hZI.js</code>/<code>assets/index-DzdAkRsn.css</code> plus boot diagnostic HTML; unauthenticated <code>/personas</code> remains <code>401</code>.</td></tr> <tr><td>OAuth/SAML admin landing</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q</code> / public runtime restart</td><td>36 passed; OAuth/SAML callback now normalizes generic saved <code>next</code> paths such as <code>/learn</code> and <code>/teach</code> to <code>/admin</code> for admin-entitled users while preserving deep links such as <code>/learn/session/...</code>. Live proof after commits <code>998acb44</code>/<code>6b6241f4</code> and latest boot-diagnostic deploy <code>b67fd5d4</code>: local/public <code>/health</code> returned <code>prod</code>, <code>db=true</code>, <code>engine=true</code>; custom domain now serves <code>assets/index-R5KK7hZI.js</code>/<code>assets/index-DzdAkRsn.css</code> plus boot diagnostic HTML; unauthenticated <code>/personas</code> remains <code>401</code>.</td></tr>
<tr><td>Session read-model DB readiness</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 prod 503 원인은 운영 DB의 <code>app.turns.provider_events</code> 컬럼 누락이었다. 운영 DB hotfix 후 <code>/teacher/dashboard</code> code path는 <code>source=database</code>로 복구됐다. 현재 <code>db.healthcheck()</code>, runtime table readiness, deploy preflight DB mode는 <code>app.turns</code> 음성 메타 컬럼 5개(<code>audio_ref</code>, <code>silence_ms</code>, <code>speech_rate</code>, <code>barge_in</code>, <code>provider_events</code>), <code>app.session_review_status</code> worksheet 컬럼, <code>app.safety_events</code> 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>로 복구됐다.</td></tr> <tr><td>Session read-model DB readiness</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 prod 503 원인은 운영 DB의 <code>app.turns.provider_events</code> 컬럼 누락이었다. 운영 DB hotfix 후 <code>/teacher/dashboard</code> code path는 <code>source=database</code>로 복구됐다. 현재 <code>db.healthcheck()</code>, runtime table readiness, deploy preflight DB mode는 <code>app.turns</code> 음성 메타 컬럼 5개(<code>audio_ref</code>, <code>silence_ms</code>, <code>speech_rate</code>, <code>barge_in</code>, <code>provider_events</code>), <code>app.session_review_status</code> worksheet 컬럼, <code>app.safety_events</code> 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>로 복구됐다.</td></tr>
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr> <tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>
@ -993,7 +993,7 @@
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr> <tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
<tr><td>Phase 3 artifact gates</td><td><code>py -3.11 -X utf8 scripts\check-phase3-artifacts.py --help</code> + <code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_dataset_export.py app/test_phase3_artifact_checker.py -q</code></td><td>16 passed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates, and approved/dry-run dataset JSONL required keys, row count, privacy, and PII shape. Actual pilot evidence still external.</td></tr> <tr><td>Phase 3 artifact gates</td><td><code>py -3.11 -X utf8 scripts\check-phase3-artifacts.py --help</code> + <code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_dataset_export.py app/test_phase3_artifact_checker.py -q</code></td><td>16 passed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates, and approved/dry-run dataset JSONL required keys, row count, privacy, and PII shape. Actual pilot evidence still external.</td></tr>
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr> <tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td>2026-07-03 production deploy from commit <code>b67fd5d4</code>; preview <code>https://8f6aba1c.vignette-b1q.pages.dev</code>; custom domain serves <code>assets/index-R5KK7hZI.js</code>/<code>assets/index-DzdAkRsn.css</code> and includes <code>vignette-boot-diagnostic</code> HTML.</td></tr> <tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td>2026-07-03 production deploy; preview <code>https://a948284e.vignette-b1q.pages.dev</code>; custom domain serves <code>assets/index-R5KK7hZI.js</code>/<code>assets/index-DzdAkRsn.css</code> and includes <code>vignette-boot-diagnostic</code> HTML plus <code>/client-diagnostics</code> boot telemetry sender.</td></tr>
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login?dev_dashboard_redteam=20260630-recovered</code></td><td>2026-06-30 recheck: login HTML HEAD 200. Public API 복구 후에도 web custom domain은 정상이다.</td></tr> <tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login?dev_dashboard_redteam=20260630-recovered</code></td><td>2026-06-30 recheck: login HTML HEAD 200. Public API 복구 후에도 web custom domain은 정상이다.</td></tr>
<tr><td>Legacy Live2D routes</td><td><code>/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js</code></td><td>404</td></tr> <tr><td>Legacy Live2D routes</td><td><code>/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js</code></td><td>404</td></tr>
<tr><td>Compose template</td><td><code>docker compose -f infra\docker-compose.yml config --quiet</code></td><td>Template path is valid with dummy required env. API build context now uses repo root; stale <code>rag.server</code> sidecar removed.</td></tr> <tr><td>Compose template</td><td><code>docker compose -f infra\docker-compose.yml config --quiet</code></td><td>Template path is valid with dummy required env. API build context now uses repo root; stale <code>rag.server</code> sidecar removed.</td></tr>