관리자 설정 로딩과 부트 진단 복구
This commit is contained in:
parent
d58eb67764
commit
fb640a75ac
7 changed files with 767 additions and 100 deletions
|
|
@ -920,6 +920,10 @@ test.describe("admin route guards", () => {
|
||||||
await expect(page.getByTestId("auth-restore-failed")).toBeVisible();
|
await expect(page.getByTestId("auth-restore-failed")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible();
|
||||||
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
|
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();
|
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-chart__day").last()).toContainText("≤$3.56");
|
||||||
await expect(page.locator(".aic-cache-score")).toContainText("80%");
|
await expect(page.locator(".aic-cache-score")).toContainText("80%");
|
||||||
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
|
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) =>
|
const usageRequest = page.waitForRequest((request) =>
|
||||||
request.url().includes("/api/admin/usage?window_days=7"),
|
request.url().includes("/api/admin/usage?window_days=7"),
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,24 @@ interface AdminEngineConfigResponse {
|
||||||
updated_at: number | null;
|
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(
|
function usageFixture(
|
||||||
days: number,
|
days: number,
|
||||||
overrides: Partial<AdminUsageResponse> = {},
|
overrides: Partial<AdminUsageResponse> = {},
|
||||||
|
|
@ -154,9 +172,14 @@ interface AdminAiMockState {
|
||||||
usageForWindow: (days: number) => AdminUsageResponse;
|
usageForWindow: (days: number) => AdminUsageResponse;
|
||||||
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
|
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
|
||||||
usageGate?: (days: number) => Promise<void> | undefined;
|
usageGate?: (days: number) => Promise<void> | undefined;
|
||||||
|
usageStatus?: (days: number) => number | undefined;
|
||||||
engineConfig: AdminEngineConfigResponse;
|
engineConfig: AdminEngineConfigResponse;
|
||||||
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
|
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
|
||||||
engineConfigStatus?: number;
|
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 {
|
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 days = Number(url.searchParams.get("window_days") ?? 30);
|
||||||
const gate = state.usageGate?.(days);
|
const gate = state.usageGate?.(days);
|
||||||
if (gate) await gate;
|
if (gate) await gate;
|
||||||
|
const status = state.usageStatus?.(days);
|
||||||
|
if (status) {
|
||||||
|
await fulfillJson({ detail: `usage ${days} unavailable` }, status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await fulfillJson(state.usageForWindow(days));
|
await fulfillJson(state.usageForWindow(days));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +266,15 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
|
||||||
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
|
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
|
||||||
state.counts.capabilities += 1;
|
state.counts.capabilities += 1;
|
||||||
const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode;
|
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,
|
provider,
|
||||||
available: true,
|
available: true,
|
||||||
source: "live_cli",
|
source: "live_cli",
|
||||||
|
|
@ -262,6 +298,8 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
|
||||||
|
|
||||||
if (method === "GET" && path.endsWith("/admin/engine-config")) {
|
if (method === "GET" && path.endsWith("/admin/engine-config")) {
|
||||||
state.counts.engine += 1;
|
state.counts.engine += 1;
|
||||||
|
const gate = state.engineConfigGate?.();
|
||||||
|
if (gate) await gate;
|
||||||
if (state.engineConfigStatus) {
|
if (state.engineConfigStatus) {
|
||||||
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
|
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
|
||||||
return;
|
return;
|
||||||
|
|
@ -374,7 +412,7 @@ test.describe("full sweep: admin ai operations", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
|
// 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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const state = createMockState({ engineConfigStatus: 500 });
|
const state = createMockState({ engineConfigStatus: 500 });
|
||||||
|
|
@ -382,29 +420,151 @@ test.describe("full sweep: admin ai operations", () => {
|
||||||
|
|
||||||
await page.goto("/admin/ai");
|
await page.goto("/admin/ai");
|
||||||
|
|
||||||
// engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출.
|
const enginePanel = page.locator(".aic-engine");
|
||||||
const alert = page.locator(".aic-alert[role='alert']");
|
await expect(enginePanel.getByRole("alert")).toContainText("engine config unavailable");
|
||||||
await expect(alert).toBeVisible();
|
await expect(
|
||||||
await expect(alert).toContainText("engine config unavailable");
|
enginePanel.getByText("현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."),
|
||||||
// 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다.
|
).toBeVisible();
|
||||||
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible();
|
|
||||||
await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0);
|
await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0);
|
||||||
|
|
||||||
// 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침.
|
|
||||||
state.engineConfigStatus = undefined;
|
state.engineConfigStatus = undefined;
|
||||||
let gate: ReturnType<typeof deferred> | null = deferred();
|
let gate: ReturnType<typeof deferred> | null = deferred();
|
||||||
state.usageGate = () => gate?.promise;
|
state.usageGate = () => gate?.promise;
|
||||||
await page.getByRole("button", { name: "새로고침", exact: true }).click();
|
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.resolve();
|
||||||
gate = null;
|
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
|
// checklist: admin-ai-window-toggle
|
||||||
test("refetches only usage on window change and ignores a stale late response", async ({
|
test("refetches only usage on window change and ignores a stale late response", async ({
|
||||||
page,
|
page,
|
||||||
|
|
@ -457,6 +617,30 @@ test.describe("full sweep: admin ai operations", () => {
|
||||||
expect(state.counts.capabilities).toBe(1);
|
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
|
// 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 ({
|
test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -232,9 +232,15 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
|
||||||
|
|
||||||
await page.goto("/learn");
|
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$/);
|
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();
|
releaseMe();
|
||||||
|
|
||||||
|
|
@ -242,6 +248,154 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
|
||||||
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
|
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
|
// checklist: shell-suspense-fallback
|
||||||
// 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안
|
// 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안
|
||||||
// Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현.
|
// Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현.
|
||||||
|
|
@ -254,7 +408,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
|
||||||
await gate;
|
await gate;
|
||||||
await route.continue();
|
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(
|
const meResolved = page.waitForResponse(
|
||||||
(res) => res.url().includes("/api/auth/me"),
|
(res) => res.url().includes("/api/auth/me"),
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|
@ -285,7 +467,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.isNavigationRequest()) documentRequests += 1;
|
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 page.goto("/login");
|
||||||
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
|
||||||
expect(documentRequests).toBe(1);
|
expect(documentRequests).toBe(1);
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,13 @@
|
||||||
);
|
);
|
||||||
return nodes.filter(isVisiblyRendered).length;
|
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() {
|
function appHealth() {
|
||||||
var root = document.getElementById("root");
|
var root = document.getElementById("root");
|
||||||
var path = window.location.pathname;
|
var path = window.location.pathname;
|
||||||
|
|
@ -138,7 +145,13 @@
|
||||||
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
|
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
|
||||||
)
|
)
|
||||||
: null;
|
: 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 {
|
return {
|
||||||
healthy: Boolean(
|
healthy: Boolean(
|
||||||
root &&
|
root &&
|
||||||
|
|
@ -148,14 +161,27 @@
|
||||||
hasAdminContent,
|
hasAdminContent,
|
||||||
),
|
),
|
||||||
visible: visible,
|
visible: visible,
|
||||||
|
bootLoading: hasVisibleBootLoading(root),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function dismissDiagnosticIfHealthy() {
|
function dismissDiagnosticIfHealthy() {
|
||||||
if (!appHealth().healthy) return false;
|
|
||||||
var panel = document.getElementById("vignette-boot-diagnostic");
|
var panel = document.getElementById("vignette-boot-diagnostic");
|
||||||
|
var health = appHealth();
|
||||||
|
if (health.healthy) {
|
||||||
if (panel) panel.remove();
|
if (panel) panel.remove();
|
||||||
return true;
|
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() {
|
function scheduleHealthyCheck() {
|
||||||
if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer);
|
if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer);
|
||||||
healthyCheckTimer = window.setTimeout(function () {
|
healthyCheckTimer = window.setTimeout(function () {
|
||||||
|
|
@ -241,6 +267,13 @@
|
||||||
var path = window.location.pathname;
|
var path = window.location.pathname;
|
||||||
var text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
|
var text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
|
||||||
var health = appHealth();
|
var health = appHealth();
|
||||||
|
if (
|
||||||
|
reason === "watchdog" &&
|
||||||
|
health.bootLoading &&
|
||||||
|
Date.now() - loadedAt < 20000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (health.healthy && reason !== "manual") {
|
if (health.healthy && reason !== "manual") {
|
||||||
if (reason !== "watchdog") {
|
if (reason !== "watchdog") {
|
||||||
sendDiagnostic(reason, text.length, health.visible);
|
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;";
|
"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);
|
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 = [
|
var lines = [
|
||||||
"<b style=\"display:block;font-size:18px;margin-bottom:6px\">Vignette 화면 진단</b>",
|
"<b style=\"display:block;font-size:18px;margin-bottom:6px\">Vignette 화면 진단</b>",
|
||||||
"<div>React 화면이 정상 콘텐츠를 표시하지 못해서 HTML 부트스트랩 진단을 띄웠습니다.</div>",
|
"<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\">",
|
"<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\">" +
|
"<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>",
|
"</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\">" +
|
"<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 +
|
path +
|
||||||
|
|
@ -320,6 +363,9 @@
|
||||||
window.setTimeout(function () {
|
window.setTimeout(function () {
|
||||||
render("watchdog");
|
render("watchdog");
|
||||||
}, 8000);
|
}, 8000);
|
||||||
|
window.setTimeout(function () {
|
||||||
|
render("watchdog");
|
||||||
|
}, 20000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ const Settings = lazy(() => import("./pages/Settings"));
|
||||||
function BootScreen() {
|
function BootScreen() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
|
data-vignette-boot-loading="true"
|
||||||
style={{
|
style={{
|
||||||
minHeight: "100dvh",
|
minHeight: "100dvh",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|
|
||||||
|
|
@ -616,6 +616,7 @@ export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
|
||||||
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
|
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
|
||||||
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
|
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
|
||||||
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
|
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
|
||||||
|
type AdminReadOptions = Pick<RequestOptions, "signal">;
|
||||||
export type AdminTicketFilters = {
|
export type AdminTicketFilters = {
|
||||||
status?: AdminSupportTicket["status"] | "";
|
status?: AdminSupportTicket["status"] | "";
|
||||||
category?: AdminSupportTicket["category"] | "";
|
category?: AdminSupportTicket["category"] | "";
|
||||||
|
|
@ -628,8 +629,9 @@ export type AdminTicketFilters = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
health: (options?: AdminReadOptions) => api.get<AdminHealthResponse>("/admin/health", options),
|
||||||
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
|
usage: (windowDays = 7, options?: AdminReadOptions) =>
|
||||||
|
api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`, options),
|
||||||
uptime: (windowHours = 24) =>
|
uptime: (windowHours = 24) =>
|
||||||
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
|
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
|
||||||
tickets: (filters: AdminTicketFilters = {}) => {
|
tickets: (filters: AdminTicketFilters = {}) => {
|
||||||
|
|
@ -828,14 +830,19 @@ export type AdminProviderOAuthStartResponse =
|
||||||
ApiSchema<"AdminProviderOAuthStartResponse">;
|
ApiSchema<"AdminProviderOAuthStartResponse">;
|
||||||
|
|
||||||
export const adminEngineApi = {
|
export const adminEngineApi = {
|
||||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
get: (options?: AdminReadOptions) =>
|
||||||
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
|
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) });
|
const query = new URLSearchParams({ engine_mode: engineMode, force: String(force) });
|
||||||
if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim());
|
if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim());
|
||||||
return (
|
return api.get<EngineCapabilitiesResponse>(
|
||||||
api.get<EngineCapabilitiesResponse>(
|
|
||||||
`/admin/engine-capabilities?${query.toString()}`,
|
`/admin/engine-capabilities?${query.toString()}`,
|
||||||
)
|
options,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
update: (body: AdminEngineConfigPatchRequest) =>
|
update: (body: AdminEngineConfigPatchRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -188,11 +188,33 @@ function healthStatusLabel(status?: string): string {
|
||||||
return "확인 불가";
|
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() {
|
export default function AdminAi() {
|
||||||
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
|
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
|
||||||
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
|
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
|
||||||
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
|
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
|
||||||
const [engine, setEngine] = useState<AdminEngineConfigResponse | 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 [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
|
||||||
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
|
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
|
||||||
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
|
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
|
||||||
|
|
@ -200,100 +222,257 @@ export default function AdminAi() {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 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(
|
const loadCapabilities = useCallback(
|
||||||
async (
|
async (
|
||||||
engineMode: string,
|
engineMode: string,
|
||||||
engineUrl: string,
|
engineUrl: string,
|
||||||
options: { force?: boolean; applyDefault?: boolean } = {},
|
options: { force?: boolean; applyDefault?: boolean; draftRevision?: number } = {},
|
||||||
) => {
|
) => {
|
||||||
const requestId = ++capabilityRequestId.current;
|
const requestId = ++capabilityRequestId.current;
|
||||||
|
const draftRevision = options.draftRevision ?? engineDraftRevision.current;
|
||||||
|
const scope = capabilitiesScope(engineMode, engineUrl);
|
||||||
setCapabilitiesLoading(true);
|
setCapabilitiesLoading(true);
|
||||||
setCapabilitiesError(null);
|
setCapabilitiesError(null);
|
||||||
try {
|
try {
|
||||||
const next = await adminEngineApi.capabilities(
|
const next = await runTimedRead(
|
||||||
engineMode,
|
CAPABILITIES_READ_TIMEOUT_MS,
|
||||||
options.force ?? false,
|
"모델 목록",
|
||||||
engineUrl,
|
(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);
|
setCapabilities(next);
|
||||||
|
loadedCapabilitiesScope.current = scope;
|
||||||
if (options.applyDefault && next.available && next.default_model) {
|
if (options.applyDefault && next.available && next.default_model) {
|
||||||
setEngine((current) => {
|
setEngine((current) => {
|
||||||
if (!current || current.engine_mode !== engineMode) return current;
|
if (!current || current.engine_mode !== engineMode) return current;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
model: next.default_model ?? "",
|
model: next.default_model ?? "",
|
||||||
reasoning_effort: next.default_reasoning_effort,
|
reasoning_effort: next.default_reasoning_effort ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (requestId !== capabilityRequestId.current) return;
|
if (
|
||||||
setCapabilities(null);
|
!mounted.current ||
|
||||||
setCapabilitiesError(
|
requestId !== capabilityRequestId.current ||
|
||||||
cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.",
|
draftRevision !== engineDraftRevision.current
|
||||||
);
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilitiesError(adminReadError(cause, "모델 목록"));
|
||||||
} finally {
|
} 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 load = useCallback(async () => {
|
||||||
|
const generation = ++loadGeneration.current;
|
||||||
|
const displayRequest = ++displayRequestId.current;
|
||||||
|
const draftRevision = engineDraftRevision.current;
|
||||||
|
capabilityRequestId.current += 1;
|
||||||
|
setCapabilitiesLoading(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
setEngineLoading(true);
|
||||||
const [usageResult, healthResult, engineResult] = await Promise.allSettled([
|
setEngineError(null);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const usagePromise = loadUsage(windowDays);
|
||||||
if (initialLoad.current) {
|
const healthPromise = loadHealth();
|
||||||
initialLoad.current = false;
|
void runTimedRead(
|
||||||
void load();
|
ADMIN_READ_TIMEOUT_MS,
|
||||||
|
"AI 엔진 설정",
|
||||||
|
(signal) => adminEngineApi.get({ signal }),
|
||||||
|
)
|
||||||
|
.then((next) => {
|
||||||
|
if (
|
||||||
|
!mounted.current ||
|
||||||
|
generation !== loadGeneration.current ||
|
||||||
|
draftRevision !== engineDraftRevision.current ||
|
||||||
|
draftRevision !== loadedEngineRevision.current
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
const scope = capabilitiesScope(next.engine_mode, next.engine_url);
|
||||||
setLoading(true);
|
if (loadedCapabilitiesScope.current !== scope) {
|
||||||
setError(null);
|
setCapabilities(null);
|
||||||
void adminApi.usage(windowDays)
|
setCapabilitiesError(null);
|
||||||
.then((nextUsage) => {
|
}
|
||||||
if (active) setUsage(nextUsage);
|
setEngine(next);
|
||||||
|
loadedEngineRevision.current = draftRevision;
|
||||||
|
setEngineError(null);
|
||||||
|
void loadCapabilities(next.engine_mode, next.engine_url, { draftRevision });
|
||||||
})
|
})
|
||||||
.catch((cause) => {
|
.catch((cause) => {
|
||||||
if (active) {
|
if (!mounted.current || generation !== loadGeneration.current) return;
|
||||||
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
|
setEngineError(adminReadError(cause, "AI 엔진 설정"));
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (active) setLoading(false);
|
if (mounted.current && generation === loadGeneration.current) setEngineLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
|
||||||
active = false;
|
const [usageResult, healthResult] = await Promise.all([usagePromise, healthPromise]);
|
||||||
};
|
if (
|
||||||
}, [windowDays]);
|
!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 () => {
|
const saveEngine = async () => {
|
||||||
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
|
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
|
||||||
|
|
@ -307,9 +486,11 @@ export default function AdminAi() {
|
||||||
model: engine.model.trim(),
|
model: engine.model.trim(),
|
||||||
reasoning_effort: engine.reasoning_effort,
|
reasoning_effort: engine.reasoning_effort,
|
||||||
});
|
});
|
||||||
setEngine(next);
|
const savedRevision = updateEngineDraft(() => next);
|
||||||
|
loadedEngineRevision.current = savedRevision;
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setHealth(await adminApi.health().catch(() => health));
|
const healthResult = await loadHealth();
|
||||||
|
if (healthResult.error) setError(healthResult.error);
|
||||||
window.setTimeout(() => setSaved(false), 2400);
|
window.setTimeout(() => setSaved(false), 2400);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
|
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
|
||||||
|
|
@ -317,7 +498,6 @@ export default function AdminAi() {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const usageCostBasis = readCostBasis(usage);
|
const usageCostBasis = readCostBasis(usage);
|
||||||
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
|
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
|
||||||
const daily = usage?.daily_cost ?? [];
|
const daily = usage?.daily_cost ?? [];
|
||||||
|
|
@ -710,6 +890,14 @@ export default function AdminAi() {
|
||||||
{healthStatusLabel(engineService?.status)}
|
{healthStatusLabel(engineService?.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{engineError ? (
|
||||||
|
<div className="aic-capability is-unavailable" role="alert">
|
||||||
|
<div>
|
||||||
|
<b>엔진 설정을 확인할 수 없음</b>
|
||||||
|
<span>{engineError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{engine ? (
|
{engine ? (
|
||||||
<>
|
<>
|
||||||
<Field
|
<Field
|
||||||
|
|
@ -726,15 +914,17 @@ export default function AdminAi() {
|
||||||
const engineMode = event.target
|
const engineMode = event.target
|
||||||
.value as AdminEngineConfigResponse["engine_mode"];
|
.value as AdminEngineConfigResponse["engine_mode"];
|
||||||
setCapabilities(null);
|
setCapabilities(null);
|
||||||
setEngine({
|
loadedCapabilitiesScope.current = null;
|
||||||
...engine,
|
const draftRevision = updateEngineDraft((current) => ({
|
||||||
|
...current,
|
||||||
engine_mode: engineMode,
|
engine_mode: engineMode,
|
||||||
model: "",
|
model: "",
|
||||||
reasoning_effort: null,
|
reasoning_effort: null,
|
||||||
});
|
}));
|
||||||
void loadCapabilities(engineMode, engine.engine_url, {
|
void loadCapabilities(engineMode, engine.engine_url, {
|
||||||
force: true,
|
force: true,
|
||||||
applyDefault: true,
|
applyDefault: true,
|
||||||
|
draftRevision,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -750,8 +940,12 @@ export default function AdminAi() {
|
||||||
id="aic-engine-url"
|
id="aic-engine-url"
|
||||||
value={engine.engine_url}
|
value={engine.engine_url}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setEngine({ ...engine, engine_url: event.target.value });
|
updateEngineDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
engine_url: event.target.value,
|
||||||
|
}));
|
||||||
setCapabilities(null);
|
setCapabilities(null);
|
||||||
|
loadedCapabilitiesScope.current = null;
|
||||||
setCapabilitiesError(
|
setCapabilitiesError(
|
||||||
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
|
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
|
||||||
);
|
);
|
||||||
|
|
@ -771,11 +965,11 @@ export default function AdminAi() {
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const model = event.target.value;
|
const model = event.target.value;
|
||||||
const option = capabilityModels.find((item) => item.id === model);
|
const option = capabilityModels.find((item) => item.id === model);
|
||||||
setEngine({
|
updateEngineDraft((current) => ({
|
||||||
...engine,
|
...current,
|
||||||
model,
|
model,
|
||||||
reasoning_effort: option?.default_reasoning_effort ?? null,
|
reasoning_effort: option?.default_reasoning_effort ?? null,
|
||||||
});
|
}));
|
||||||
}}
|
}}
|
||||||
aria-label="AI 기본 모델"
|
aria-label="AI 기본 모델"
|
||||||
disabled={capabilitiesLoading || !capabilities?.available}
|
disabled={capabilitiesLoading || !capabilities?.available}
|
||||||
|
|
@ -805,11 +999,11 @@ export default function AdminAi() {
|
||||||
aria-label="AI 추론 강도"
|
aria-label="AI 추론 강도"
|
||||||
disabled={capabilitiesLoading || !selectedModel}
|
disabled={capabilitiesLoading || !selectedModel}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEngine({
|
updateEngineDraft((current) => ({
|
||||||
...engine,
|
...current,
|
||||||
reasoning_effort: (event.target.value ||
|
reasoning_effort: (event.target.value ||
|
||||||
null) as AdminEngineConfigResponse["reasoning_effort"],
|
null) as AdminEngineConfigResponse["reasoning_effort"],
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selectedEfforts.length ? null : (
|
{selectedEfforts.length ? null : (
|
||||||
|
|
@ -858,6 +1052,7 @@ export default function AdminAi() {
|
||||||
void loadCapabilities(engine.engine_mode, engine.engine_url, {
|
void loadCapabilities(engine.engine_mode, engine.engine_url, {
|
||||||
force: true,
|
force: true,
|
||||||
applyDefault: !selectedModel,
|
applyDefault: !selectedModel,
|
||||||
|
draftRevision: engineDraftRevision.current,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={capabilitiesLoading}
|
disabled={capabilitiesLoading}
|
||||||
|
|
@ -889,7 +1084,11 @@ export default function AdminAi() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="aic-empty">현재 AI 엔진 설정을 불러오는 중입니다.</div>
|
<div className="aic-empty">
|
||||||
|
{engineLoading
|
||||||
|
? "현재 AI 엔진 설정을 불러오는 중입니다."
|
||||||
|
: "현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue