diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 9874703..9c46c59 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -920,6 +920,10 @@ test.describe("admin route guards", () => { await expect(page.getByTestId("auth-restore-failed")).toBeVisible(); await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible(); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + // 관리자 auth 복구 UI(.vg-route-error)는 3.5초/8초 watchdog 뒤에도 + // 정상적인 오류 복구 화면으로 남고 부트 진단과 겹치지 않는다. + await page.waitForTimeout(8_600); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); await page.getByTestId("auth-restore-retry").click(); @@ -1073,7 +1077,22 @@ test.describe("admin route guards", () => { await expect(page.locator(".aic-chart__day").last()).toContainText("≤$3.56"); await expect(page.locator(".aic-cache-score")).toContainText("80%"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); - await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); + expect( + await page + .getByLabel("AI 엔진 공급자") + .locator("option") + .evaluateAll((options) => options.map((option) => option.value)), + ).toEqual([ + "claude_cli", + "claude_api", + "codex_cli", + "codex_api", + "agy_cli", + "antigravity_api", + "openai", + "openrouter", + "solar", + ]); const usageRequest = page.waitForRequest((request) => request.url().includes("/api/admin/usage?window_days=7"), diff --git a/apps/web/e2e/full-sweep-admin-ai.spec.ts b/apps/web/e2e/full-sweep-admin-ai.spec.ts index 2ec2ef9..976b97a 100644 --- a/apps/web/e2e/full-sweep-admin-ai.spec.ts +++ b/apps/web/e2e/full-sweep-admin-ai.spec.ts @@ -86,6 +86,24 @@ interface AdminEngineConfigResponse { updated_at: number | null; } +interface EngineCapabilitiesResponse { + provider: string; + available: boolean; + source: string; + models: Array<{ + id: string; + label: string; + description: string; + reasoning_efforts: string[]; + default_reasoning_effort: string | null; + is_default: boolean; + }>; + default_model: string; + default_reasoning_effort: string | null; + detail: string; + fetched_at: number; +} + function usageFixture( days: number, overrides: Partial = {}, @@ -154,9 +172,14 @@ interface AdminAiMockState { usageForWindow: (days: number) => AdminUsageResponse; /** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */ usageGate?: (days: number) => Promise | undefined; + usageStatus?: (days: number) => number | undefined; engineConfig: AdminEngineConfigResponse; /** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */ engineConfigStatus?: number; + engineConfigGate?: () => Promise | undefined; + capabilityGate?: (provider: string, engineUrl: string) => Promise | undefined; + capabilityStatus?: (provider: string, engineUrl: string) => number | undefined; + capabilityFor?: (provider: string, engineUrl: string) => EngineCapabilitiesResponse; } function createMockState(overrides: Partial = {}): AdminAiMockState { @@ -211,6 +234,11 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) { const days = Number(url.searchParams.get("window_days") ?? 30); const gate = state.usageGate?.(days); if (gate) await gate; + const status = state.usageStatus?.(days); + if (status) { + await fulfillJson({ detail: `usage ${days} unavailable` }, status); + return; + } await fulfillJson(state.usageForWindow(days)); return; } @@ -238,7 +266,15 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) { if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { state.counts.capabilities += 1; const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode; - await fulfillJson({ + const engineUrl = url.searchParams.get("engine_url") ?? state.engineConfig.engine_url; + const gate = state.capabilityGate?.(provider, engineUrl); + if (gate) await gate; + const status = state.capabilityStatus?.(provider, engineUrl); + if (status) { + await fulfillJson({ detail: `capability ${provider} unavailable` }, status); + return; + } + await fulfillJson(state.capabilityFor?.(provider, engineUrl) ?? { provider, available: true, source: "live_cli", @@ -262,6 +298,8 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) { if (method === "GET" && path.endsWith("/admin/engine-config")) { state.counts.engine += 1; + const gate = state.engineConfigGate?.(); + if (gate) await gate; if (state.engineConfigStatus) { await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus); return; @@ -374,7 +412,7 @@ test.describe("full sweep: admin ai operations", () => { }); // checklist: admin-ai-error-alert, admin-ai-engine-loading-empty - test("shows a role=alert banner with the engine panel placeholder on load failure and clears it on refresh", async ({ + test("shows an explicit engine state after an engine-config failure and recovers on refresh", async ({ page, }) => { const state = createMockState({ engineConfigStatus: 500 }); @@ -382,29 +420,151 @@ test.describe("full sweep: admin ai operations", () => { await page.goto("/admin/ai"); - // engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출. - const alert = page.locator(".aic-alert[role='alert']"); - await expect(alert).toBeVisible(); - await expect(alert).toContainText("engine config unavailable"); - // 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다. - await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible(); + const enginePanel = page.locator(".aic-engine"); + await expect(enginePanel.getByRole("alert")).toContainText("engine config unavailable"); + await expect( + enginePanel.getByText("현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."), + ).toBeVisible(); await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0); - // 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침. state.engineConfigStatus = undefined; let gate: ReturnType | null = deferred(); state.usageGate = () => gate?.promise; await page.getByRole("button", { name: "새로고침", exact: true }).click(); - await expect(alert).toHaveCount(0); + await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099"); + await expect(enginePanel.getByRole("alert")).toHaveCount(0); gate.resolve(); gate = null; - // 성공 응답 이후에도 배너는 없고 엔진 설정 폼이 렌더된다. - await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099"); - await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); - await expect(alert).toHaveCount(0); }); + test("shows engine config before a slow usage response completes", async ({ page }) => { + const usageGate = deferred(); + const state = createMockState({ usageGate: () => usageGate.promise }); + await mockAdminAiSession(page, state); + + await page.goto("/admin/ai"); + + await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099"); + await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "true"); + + usageGate.resolve(); + await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false"); + }); + + test("shows a retryable Korean error when engine-config read times out", async ({ page }) => { + test.setTimeout(30_000); + const engineGate = deferred(); + const state = createMockState({ engineConfigGate: () => engineGate.promise }); + await mockAdminAiSession(page, state); + + await page.goto("/admin/ai"); + + await expect( + page.locator(".aic-engine").getByText( + "AI 엔진 설정 조회 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.", + ), + ).toBeVisible({ timeout: 20_000 }); + engineGate.resolve(); + }); + + test("keeps the latest provider selection when an earlier capability request finishes late", async ({ + page, + }) => { + const claudeGate = deferred(); + const state = createMockState({ + capabilityGate: (provider) => (provider === "claude_api" ? claudeGate.promise : undefined), + capabilityFor: (provider) => ({ + provider, + available: true, + source: "live_cli", + models: [ + { + id: `${provider}-model`, + label: `${provider} 모델`, + description: "테스트 모델", + reasoning_efforts: ["medium"], + default_reasoning_effort: "medium", + is_default: true, + }, + ], + default_model: `${provider}-model`, + default_reasoning_effort: "medium", + detail: "테스트 모델 목록", + fetched_at: 1_785_142_800, + }), + }); + await mockAdminAiSession(page, state); + + await page.goto("/admin/ai"); + const provider = page.getByLabel("AI 엔진 공급자"); + const model = page.getByLabel("AI 기본 모델"); + await expect(model.getByRole("option", { name: "openai 모델" })).toBeAttached(); + + const lateCapabilityResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith("/admin/engine-capabilities") && + url.searchParams.get("engine_mode") === "claude_api" + ); + }); + await provider.selectOption("claude_api"); + await provider.selectOption("openrouter"); + await expect(model).toHaveValue("openrouter-model"); + + await page.getByRole("button", { name: "새로고침", exact: true }).click(); + await expect(provider).toHaveValue("openrouter"); + await expect(model).toHaveValue("openrouter-model"); + + claudeGate.resolve(); + await lateCapabilityResponse; + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + await expect(model).toHaveValue("openrouter-model"); + }); + test("clears prior capability models when refresh changes the engine URL and lookup fails", async ({ + page, + }) => { + const state = createMockState({ + capabilityFor: (provider, engineUrl) => ({ + provider, + available: true, + source: "live_cli", + models: [ + { + id: `model-${engineUrl}`, + label: `모델 ${engineUrl}`, + description: "테스트 모델", + reasoning_efforts: ["medium"], + default_reasoning_effort: "medium", + is_default: true, + }, + ], + default_model: `model-${engineUrl}`, + default_reasoning_effort: "medium", + detail: "테스트 모델 목록", + fetched_at: 1_785_142_800, + }), + }); + await mockAdminAiSession(page, state); + + await page.goto("/admin/ai"); + const model = page.getByLabel("AI 기본 모델"); + await expect(model.getByRole("option", { name: "모델 http://127.0.0.1:9099" })).toBeAttached(); + + state.engineConfig = engineConfigFixture({ + engine_url: "http://127.0.0.1:9100", + model: "restored-model", + }); + state.capabilityStatus = () => 500; + await page.getByRole("button", { name: "새로고침", exact: true }).click(); + + await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9100"); + await expect(model).toHaveValue("restored-model"); + await expect(model.getByRole("option", { name: "모델 http://127.0.0.1:9099" })).toHaveCount(0); + await expect(page.locator(".aic-engine").getByRole("alert")).toContainText( + "capability openai unavailable", + ); + }); // checklist: admin-ai-window-toggle test("refetches only usage on window change and ignores a stale late response", async ({ page, @@ -457,6 +617,30 @@ test.describe("full sweep: admin ai operations", () => { expect(state.counts.capabilities).toBe(1); }); + test("does not let a superseded usage response clear a newer usage error", async ({ page }) => { + const slowWindow = deferred(); + const state = createMockState({ + usageGate: (days) => (days === 90 ? slowWindow.promise : undefined), + usageStatus: (days) => (days === 7 ? 500 : undefined), + }); + await mockAdminAiSession(page, state); + + await page.goto("/admin/ai"); + const staleResponse = page.waitForResponse((response) => + response.url().includes("/api/admin/usage?window_days=90"), + ); + await page.getByRole("button", { name: "90일" }).click(); + await page.getByRole("button", { name: "7일" }).click(); + const alert = page.getByRole("alert").filter({ hasText: "usage 7 unavailable" }); + await expect(alert).toContainText("usage 7 unavailable"); + await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false"); + + slowWindow.resolve(); + await staleResponse; + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + await expect(alert).toContainText("usage 7 unavailable"); + await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false"); + }); // checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({ page, diff --git a/apps/web/e2e/full-sweep-shell.spec.ts b/apps/web/e2e/full-sweep-shell.spec.ts index e8ecef7..fa62081 100644 --- a/apps/web/e2e/full-sweep-shell.spec.ts +++ b/apps/web/e2e/full-sweep-shell.spec.ts @@ -232,9 +232,15 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { await page.goto("/learn"); - await expect(page.getByText(BOOT_SCREEN_TEXT)).toBeVisible(); + const bootScreen = page.getByText(BOOT_SCREEN_TEXT); + await expect(bootScreen).toBeVisible(); + await expect(bootScreen).toHaveAttribute("data-vignette-boot-loading", "true"); // 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다 await expect(page).toHaveURL(/\/learn$/); + // 3.5초와 8초 watchdog은 화면에 실제로 보이는 bounded BootScreen을 + // 빈 React root로 오인하지 않는다. + await page.waitForTimeout(8_600); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); releaseMe(); @@ -242,6 +248,154 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); }); + test("removes a watchdog panel when the delayed boot screen becomes visible", async ({ page }) => { + test.setTimeout(15_000); + let releaseMain!: () => void; + const mainGate = new Promise((resolve) => { + releaseMain = resolve; + }); + let releaseMe!: () => void; + const meGate = new Promise((resolve) => { + releaseMe = resolve; + }); + await page.route("**/src/main.tsx*", async (route) => { + await mainGate; + await route.continue(); + }); + await page.route("**/api/auth/me", async (route) => { + await meGate; + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "not authenticated" }), + }); + }); + + await page.goto("/learn", { waitUntil: "commit" }); + await expect(page.locator("#root")).toBeEmpty(); + await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 4_500 }); + + releaseMain(); + + await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveAttribute( + "data-vignette-boot-loading", + "true", + ); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, { timeout: 1_500 }); + + releaseMe(); + await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); + }); + + test("reports a boot screen that remains visible through the 20-second watchdog", async ({ page }) => { + test.setTimeout(35_000); + let releaseChunk!: () => void; + const gate = new Promise((resolve) => { + releaseChunk = resolve; + }); + await page.route("**/src/pages/Login.tsx*", async (route) => { + await gate; + await route.continue(); + }); + await page.route("**/api/auth/me", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "not authenticated" }), + }); + }); + + await page.route("**/api/auth/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + google_oauth_configured: true, + saml_configured: false, + providers: [ + { + provider: "google", + configured: true, + enabled: true, + login_path: "/auth/login?provider=google", + }, + ], + allowed_email_domains: [], + redirect_uri: "http://127.0.0.1:8000/auth/callback", + dev_login_enabled: false, + }), + }); + }); + const meResolved = page.waitForResponse((response) => response.url().includes("/api/auth/me")); + await page.goto("/login", { waitUntil: "domcontentloaded" }); + await meResolved; + await expect(page.getByText(BOOT_SCREEN_TEXT).filter({ visible: true })).toHaveAttribute( + "data-vignette-boot-loading", + "true", + ); + await page.waitForTimeout(8_600); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 14_000 }); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveAttribute( + "data-vignette-boot-reason", + "watchdog", + ); + + releaseChunk(); + await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); + }); + + test("keeps manual and runtime diagnostics when a delayed boot screen appears after watchdog", async ({ page }) => { + test.setTimeout(15_000); + let releaseMain!: () => void; + const mainGate = new Promise((resolve) => { + releaseMain = resolve; + }); + let releaseMe!: () => void; + const meGate = new Promise((resolve) => { + releaseMe = resolve; + }); + await page.route("**/src/main.tsx*", async (route) => { + await mainGate; + await route.continue(); + }); + await page.route("**/api/auth/me", async (route) => { + await meGate; + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "not authenticated" }), + }); + }); + + await page.goto("/learn", { waitUntil: "commit" }); + const diagnostic = page.locator("#vignette-boot-diagnostic"); + await expect(diagnostic).toBeVisible({ timeout: 4_500 }); + await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "watchdog"); + + await page.evaluate(() => { + (window as Window & { __VIGNETTE_BOOT_DIAG__?: { check(): void } }).__VIGNETTE_BOOT_DIAG__?.check(); + }); + await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "manual"); + await page.evaluate(() => { + window.dispatchEvent(new ErrorEvent("error", { message: "boot diagnostic runtime fixture" })); + }); + await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "runtime-error"); + + releaseMain(); + await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveAttribute( + "data-vignette-boot-loading", + "true", + ); + await page.waitForTimeout(250); + await expect(diagnostic).toBeVisible(); + await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "runtime-error"); + await expect(diagnostic.locator("dd").first()).toHaveText("runtime-error"); + + releaseMe(); + await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); + }); + // checklist: shell-suspense-fallback // 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안 // Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현. @@ -254,7 +408,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { await gate; await route.continue(); }); + await page.route("**/api/auth/me", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "not authenticated" }), + }); + }); + await page.route("**/api/auth/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + google_oauth_configured: true, + saml_configured: false, + providers: [ + { + provider: "google", + configured: true, + enabled: true, + login_path: "/auth/login?provider=google", + }, + ], + allowed_email_domains: [], + redirect_uri: "http://127.0.0.1:8000/auth/callback", + dev_login_enabled: false, + }), + }); + }); const meResolved = page.waitForResponse( (res) => res.url().includes("/api/auth/me"), { timeout: 15_000 }, @@ -285,7 +467,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { page.on("request", (request) => { if (request.isNavigationRequest()) documentRequests += 1; }); + await page.route("**/api/auth/me", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "not authenticated" }), + }); + }); + await page.route("**/api/auth/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + google_oauth_configured: true, + saml_configured: false, + providers: [ + { + provider: "google", + configured: true, + enabled: true, + login_path: "/auth/login?provider=google", + }, + ], + allowed_email_domains: [], + redirect_uri: "http://127.0.0.1:8000/auth/callback", + dev_login_enabled: false, + }), + }); + }); await page.goto("/login"); await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible(); expect(documentRequests).toBe(1); diff --git a/apps/web/index.html b/apps/web/index.html index 3800010..1c40263 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -127,6 +127,13 @@ ); return nodes.filter(isVisiblyRendered).length; } + function hasVisibleBootLoading(root) { + if (!root) return false; + var nodes = Array.prototype.slice.call( + root.querySelectorAll("[data-vignette-boot-loading]"), + ); + return nodes.some(isVisiblyRendered); + } function appHealth() { var root = document.getElementById("root"); var path = window.location.pathname; @@ -138,7 +145,13 @@ "[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']", ) : null; - var hasAdminContent = !isAdmin || isVisiblyRendered(adminContent); + var adminRecovery = root + ? root.querySelector( + ".vg-route-error[data-testid='auth-restore-retrying'],.vg-route-error[data-testid='auth-restore-failed']", + ) + : null; + var hasAdminContent = + !isAdmin || isVisiblyRendered(adminContent) || isVisiblyRendered(adminRecovery); return { healthy: Boolean( root && @@ -148,13 +161,26 @@ hasAdminContent, ), visible: visible, + bootLoading: hasVisibleBootLoading(root), }; } function dismissDiagnosticIfHealthy() { - if (!appHealth().healthy) return false; var panel = document.getElementById("vignette-boot-diagnostic"); - if (panel) panel.remove(); - return true; + var health = appHealth(); + if (health.healthy) { + if (panel) panel.remove(); + return true; + } + if ( + panel && + panel.getAttribute("data-vignette-boot-reason") === "watchdog" && + health.bootLoading && + Date.now() - loadedAt < 20000 + ) { + panel.remove(); + return true; + } + return false; } function scheduleHealthyCheck() { if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer); @@ -241,6 +267,13 @@ var path = window.location.pathname; var text = (document.body && document.body.innerText ? document.body.innerText : "").trim(); var health = appHealth(); + if ( + reason === "watchdog" && + health.bootLoading && + Date.now() - loadedAt < 20000 + ) { + return; + } if (health.healthy && reason !== "manual") { if (reason !== "watchdog") { sendDiagnostic(reason, text.length, health.visible); @@ -265,12 +298,22 @@ "position:fixed;z-index:2147483647;inset:24px auto auto 24px;max-width:min(760px,calc(100vw - 48px));padding:18px;border:1px solid #d59b47;border-radius:8px;background:#fffaf0;color:#21312e;box-shadow:0 18px 48px rgba(10,20,18,.18);font:13px/1.5 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;white-space:normal;"; document.body.appendChild(panel); } + var currentReason = panel.getAttribute("data-vignette-boot-reason"); + var effectiveReason = reason; + if ( + reason !== "watchdog" || + (currentReason !== "runtime-error" && currentReason !== "manual") + ) { + panel.setAttribute("data-vignette-boot-reason", reason); + } else { + effectiveReason = currentReason; + } var lines = [ "Vignette 화면 진단", "
React 화면이 정상 콘텐츠를 표시하지 못해서 HTML 부트스트랩 진단을 띄웠습니다.
", "
", "
reason
" + - reason + + effectiveReason + "
", "
path
" + path + @@ -320,6 +363,9 @@ window.setTimeout(function () { render("watchdog"); }, 8000); + window.setTimeout(function () { + render("watchdog"); + }, 20000); })(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 71602bc..92eb02c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -66,6 +66,8 @@ const Settings = lazy(() => import("./pages/Settings")); function BootScreen() { return (
; export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">; export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">; export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">; +type AdminReadOptions = Pick; export type AdminTicketFilters = { status?: AdminSupportTicket["status"] | ""; category?: AdminSupportTicket["category"] | ""; @@ -628,8 +629,9 @@ export type AdminTicketFilters = { }; export const adminApi = { - health: () => api.get("/admin/health"), - usage: (windowDays = 7) => api.get(`/admin/usage?window_days=${windowDays}`), + health: (options?: AdminReadOptions) => api.get("/admin/health", options), + usage: (windowDays = 7, options?: AdminReadOptions) => + api.get(`/admin/usage?window_days=${windowDays}`, options), uptime: (windowHours = 24) => api.get(`/admin/uptime?window_hours=${windowHours}`), tickets: (filters: AdminTicketFilters = {}) => { @@ -828,14 +830,19 @@ export type AdminProviderOAuthStartResponse = ApiSchema<"AdminProviderOAuthStartResponse">; export const adminEngineApi = { - get: () => api.get("/admin/engine-config"), - capabilities: (engineMode: string, force = false, engineUrl?: string) => { + get: (options?: AdminReadOptions) => + api.get("/admin/engine-config", options), + capabilities: ( + engineMode: string, + force = false, + engineUrl?: string, + options?: AdminReadOptions, + ) => { const query = new URLSearchParams({ engine_mode: engineMode, force: String(force) }); if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim()); - return ( - api.get( - `/admin/engine-capabilities?${query.toString()}`, - ) + return api.get( + `/admin/engine-capabilities?${query.toString()}`, + options, ); }, update: (body: AdminEngineConfigPatchRequest) => diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx index f19f957..60f34a4 100644 --- a/apps/web/src/pages/AdminAi.tsx +++ b/apps/web/src/pages/AdminAi.tsx @@ -188,11 +188,33 @@ function healthStatusLabel(status?: string): string { return "확인 불가"; } +const ADMIN_READ_TIMEOUT_MS = 15_000; +const CAPABILITIES_READ_TIMEOUT_MS = 25_000; + +class AdminReadTimeoutError extends Error { + constructor(label: string) { + super(`${label} 조회 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.`); + this.name = "AdminReadTimeoutError"; + } +} + +function adminReadError(cause: unknown, label: string): string { + if (cause instanceof AdminReadTimeoutError) return cause.message; + if (cause instanceof Error) return cause.message; + return `${label}을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.`; +} + +function capabilitiesScope(engineMode: string, engineUrl: string): string { + return `${engineMode}\u0000${engineUrl.trim()}`; +} + export default function AdminAi() { const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30); const [usage, setUsage] = useState(null); const [health, setHealth] = useState(null); const [engine, setEngine] = useState(null); + const [engineLoading, setEngineLoading] = useState(true); + const [engineError, setEngineError] = useState(null); const [capabilities, setCapabilities] = useState(null); const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); const [capabilitiesError, setCapabilitiesError] = useState(null); @@ -200,100 +222,257 @@ export default function AdminAi() { const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); - const initialLoad = useRef(true); + const mounted = useRef(false); + const loadGeneration = useRef(0); + const displayRequestId = useRef(0); + const usageRequestId = useRef(0); + const healthRequestId = useRef(0); const capabilityRequestId = useRef(0); + const engineDraftRevision = useRef(0); + const loadedEngineRevision = useRef(0); + const loadedCapabilitiesScope = useRef(null); + const activeControllers = useRef(new Set()); + const loadRef = useRef<() => Promise>(async () => undefined); + const loadedWindowDays = useRef(windowDays); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + loadGeneration.current += 1; + capabilityRequestId.current += 1; + for (const controller of activeControllers.current) controller.abort(); + activeControllers.current.clear(); + }; + }, []); + + const runTimedRead = useCallback( + async ( + timeoutMs: number, + label: string, + request: (signal: AbortSignal) => Promise, + ): Promise => { + const controller = new AbortController(); + activeControllers.current.add(controller); + let timedOut = false; + const timeout = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + try { + return await request(controller.signal); + } catch (cause) { + if (timedOut) throw new AdminReadTimeoutError(label); + throw cause; + } finally { + window.clearTimeout(timeout); + activeControllers.current.delete(controller); + } + }, + [], + ); + + const updateEngineDraft = useCallback( + (updater: (current: AdminEngineConfigResponse) => AdminEngineConfigResponse): number => { + const revision = ++engineDraftRevision.current; + setEngine((current) => (current ? updater(current) : current)); + return revision; + }, + [], + ); const loadCapabilities = useCallback( async ( engineMode: string, engineUrl: string, - options: { force?: boolean; applyDefault?: boolean } = {}, + options: { force?: boolean; applyDefault?: boolean; draftRevision?: number } = {}, ) => { const requestId = ++capabilityRequestId.current; + const draftRevision = options.draftRevision ?? engineDraftRevision.current; + const scope = capabilitiesScope(engineMode, engineUrl); setCapabilitiesLoading(true); setCapabilitiesError(null); try { - const next = await adminEngineApi.capabilities( - engineMode, - options.force ?? false, - engineUrl, + const next = await runTimedRead( + CAPABILITIES_READ_TIMEOUT_MS, + "모델 목록", + (signal) => + adminEngineApi.capabilities(engineMode, options.force ?? false, engineUrl, { signal }), ); - if (requestId !== capabilityRequestId.current) return; + if ( + !mounted.current || + requestId !== capabilityRequestId.current || + draftRevision !== engineDraftRevision.current + ) { + return; + } setCapabilities(next); + loadedCapabilitiesScope.current = scope; if (options.applyDefault && next.available && next.default_model) { setEngine((current) => { if (!current || current.engine_mode !== engineMode) return current; return { ...current, model: next.default_model ?? "", - reasoning_effort: next.default_reasoning_effort, + reasoning_effort: next.default_reasoning_effort ?? null, }; }); } } catch (cause) { - if (requestId !== capabilityRequestId.current) return; - setCapabilities(null); - setCapabilitiesError( - cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.", - ); + if ( + !mounted.current || + requestId !== capabilityRequestId.current || + draftRevision !== engineDraftRevision.current + ) { + return; + } + setCapabilitiesError(adminReadError(cause, "모델 목록")); } finally { - if (requestId === capabilityRequestId.current) setCapabilitiesLoading(false); + if (mounted.current && requestId === capabilityRequestId.current) { + setCapabilitiesLoading(false); + } } }, - [], + [runTimedRead], + ); + + const loadUsage = useCallback( + async (days: number): Promise<{ requestId: number; error: string | null }> => { + const requestId = ++usageRequestId.current; + try { + const next = await runTimedRead( + ADMIN_READ_TIMEOUT_MS, + "AI 사용량", + (signal) => adminApi.usage(days, { signal }), + ); + if (!mounted.current || requestId !== usageRequestId.current) { + return { requestId, error: null }; + } + setUsage(next); + return { requestId, error: null }; + } catch (cause) { + if (!mounted.current || requestId !== usageRequestId.current) { + return { requestId, error: null }; + } + return { requestId, error: adminReadError(cause, "AI 사용량") }; + } + }, + [runTimedRead], + ); + + const loadHealth = useCallback( + async (): Promise<{ requestId: number; error: string | null }> => { + const requestId = ++healthRequestId.current; + try { + const next = await runTimedRead( + ADMIN_READ_TIMEOUT_MS, + "엔진 상태", + (signal) => adminApi.health({ signal }), + ); + if (!mounted.current || requestId !== healthRequestId.current) { + return { requestId, error: null }; + } + setHealth(next); + return { requestId, error: null }; + } catch (cause) { + if (!mounted.current || requestId !== healthRequestId.current) { + return { requestId, error: null }; + } + return { requestId, error: adminReadError(cause, "엔진 상태") }; + } + }, + [runTimedRead], ); const load = useCallback(async () => { + const generation = ++loadGeneration.current; + const displayRequest = ++displayRequestId.current; + const draftRevision = engineDraftRevision.current; + capabilityRequestId.current += 1; + setCapabilitiesLoading(false); setLoading(true); setError(null); - try { - const [usageResult, healthResult, engineResult] = await Promise.allSettled([ - adminApi.usage(windowDays), - adminApi.health(), - adminEngineApi.get(), - ]); - if (usageResult.status === "fulfilled") setUsage(usageResult.value); - if (healthResult.status === "fulfilled") setHealth(healthResult.value); - if (engineResult.status === "fulfilled") { - setEngine(engineResult.value); - await loadCapabilities(engineResult.value.engine_mode, engineResult.value.engine_url); - } - const failures = [usageResult, healthResult, engineResult] - .filter((result): result is PromiseRejectedResult => result.status === "rejected") - .map((result) => - result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.", - ); - if (failures.length > 0) setError([...new Set(failures)].join(" · ")); - } finally { - setLoading(false); - } - }, [loadCapabilities, windowDays]); + setEngineLoading(true); + setEngineError(null); - useEffect(() => { - if (initialLoad.current) { - initialLoad.current = false; - void load(); - return; - } - let active = true; - setLoading(true); - setError(null); - void adminApi.usage(windowDays) - .then((nextUsage) => { - if (active) setUsage(nextUsage); + const usagePromise = loadUsage(windowDays); + const healthPromise = loadHealth(); + void runTimedRead( + ADMIN_READ_TIMEOUT_MS, + "AI 엔진 설정", + (signal) => adminEngineApi.get({ signal }), + ) + .then((next) => { + if ( + !mounted.current || + generation !== loadGeneration.current || + draftRevision !== engineDraftRevision.current || + draftRevision !== loadedEngineRevision.current + ) { + return; + } + const scope = capabilitiesScope(next.engine_mode, next.engine_url); + if (loadedCapabilitiesScope.current !== scope) { + setCapabilities(null); + setCapabilitiesError(null); + } + setEngine(next); + loadedEngineRevision.current = draftRevision; + setEngineError(null); + void loadCapabilities(next.engine_mode, next.engine_url, { draftRevision }); }) .catch((cause) => { - if (active) { - setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다."); - } + if (!mounted.current || generation !== loadGeneration.current) return; + setEngineError(adminReadError(cause, "AI 엔진 설정")); }) .finally(() => { - if (active) setLoading(false); + if (mounted.current && generation === loadGeneration.current) setEngineLoading(false); }); - return () => { - active = false; - }; - }, [windowDays]); + + const [usageResult, healthResult] = await Promise.all([usagePromise, healthPromise]); + if ( + !mounted.current || + generation !== loadGeneration.current || + displayRequest !== displayRequestId.current || + usageResult.requestId !== usageRequestId.current || + healthResult.requestId !== healthRequestId.current + ) { + return; + } + const errors = [usageResult.error, healthResult.error].filter((item): item is string => Boolean(item)); + setError(errors.length ? [...new Set(errors)].join(" · ") : null); + setLoading(false); + }, [loadCapabilities, loadHealth, loadUsage, runTimedRead, windowDays]); + + useEffect(() => { + loadRef.current = load; + }, [load]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadRef.current(); + }, 0); + return () => window.clearTimeout(timer); + }, []); + + useEffect(() => { + if (windowDays === loadedWindowDays.current) return; + loadedWindowDays.current = windowDays; + const displayRequest = ++displayRequestId.current; + setLoading(true); + setError(null); + void loadUsage(windowDays).then((usageResult) => { + if ( + !mounted.current || + displayRequest !== displayRequestId.current || + usageResult.requestId !== usageRequestId.current + ) { + return; + } + setError(usageResult.error); + setLoading(false); + }); + }, [loadUsage, windowDays]); const saveEngine = async () => { if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return; @@ -307,9 +486,11 @@ export default function AdminAi() { model: engine.model.trim(), reasoning_effort: engine.reasoning_effort, }); - setEngine(next); + const savedRevision = updateEngineDraft(() => next); + loadedEngineRevision.current = savedRevision; setSaved(true); - setHealth(await adminApi.health().catch(() => health)); + const healthResult = await loadHealth(); + if (healthResult.error) setError(healthResult.error); window.setTimeout(() => setSaved(false), 2400); } catch (cause) { setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다."); @@ -317,7 +498,6 @@ export default function AdminAi() { setSaving(false); } }; - const usageCostBasis = readCostBasis(usage); const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis); const daily = usage?.daily_cost ?? []; @@ -710,6 +890,14 @@ export default function AdminAi() { {healthStatusLabel(engineService?.status)}
+ {engineError ? ( +
+
+ 엔진 설정을 확인할 수 없음 + {engineError} +
+
+ ) : null} {engine ? ( <> ({ + ...current, engine_mode: engineMode, model: "", reasoning_effort: null, - }); + })); void loadCapabilities(engineMode, engine.engine_url, { force: true, applyDefault: true, + draftRevision, }); }} > @@ -750,8 +940,12 @@ export default function AdminAi() { id="aic-engine-url" value={engine.engine_url} onChange={(event) => { - setEngine({ ...engine, engine_url: event.target.value }); + updateEngineDraft((current) => ({ + ...current, + engine_url: event.target.value, + })); setCapabilities(null); + loadedCapabilitiesScope.current = null; setCapabilitiesError( "연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.", ); @@ -771,11 +965,11 @@ export default function AdminAi() { onChange={(event) => { const model = event.target.value; const option = capabilityModels.find((item) => item.id === model); - setEngine({ - ...engine, + updateEngineDraft((current) => ({ + ...current, model, reasoning_effort: option?.default_reasoning_effort ?? null, - }); + })); }} aria-label="AI 기본 모델" disabled={capabilitiesLoading || !capabilities?.available} @@ -805,11 +999,11 @@ export default function AdminAi() { aria-label="AI 추론 강도" disabled={capabilitiesLoading || !selectedModel} onChange={(event) => - setEngine({ - ...engine, + updateEngineDraft((current) => ({ + ...current, reasoning_effort: (event.target.value || null) as AdminEngineConfigResponse["reasoning_effort"], - }) + })) } > {selectedEfforts.length ? null : ( @@ -858,6 +1052,7 @@ export default function AdminAi() { void loadCapabilities(engine.engine_mode, engine.engine_url, { force: true, applyDefault: !selectedModel, + draftRevision: engineDraftRevision.current, }) } disabled={capabilitiesLoading} @@ -889,7 +1084,11 @@ export default function AdminAi() { ) : ( -
현재 AI 엔진 설정을 불러오는 중입니다.
+
+ {engineLoading + ? "현재 AI 엔진 설정을 불러오는 중입니다." + : "현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."} +
)}