현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -39,6 +39,35 @@ interface AdminUsersResponse {
users: AdminManagedUser[];
}
interface AdminUsageBreakdown {
provider: string;
model: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded";
}
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
window_days: number;
total_turns: number;
metered_turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
budget: AdminUsageBudget;
by_provider: AdminUsageBreakdown[];
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
@ -66,6 +95,11 @@ function isAdminUsersResponse(response: Response) {
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
}
function isAdminUsageResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage");
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
@ -106,17 +140,33 @@ function engineModeLabel(value: string) {
return value;
}
function countLabel(value: number) {
if (!Number.isFinite(value)) return "0";
return Math.round(value).toLocaleString("ko-KR");
}
function costLabel(value: number) {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
await page.goto("/admin");
const healthResponse = await healthResponsePromise;
const [healthResponse, usageResponse] = await Promise.all([
healthResponsePromise,
usageResponsePromise,
]);
await expectResponseOk(healthResponse);
await expectResponseOk(usageResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
const usage = (await usageResponse.json()) as AdminUsageResponse;
expect(health.services.length).toBeGreaterThan(0);
return health;
return { health, usage };
}
async function openAdminAndReadUsers(page: Page) {
@ -225,7 +275,7 @@ test.describe("admin route", () => {
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const health = await openAdminAndReadHealth(page);
const { health, usage } = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
@ -242,6 +292,34 @@ test.describe("admin route", () => {
String(counts.degraded),
String(counts.down),
]);
await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible();
await expect(page.locator(".ad-usage-kpi b")).toHaveText([
costLabel(usage.cost_usd),
countLabel(usage.tokens_in),
countLabel(usage.tokens_out),
usage.total_turns > 0
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
: "0%",
]);
await expect(page.locator(".ad-usage-budget")).toContainText(
usage.budget.status === "disabled"
? "예산 경고 비활성"
: usage.budget.status === "exceeded"
? "예산 초과"
: usage.budget.status === "warn"
? "예산 주의"
: "예산 정상",
);
if (usage.by_provider.length > 0) {
await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length);
await expect(page.locator(".ad-usage-row").first()).toContainText(
usage.by_provider[0].provider,
);
} else {
await expect(page.locator(".ad-usage-breakdown")).toContainText(
"최근 윈도우에 계량된 AI 턴이 없습니다.",
);
}
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {