vignette/apps/web/src/pages/AdminAi.tsx

737 lines
32 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import { Button, Field, Icon, Input, Kicker } from "../components/ui";
import {
adminApi,
adminEngineApi,
type AdminEngineConfigResponse,
type AdminHealthResponse,
type AdminUsageResponse,
type EngineCapabilitiesResponse,
} from "../lib/api";
import "./admin/admin-ai.css";
const WINDOW_OPTIONS = [7, 30, 90] as const;
const ENGINE_MODES = [
"claude_cli",
"claude_api",
"codex_cli",
"agy_cli",
"openai",
"solar",
] as const;
const ENGINE_MODE_LABEL: Record<string, string> = {
claude_cli: "Claude CLI 게이트웨이",
messages_api: "클라우드 연결",
claude_api: "Anthropic API",
codex_cli: "Codex CLI 게이트웨이",
agy_cli: "Agy CLI 게이트웨이",
openai: "OpenAI 호환",
solar: "Solar",
};
function countLabel(value: number): string {
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
}
/**
* 금액 표기 정밀도 — 누적·일별처럼 합계 성격의 금액은 소수 2자리로 읽기 쉽게,
* 호출당 단가처럼 값이 작아 2자리로는 0이 되는 금액만 4자리로 보여준다.
* 반올림 이전 원본 값은 costTitle()로 title 툴팁에 남긴다.
*/
function costLabel(value: number, digits: 2 | 4 = 2): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(digits)}`;
}
/** 반올림 전 원본 정밀도 — 화면에는 안 쓰고 title 툴팁으로만 노출한다. */
function costTitle(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
const exact = value.toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
return `$${exact === "0" ? value.toPrecision(3) : exact}`;
}
function rateLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
}
function dateTimeLabel(value?: number | null): string {
if (!value || !Number.isFinite(value)) return "기록 없음";
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value * 1000));
}
function dayLabel(value: string): string {
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", { month: "numeric", day: "numeric" }).format(date);
}
function sourceLabel(usage: AdminUsageResponse): string {
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
}
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
if (status === "exceeded") return "예산 초과";
if (status === "warn") return "예산 주의";
if (status === "ok") return "예산 정상";
return "예산 경고 비활성";
}
function healthStatusLabel(status?: string): string {
if (status === "ok") return "정상";
if (status === "down") return "중단";
if (status === "degraded") return "제한 운영";
return "확인 불가";
}
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 [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialLoad = useRef(true);
const capabilityRequestId = useRef(0);
const loadCapabilities = useCallback(
async (
engineMode: string,
engineUrl: string,
options: { force?: boolean; applyDefault?: boolean } = {},
) => {
const requestId = ++capabilityRequestId.current;
setCapabilitiesLoading(true);
setCapabilitiesError(null);
try {
const next = await adminEngineApi.capabilities(
engineMode,
options.force ?? false,
engineUrl,
);
if (requestId !== capabilityRequestId.current) return;
setCapabilities(next);
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,
};
});
}
} catch (cause) {
if (requestId !== capabilityRequestId.current) return;
setCapabilities(null);
setCapabilitiesError(
cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.",
);
} finally {
if (requestId === capabilityRequestId.current) setCapabilitiesLoading(false);
}
},
[],
);
const load = useCallback(async () => {
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]);
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);
})
.catch((cause) => {
if (active) {
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
}
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [windowDays]);
const saveEngine = async () => {
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
setSaving(true);
setSaved(false);
setError(null);
try {
const next = await adminEngineApi.update({
engine_mode: engine.engine_mode,
engine_url: engine.engine_url.trim(),
model: engine.model.trim(),
reasoning_effort: engine.reasoning_effort,
});
setEngine(next);
setSaved(true);
setHealth(await adminApi.health().catch(() => health));
window.setTimeout(() => setSaved(false), 2400);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
} finally {
setSaving(false);
}
};
const daily = usage?.daily_cost ?? [];
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
const coverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
const engineService = health?.services.find((service) => service.key === "engine");
const capabilityModels = capabilities?.models ?? [];
const selectedModel = capabilityModels.find((model) => model.id === engine?.model);
const selectedEfforts = selectedModel?.reasoning_efforts ?? [];
const engineSelectionValid = Boolean(
engine &&
capabilities?.available &&
selectedModel &&
(!engine.reasoning_effort || selectedEfforts.includes(engine.reasoning_effort)),
);
const cache = usage?.evaluator_cache;
const sortedBreakdown = useMemo(
() => [...(usage?.by_provider ?? [])].sort((a, b) => b.cost_usd - a.cost_usd),
[usage],
);
return (
<AppShell contextLabel="AI 운영" navRole="admin" wide>
<div className="aic" data-testid="admin-ai-page" aria-busy={loading}>
<header className="aic-head">
<div>
{/* 영문 대문자 eyebrow는 한글로 — "관리자"는 제목에 없는 역할 맥락이라 유지한다. */}
<Kicker> · AI </Kicker>
<h1>AI DB </h1>
<p> DB에 ·· .</p>
</div>
<div className="aic-head__actions">
<span className="aic-head__stamp">
<b>{dateTimeLabel(usage?.generated_at)}</b>
</span>
<Button
variant="secondary"
size="sm"
leading={<Icon name="arrow-flat" size={15} />}
onClick={() => void load()}
disabled={loading}
>
</Button>
</div>
</header>
{error ? (
<div className="aic-alert" role="alert">
<Icon name="alert" size={18} />
<span>{error}</span>
</div>
) : null}
<section className="aic-source" aria-label="계량 데이터 원천">
<span className={`aic-source__dot ${usage?.durable ? "is-live" : ""}`} aria-hidden="true" />
<div>
<b>{usage ? sourceLabel(usage) : "계량 원천 확인 중"}</b>
<span>
{usage?.durable
? "호출 완료 시점의 provider·model·token·cost 필드를 DB에서 다시 집계합니다."
: "DB 계량이 아니므로 서버 재시작 시 집계가 사라질 수 있습니다."}
</span>
</div>
<div className="aic-window" role="group" aria-label="계량 기간">
{WINDOW_OPTIONS.map((days) => (
<button
key={days}
type="button"
className={windowDays === days ? "is-active" : ""}
aria-pressed={windowDays === days}
onClick={() => setWindowDays(days)}
>
{days}
</button>
))}
</div>
</section>
<section className="aic-ledger" aria-label="AI 사용 핵심 지표">
<article>
<span> </span>
{/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */}
<b title={usage ? costTitle(usage.cost_usd) : undefined}>
{usage ? costLabel(usage.cost_usd) : "—"}
</b>
<small>{windowDays} DB </small>
</article>
<article>
<span> </span>
<b>{usage ? countLabel(usage.metered_turns) : "—"}</b>
<small> {usage ? countLabel(usage.total_turns) : "—"}</small>
</article>
<article>
<span> </span>
<b>{usage ? countLabel(usage.tokens_in) : "—"}</b>
<small>·</small>
</article>
<article>
<span> </span>
<b>{usage ? countLabel(usage.tokens_out) : "—"}</b>
<small> </small>
</article>
<article>
<span> </span>
{/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */}
<b title={usage ? costTitle(costPerTurn) : undefined}>
{usage ? costLabel(costPerTurn, 4) : "—"}
</b>
<small title={usage && totalTokens > 0 ? costTitle(costPerThousandTokens) : undefined}>
1 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, 4) : "계산 불가") : "—"}
</small>
</article>
</section>
<div className="aic-grid">
<div className="aic-column aic-column--main">
<section className="aic-panel aic-budget">
<div className="aic-panel__head">
<div>
{/* SPEND CONTROL → 한글. 제목이 다루지 않는 "관리" 관점을 남긴다. */}
<span className="aic-eyebrow"> </span>
<h2> </h2>
</div>
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
</span>
</div>
<div className="aic-budget__split">
<div>
<div className="aic-metricline">
<span> </span>
<b
title={
usage && usage.budget.status !== "disabled"
? `${costTitle(usage.cost_usd)} / ${costTitle(usage.budget.limit_usd)}`
: undefined
}
>
{usage?.budget.status === "disabled"
? "한도 미설정"
: `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
</b>
</div>
<div className="aic-track" aria-label={`예산 사용률 ${rateLabel(usage?.budget.used_ratio ?? 0)}`}>
<span style={{ width: `${Math.min(100, (usage?.budget.used_ratio ?? 0) * 100)}%` }} />
</div>
<p
title={
usage && usage.budget.status !== "disabled"
? `잔여 ${costTitle(usage.budget.remaining_usd ?? 0)}`
: undefined
}
>
{usage?.budget.status === "disabled"
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
</p>
</div>
<div>
<div className="aic-metricline">
<span>DB </span>
<b>{rateLabel(coverage)}</b>
</div>
<div className="aic-track aic-track--coverage" aria-label={`DB 계량 커버리지 ${rateLabel(coverage)}`}>
<span style={{ width: `${Math.min(100, coverage * 100)}%` }} />
</div>
<p>
{unmeteredTurns > 0
? `${countLabel(unmeteredTurns)}개 턴은 provider·model·token·cost 계량 필드가 없습니다.`
: "조회 기간의 모든 완료 턴에 계량 정보가 연결되어 있습니다."}
</p>
</div>
</div>
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
{/* DAILY JOURNAL → 한글. */}
<span className="aic-eyebrow"> </span>
<h2> </h2>
</div>
<span className="aic-panel__meta">{daily.length} </span>
</div>
{daily.length > 0 ? (
<div className="aic-chart" role="img" aria-label={`${windowDays}일 일별 AI 비용 막대 차트`}>
{daily.map((item) => {
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
return (
// 막대 위 라벨은 일별 합계라 소수 2자리, 원본 정밀도는 title 로 남긴다.
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costTitle(item.cost_usd)}`}>
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
<span className="aic-chart__rail">
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
</span>
<span className="aic-chart__date">{dayLabel(item.day)}</span>
<small>{countLabel(item.turns)}</small>
</div>
);
})}
</div>
) : (
<div className="aic-empty"> AI .</div>
)}
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
{/* MODEL LEDGER eyebrow 제거 — 바로 아래 제목이 그대로 "모델별 사용 원장"이라 중복. */}
<h2>Provider· </h2>
</div>
<span className="aic-panel__meta"> </span>
</div>
{sortedBreakdown.length > 0 ? (
<div className="aic-table-wrap">
<table className="aic-table">
<thead>
<tr>
<th scope="col">Provider / </th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"> </th>
</tr>
</thead>
<tbody>
{sortedBreakdown.map((row) => (
<tr key={`${row.provider}:${row.model}`}>
<td>
<b>{row.model}</b>
<span>{row.provider}</span>
</td>
<td>{countLabel(row.turns)}</td>
<td>{countLabel(row.tokens_in)}</td>
<td>{countLabel(row.tokens_out)}</td>
{/* 합계 비용은 2자리, 호출당 단가는 4자리. 원본은 title. */}
<td title={costTitle(row.cost_usd)}>{costLabel(row.cost_usd)}</td>
<td title={costTitle(row.turns > 0 ? row.cost_usd / row.turns : 0)}>
{costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)}
</td>
<td>
<span className="aic-share">
<span style={{ width: `${usage && usage.cost_usd > 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
</span>
{rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="aic-empty"> .</div>
)}
</section>
</div>
<div className="aic-column aic-column--side">
<section className="aic-panel aic-engine">
<div className="aic-panel__head">
<div>
{/* RUNTIME ROUTE → 한글. 제목의 "설정"과 달리 어디로 호출이 나가는지를 가리킨다. */}
<span className="aic-eyebrow"> </span>
<h2>AI </h2>
</div>
<span className={`aic-runtime aic-runtime--${engineService?.status ?? "degraded"}`}>
{healthStatusLabel(engineService?.status)}
</span>
</div>
{engine ? (
<>
<Field
label="엔진 공급자"
hint="저장 전에 게이트웨이가 실제 가용 모델을 조회하고 검증합니다."
htmlFor="aic-engine-mode"
>
<select
id="aic-engine-mode"
className="vg-input aic-select"
value={engine.engine_mode}
aria-label="AI 엔진 공급자"
onChange={(event) => {
const engineMode = event.target
.value as AdminEngineConfigResponse["engine_mode"];
setCapabilities(null);
setEngine({
...engine,
engine_mode: engineMode,
model: "",
reasoning_effort: null,
});
void loadCapabilities(engineMode, engine.engine_url, {
force: true,
applyDefault: true,
});
}}
>
{ENGINE_MODES.map((mode) => (
<option key={mode} value={mode}>
{ENGINE_MODE_LABEL[mode]}
</option>
))}
</select>
</Field>
<Field label="연결 주소" hint="응답 생성 게이트웨이 또는 API base URL입니다." htmlFor="aic-engine-url">
<Input
id="aic-engine-url"
value={engine.engine_url}
onChange={(event) => {
setEngine({ ...engine, engine_url: event.target.value });
setCapabilities(null);
setCapabilitiesError(
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
);
}}
aria-label="AI 연결 주소"
/>
</Field>
<Field
label="기본 모델"
hint="게이트웨이가 현재 계정에서 확인한 모델만 선택할 수 있습니다."
htmlFor="aic-engine-model"
>
<select
id="aic-engine-model"
className="vg-input aic-select"
value={engine.model}
onChange={(event) => {
const model = event.target.value;
const option = capabilityModels.find((item) => item.id === model);
setEngine({
...engine,
model,
reasoning_effort: option?.default_reasoning_effort ?? null,
});
}}
aria-label="AI 기본 모델"
disabled={capabilitiesLoading || !capabilities?.available}
>
{!selectedModel && engine.model ? (
<option value={engine.model} disabled>
{engine.model} ·
</option>
) : null}
{!engine.model ? <option value=""> </option> : null}
{capabilityModels.map((model) => (
<option key={model.id} value={model.id}>
{model.label}
</option>
))}
</select>
</Field>
<Field
label="추론 강도"
hint="선택한 모델이 공식적으로 지원하는 단계만 표시됩니다."
htmlFor="aic-engine-effort"
>
<select
id="aic-engine-effort"
className="vg-input aic-select"
value={engine.reasoning_effort ?? ""}
aria-label="AI 추론 강도"
disabled={capabilitiesLoading || !selectedModel}
onChange={(event) =>
setEngine({
...engine,
reasoning_effort: (event.target.value ||
null) as AdminEngineConfigResponse["reasoning_effort"],
})
}
>
{selectedEfforts.length ? null : (
<option value=""> </option>
)}
{selectedEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort === "low"
? "Low"
: effort === "medium"
? "Medium"
: effort === "high"
? "High"
: effort === "xhigh"
? "Extra High"
: effort === "max"
? "Max"
: "Ultra"}
</option>
))}
</select>
</Field>
<div
className={`aic-capability ${capabilities?.available ? "is-ready" : "is-unavailable"}`}
role={capabilitiesError || capabilities?.available === false ? "alert" : "status"}
>
<div>
<b>
{capabilitiesLoading
? "모델 목록 확인 중"
: capabilities?.available
? `${capabilityModels.length}개 모델 확인됨`
: "모델 목록을 확인할 수 없음"}
</b>
<span>
{capabilitiesError ??
capabilities?.detail ??
"게이트웨이 연결 뒤 모델 목록을 표시합니다."}
</span>
{selectedModel?.description ? <span>{selectedModel.description}</span> : null}
</div>
<Button
size="sm"
variant="secondary"
onClick={() =>
void loadCapabilities(engine.engine_mode, engine.engine_url, {
force: true,
applyDefault: !selectedModel,
})
}
disabled={capabilitiesLoading}
>
</Button>
</div>
<dl className="aic-config-meta">
<div><dt> </dt><dd>{engine.durable ? "DB 영구 저장" : "런타임 적용"}</dd></div>
<div><dt> </dt><dd>{engine.source}</dd></div>
<div><dt> </dt><dd>{dateTimeLabel(engine.updated_at)}</dd></div>
<div><dt></dt><dd>{engine.updated_by ?? "기록 없음"}</dd></div>
</dl>
<div className="aic-engine__foot">
<span>{saved ? <><Icon name="check" size={15} /> </> : engineService?.detail ?? "엔진 상태를 확인합니다."}</span>
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveEngine()}
disabled={
saving ||
capabilitiesLoading ||
!engine.engine_url.trim() ||
!engineSelectionValid
}
>
{saving ? "저장 중" : "운영 설정 저장"}
</Button>
</div>
</>
) : (
<div className="aic-empty"> AI .</div>
)}
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
{/* EVALUATOR CACHE eyebrow 제거 — 제목 "평가 캐시 효율"과 같은 말. */}
<h2> </h2>
</div>
<span className={`aic-runtime ${cache?.enabled ? "aic-runtime--ok" : ""}`}>
{cache?.enabled ? "사용 중" : "비활성"}
</span>
</div>
<div className="aic-cache-score">
<b>{cache ? rateLabel(cache.hit_rate) : "—"}</b>
<span>hit-rate</span>
</div>
<dl className="aic-mini-grid">
<div><dt></dt><dd>{countLabel(cache?.requests ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.hits ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.misses ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.stores ?? 0)}</dd></div>
<div><dt> </dt><dd>{countLabel(cache?.entries ?? 0)}</dd></div>
<div><dt></dt><dd>{countLabel(cache?.evictions ?? 0)}</dd></div>
</dl>
<p className="aic-note"> .</p>
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
{/* METERING INTEGRITY eyebrow 제거 — 제목 "DB 계량 상태"와 같은 말. */}
<h2>DB </h2>
</div>
</div>
<dl className="aic-integrity">
<div>
<dt></dt>
<dd>{usage ? sourceLabel(usage) : "확인 중"}</dd>
<span>{usage?.source ?? "—"}</span>
</div>
<div>
<dt></dt>
<dd>{usage?.durable ? "재시작 후 유지" : "런타임 한정"}</dd>
<span>{usage?.durable ? "durable=true" : "durable=false"}</span>
</div>
<div>
<dt> </dt>
<dd>{countLabel(unmeteredTurns)}</dd>
<span>{unmeteredTurns > 0 ? "감사 필요" : "누락 없음"}</span>
</div>
</dl>
</section>
</div>
</div>
</div>
</AppShell>
);
}