전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -0,0 +1,504 @@
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,
} from "../lib/api";
import "./admin/admin-ai.css";
const WINDOW_OPTIONS = [7, 30, 90] as const;
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
const ENGINE_MODE_LABEL: Record<string, string> = {
claude_cli: "Claude CLI 게이트웨이",
messages_api: "클라우드 연결",
claude_api: "Anthropic API",
openai: "OpenAI 호환",
solar: "Solar",
};
function countLabel(value: number): string {
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
}
function costLabel(value: number, precise = false): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(precise || value < 0.01 ? 6 : 4)}`;
}
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 [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 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);
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);
}
}, [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(),
});
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 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>
<Kicker> · AI CONTROL LEDGER</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>
<b>{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>
<b>{usage ? costLabel(costPerTurn, true) : "—"}</b>
<small>
1 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, true) : "계산 불가") : "—"}
</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>
<span className="aic-eyebrow">SPEND CONTROL</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>
{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>
{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>
<span className="aic-eyebrow">DAILY JOURNAL</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 (
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costLabel(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>
<span className="aic-eyebrow">MODEL LEDGER</span>
<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>
<td>{costLabel(row.cost_usd)}</td>
<td>{costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, true)}</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>
<span className="aic-eyebrow">RUNTIME ROUTE</span>
<h2>AI </h2>
</div>
<span className={`aic-runtime aic-runtime--${engineService?.status ?? "degraded"}`}>
{healthStatusLabel(engineService?.status)}
</span>
</div>
{engine ? (
<>
<div className="aic-mode" role="radiogroup" aria-label="AI 운영 방식">
{ENGINE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={engine.engine_mode === mode}
className={engine.engine_mode === mode ? "is-active" : ""}
onClick={() => setEngine({ ...engine, engine_mode: mode })}
>
{ENGINE_MODE_LABEL[mode]}
</button>
))}
</div>
<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 })}
aria-label="AI 연결 주소"
/>
</Field>
<Field label="기본 모델" hint="개별 평가 모델 override가 없을 때 적용됩니다." htmlFor="aic-engine-model">
<Input
id="aic-engine-model"
value={engine.model}
onChange={(event) => setEngine({ ...engine, model: event.target.value })}
aria-label="AI 기본 모델"
/>
</Field>
<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 || !engine.engine_url.trim() || !engine.model.trim()}
>
{saving ? "저장 중" : "운영 설정 저장"}
</Button>
</div>
</>
) : (
<div className="aic-empty"> AI .</div>
)}
</section>
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">EVALUATOR CACHE</span>
<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>
<span className="aic-eyebrow">METERING INTEGRITY</span>
<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>
);
}