운영 화면과 회기 리뷰 UI 갱신

This commit is contained in:
Yun Chan 2026-06-28 20:13:21 +09:00
parent e7ebb38177
commit 3a9f70a97b
18 changed files with 2210 additions and 137 deletions

View file

@ -53,6 +53,14 @@ interface AdminUsageBreakdown {
cost_usd: number;
}
interface AdminUsageDailyCost {
day: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
@ -60,6 +68,17 @@ interface AdminUsageBudget {
status: "disabled" | "ok" | "warn" | "exceeded";
}
interface AdminUsageEvaluatorCache {
enabled: boolean;
entries: number;
hits: number;
misses: number;
stores: number;
evictions: number;
requests: number;
hit_rate: number;
}
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
@ -70,7 +89,9 @@ interface AdminUsageResponse {
tokens_out: number;
cost_usd: number;
budget: AdminUsageBudget;
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[];
daily_cost?: AdminUsageDailyCost[];
}
interface AdminUptimeResponse {
@ -99,6 +120,10 @@ interface AdminSupportTicket {
subject: string;
body: string;
source_path: string;
parent_ticket_id?: string | null;
duplicate_count?: number;
duplicate_parent_candidate_id?: string | null;
child_ticket_count?: number;
created_at: number;
updated_at: number;
}
@ -262,6 +287,30 @@ function costLabel(value: number) {
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function rateLabel(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${Math.round(value * 1000) / 10}%`;
}
function evaluatorCache(response: AdminUsageResponse): AdminUsageEvaluatorCache {
return (
response.evaluator_cache ?? {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
}
);
}
function usageDailyCost(response: AdminUsageResponse): AdminUsageDailyCost[] {
return response.daily_cost ?? [];
}
function isOnline(seconds: number) {
return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60;
}
@ -446,6 +495,20 @@ test.describe("admin route", () => {
? "예산 주의"
: "예산 정상",
);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"평가 캐시 hit-rate",
);
const cache = evaluatorCache(usage);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
cache.enabled
? `${rateLabel(cache.hit_rate)} hit`
: "캐시 비활성",
);
if (usageDailyCost(usage).length > 0) {
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"일별 비용 추이",
);
}
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByText("샘플 정상률")).toBeVisible();
expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0);
@ -657,6 +720,69 @@ test.describe("admin route", () => {
}
});
test("manually links duplicate operation tickets", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const subject = `[E2E] 중복 운영 티켓 ${slug}`;
const createdTicketIds: string[] = [];
try {
for (let i = 0; i < 2; i += 1) {
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "voice_browser",
priority: "normal",
subject,
body: "E2E 중복 티켓 수동 연결 검증입니다. 실제 음성 장애가 아닙니다.",
source_path: "/__e2e__/learn/session/duplicate",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
createdTicketIds.push(created.ticket_id);
}
const tickets = await openAdminTickets(page);
const createdTickets = tickets.tickets.filter((ticket) => createdTicketIds.includes(ticket.ticket_id));
expect(createdTickets).toHaveLength(2);
expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy();
const linkableCard = page
.locator(".ad-ticket")
.filter({ hasText: subject })
.filter({ has: page.getByRole("button", { name: "연결" }) });
await expect(linkableCard).toBeVisible();
const patchPromise = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" &&
createdTicketIds.some((ticketId) => response.url().endsWith(`/api/admin/tickets/${ticketId}`)),
);
await linkableCard.getByRole("button", { name: "연결" }).click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminSupportTicket;
expect(updated.parent_ticket_id).toBeTruthy();
await expect(page.locator(".ad-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible();
await expectNoHorizontalOverflow(page);
} finally {
for (const ticketId of createdTicketIds) {
const cleanup = await page.request.patch(`/api/admin/tickets/${ticketId}`, {
data: {
status: "resolved",
parent_ticket_id: "",
resolution_note: "E2E cleanup",
},
});
if (!cleanup.ok()) {
console.warn(`E2E duplicate ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`);
}
}
}
});
test("denies learner access to the admin API and UI", async ({ page }) => {
await signInAsLearner(page);