운영 화면과 회기 리뷰 UI 갱신
This commit is contained in:
parent
e7ebb38177
commit
3a9f70a97b
18 changed files with 2210 additions and 137 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue