관리자 설정 로딩과 부트 진단 복구

This commit is contained in:
Yun Chan 2026-09-22 23:06:54 +09:00
parent d58eb67764
commit fb640a75ac
7 changed files with 767 additions and 100 deletions

View file

@ -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"),

View file

@ -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<AdminUsageResponse> = {},
@ -154,9 +172,14 @@ interface AdminAiMockState {
usageForWindow: (days: number) => AdminUsageResponse;
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
usageGate?: (days: number) => Promise<void> | undefined;
usageStatus?: (days: number) => number | undefined;
engineConfig: AdminEngineConfigResponse;
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
engineConfigStatus?: number;
engineConfigGate?: () => Promise<void> | undefined;
capabilityGate?: (provider: string, engineUrl: string) => Promise<void> | undefined;
capabilityStatus?: (provider: string, engineUrl: string) => number | undefined;
capabilityFor?: (provider: string, engineUrl: string) => EngineCapabilitiesResponse;
}
function createMockState(overrides: Partial<AdminAiMockState> = {}): 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<typeof deferred> | 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<void>((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<void>((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,

View file

@ -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<void>((resolve) => {
releaseMain = resolve;
});
let releaseMe!: () => void;
const meGate = new Promise<void>((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<void>((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<void>((resolve) => {
releaseMain = resolve;
});
let releaseMe!: () => void;
const meGate = new Promise<void>((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);

View file

@ -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 = [
"<b style=\"display:block;font-size:18px;margin-bottom:6px\">Vignette 화면 진단</b>",
"<div>React 화면이 정상 콘텐츠를 표시하지 못해서 HTML 부트스트랩 진단을 띄웠습니다.</div>",
"<dl style=\"display:grid;grid-template-columns:132px minmax(0,1fr);gap:0;margin:12px 0 0;border:1px solid rgba(0,0,0,.12);border-radius:8px;overflow:hidden\">",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">reason</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
reason +
effectiveReason +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">path</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
path +
@ -320,6 +363,9 @@
window.setTimeout(function () {
render("watchdog");
}, 8000);
window.setTimeout(function () {
render("watchdog");
}, 20000);
})();
</script>
</head>

View file

@ -66,6 +66,8 @@ const Settings = lazy(() => import("./pages/Settings"));
function BootScreen() {
return (
<div
role="status"
data-vignette-boot-loading="true"
style={{
minHeight: "100dvh",
display: "grid",

View file

@ -616,6 +616,7 @@ export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
type AdminReadOptions = Pick<RequestOptions, "signal">;
export type AdminTicketFilters = {
status?: AdminSupportTicket["status"] | "";
category?: AdminSupportTicket["category"] | "";
@ -628,8 +629,9 @@ export type AdminTicketFilters = {
};
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
health: (options?: AdminReadOptions) => api.get<AdminHealthResponse>("/admin/health", options),
usage: (windowDays = 7, options?: AdminReadOptions) =>
api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`, options),
uptime: (windowHours = 24) =>
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
tickets: (filters: AdminTicketFilters = {}) => {
@ -828,14 +830,19 @@ export type AdminProviderOAuthStartResponse =
ApiSchema<"AdminProviderOAuthStartResponse">;
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
get: (options?: AdminReadOptions) =>
api.get<AdminEngineConfigResponse>("/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<EngineCapabilitiesResponse>(
`/admin/engine-capabilities?${query.toString()}`,
)
return api.get<EngineCapabilitiesResponse>(
`/admin/engine-capabilities?${query.toString()}`,
options,
);
},
update: (body: AdminEngineConfigPatchRequest) =>

View file

@ -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<AdminUsageResponse | null>(null);
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null);
const [engineLoading, setEngineLoading] = useState(true);
const [engineError, setEngineError] = useState<string | null>(null);
const [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
@ -200,100 +222,257 @@ export default function AdminAi() {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(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<string | null>(null);
const activeControllers = useRef(new Set<AbortController>());
const loadRef = useRef<() => Promise<void>>(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 <T,>(
timeoutMs: number,
label: string,
request: (signal: AbortSignal) => Promise<T>,
): Promise<T> => {
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)}
</span>
</div>
{engineError ? (
<div className="aic-capability is-unavailable" role="alert">
<div>
<b>엔진 설정을 확인할 수 없음</b>
<span>{engineError}</span>
</div>
</div>
) : null}
{engine ? (
<>
<Field
@ -726,15 +914,17 @@ export default function AdminAi() {
const engineMode = event.target
.value as AdminEngineConfigResponse["engine_mode"];
setCapabilities(null);
setEngine({
...engine,
loadedCapabilitiesScope.current = null;
const draftRevision = updateEngineDraft((current) => ({
...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() {
</div>
</>
) : (
<div className="aic-empty">현재 AI 엔진 설정을 불러오는 중입니다.</div>
<div className="aic-empty">
{engineLoading
? "현재 AI 엔진 설정을 불러오는 중입니다."
: "현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."}
</div>
)}
</section>