diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 97c5fff..fef81b8 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -23,6 +23,7 @@ from .persona_repository import materialize_seed_personas from .session_persistence import ensure_review_tables from .routes import auth as auth_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 kb as kb_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.include_router(auth_routes.router) +app.include_router(client_diagnostics_routes.router) app.include_router(admin_routes.router) app.include_router(persona_routes.router) app.include_router(session_routes.router) diff --git a/apps/api/app/routes/client_diagnostics.py b/apps/api/app/routes/client_diagnostics.py new file mode 100644 index 0000000..384ebc5 --- /dev/null +++ b/apps/api/app/routes/client_diagnostics.py @@ -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} diff --git a/apps/api/app/test_client_diagnostics.py b/apps/api/app/test_client_diagnostics.py new file mode 100644 index 0000000..7be334b --- /dev/null +++ b/apps/api/app/test_client_diagnostics.py @@ -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() diff --git a/apps/web/index.html b/apps/web/index.html index d83c78d..55f0c44 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -67,6 +67,7 @@ (function () { var errors = []; var loadedAt = Date.now(); + var sentDiagnostics = {}; function short(value) { return String(value || "").slice(0, 900); } @@ -109,6 +110,58 @@ ); }).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) { var path = window.location.pathname; var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0; @@ -122,9 +175,16 @@ if (old) old.remove(); return; } + sendDiagnostic(reason, text.length, visible); var panel = document.getElementById("vignette-boot-diagnostic"); if (!panel) { + if (!document.body) { + window.setTimeout(function () { + render(reason); + }, 100); + return; + } panel = document.createElement("section"); panel.id = "vignette-boot-diagnostic"; panel.setAttribute("role", "alert"); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 35cb959..594a631 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -10,8 +10,8 @@ - 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099` - 공개 웹: `https://vignette.chanpaca.net` - 공개 API: `https://api-vignette.chanpaca.net` -- 최신 앱 배포 소스: 2026-07-03 commit `b67fd5d4`. -- 최신 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 포함. +- 최신 앱 배포 소스: 2026-07-03 client diagnostics rollout. +- 최신 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` - 최신 백엔드 회귀: `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 없으면 거부. @@ -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은 후속. - 페르소나 저작/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가 필요하다. -- 관리자 기본 진입 경로와 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로 사용했다. - 공개 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`로 떠 있다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index f58d2f4..2964544 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -963,7 +963,7 @@ P2a RBAC/audit/visibilityapp.test_rbac_idor11 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 Admin access delegationC:\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 / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding" / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards" / npm run typecheck13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 5 passed + web typecheck/build passed. 실제 admin role은 admin_access=false 세션이어도 can_access_role(Role.ADMIN)으로 관리자 API를 열고, 승인된 관리자 또는 admin_access 계정은 /admin*에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 initialPathForUser가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 / 또는 로그인 완료 후 /admin으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다. SPA route scroll resetnpm run typecheck / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll" / Pages deployTypecheck passed and focused E2E 1 passed. App.tsx resets document scroll to top on pathname changes, so moving from a long admin page to short /admin/access no longer preserves stale scrollY and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous scrollY=1000, after clicking 권한 scrollY=0, heading 역할, 그룹, 접근 범위 in viewport. Live custom domain now serves assets/index-R5KK7hZI.js/assets/index-DzdAkRsn.css. - Admin silent-blank diagnosticsnpm run typecheck / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards" / dist watchdog smokeTypecheck passed and route-guard E2E 5 passed. Admin now normalizes malformed /admin/users payload fields such as cohort_ids=null, active_sessions=null, and created_at=null, plus malformed /admin/tickets payloads such as summary=null, instead of throwing during render. The screen shows 관리자 데이터 진단 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, index.html boot watchdog appends Vignette 화면 진단 directly to body; file-based JS-missing smoke confirmed the panel appears with path/asset/bodyText/visibleNodes. + Admin silent-blank diagnosticsnpm 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" / dist watchdog smokeTypecheck passed, build passed, client diagnostic endpoint test passed, and route-guard E2E 5 passed. Admin now normalizes malformed /admin/users payload fields such as cohort_ids=null, active_sessions=null, and created_at=null, plus malformed /admin/tickets payloads such as summary=null, instead of throwing during render. The screen shows 관리자 데이터 진단 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, index.html boot watchdog appends Vignette 화면 진단 directly to body and also posts structural counts, current path, asset name, viewport, and short JS error summaries to public /client-diagnostics; the API logs this as client_diagnostic without accepting page body text. OAuth/SAML admin landingC:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q / public runtime restart36 passed; OAuth/SAML callback now normalizes generic saved next paths such as /learn and /teach to /admin for admin-entitled users while preserving deep links such as /learn/session/.... Live proof after commits 998acb44/6b6241f4 and latest boot-diagnostic deploy b67fd5d4: local/public /health returned prod, db=true, engine=true; custom domain now serves assets/index-R5KK7hZI.js/assets/index-DzdAkRsn.css plus boot diagnostic HTML; unauthenticated /personas remains 401. Session read-model DB readinesspython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / https://api-vignette.chanpaca.net/health2026-06-29 prod 503 원인은 운영 DB의 app.turns.provider_events 컬럼 누락이었다. 운영 DB hotfix 후 /teacher/dashboard code path는 source=database로 복구됐다. 현재 db.healthcheck(), runtime table readiness, deploy preflight DB mode는 app.turns 음성 메타 컬럼 5개(audio_ref, silence_ms, speech_rate, barge_in, provider_events), app.session_review_status worksheet 컬럼, app.safety_events 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 environment=prod, db=true, engine=true로 복구됐다. Admin usage persistencepython -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q / authenticated local public-API smoke26 passed; /admin/usage returns 200 with source=database, durable=true. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against 127.0.0.1:8001 using the same prod process. @@ -993,7 +993,7 @@ Python compilepython -m compileall app engine_gatewayPassed Phase 3 artifact gatespy -3.11 -X utf8 scripts\check-phase3-artifacts.py --help + py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_dataset_export.py app/test_phase3_artifact_checker.py -q16 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. Web buildnpm run buildPassed - Pages production deploywrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true2026-07-03 production deploy from commit b67fd5d4; preview https://8f6aba1c.vignette-b1q.pages.dev; custom domain serves assets/index-R5KK7hZI.js/assets/index-DzdAkRsn.css and includes vignette-boot-diagnostic HTML. + Pages production deploywrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true2026-07-03 production deploy; preview https://a948284e.vignette-b1q.pages.dev; custom domain serves assets/index-R5KK7hZI.js/assets/index-DzdAkRsn.css and includes vignette-boot-diagnostic HTML plus /client-diagnostics boot telemetry sender. Custom domain assetshttps://vignette.chanpaca.net/login?dev_dashboard_redteam=20260630-recovered2026-06-30 recheck: login HTML HEAD 200. Public API 복구 후에도 web custom domain은 정상이다. Legacy Live2D routes/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js404 Compose templatedocker compose -f infra\docker-compose.yml config --quietTemplate path is valid with dummy required env. API build context now uses repo root; stale rag.server sidecar removed.