현재 작업 전체 반영

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

@ -6,6 +6,7 @@ import {
adminUsersApi,
type AdminHealthResponse,
type AdminHealthStatus,
type AdminUsageResponse,
type AdminManagedUser,
type AdminUserCreateRequest,
type AdminUsersResponse,
@ -84,6 +85,38 @@ function dateTimeLabel(seconds: number): string {
});
}
function countLabel(value: number): string {
if (!Number.isFinite(value)) return "0";
return Math.round(value).toLocaleString("ko-KR");
}
function costLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function usageSourceLabel(data: AdminUsageResponse | null): string {
if (!data) return "대기 중";
return data.durable ? "DB 계량" : "비영구 런타임 계량";
}
function usageBudgetLabel(data: AdminUsageResponse): string {
const { budget } = data;
if (budget.status === "disabled") return "예산 경고 비활성";
if (budget.status === "exceeded") return "예산 초과";
if (budget.status === "warn") return "예산 주의";
return "예산 정상";
}
function usageBudgetDetail(data: AdminUsageResponse): string {
const { budget } = data;
if (budget.status === "disabled") return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다.";
const pct = Math.round(budget.used_ratio * 100);
const remaining =
budget.remaining_usd === null ? "" : ` · 잔여 ${costLabel(budget.remaining_usd)}`;
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
}
function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email;
return Array.from(label)[0]?.toUpperCase() ?? "?";
@ -114,6 +147,9 @@ export default function Admin() {
const [creatingUser, setCreatingUser] = useState(false);
const [deactivatingUserId, setDeactivatingUserId] = useState<string | null>(null);
const [userSearch, setUserSearch] = useState("");
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [usageLoading, setUsageLoading] = useState(true);
const [usageError, setUsageError] = useState<string | null>(null);
const loadHealth = useCallback(async () => {
setLoading(true);
@ -155,14 +191,27 @@ export default function Admin() {
}
}, []);
const loadUsage = useCallback(async () => {
setUsageLoading(true);
setUsageError(null);
try {
setUsage(await adminApi.usage(7));
} catch (err) {
setUsageError(err instanceof Error ? err.message : "비용 사용량을 불러오지 못했습니다.");
} finally {
setUsageLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
void loadUsers();
}, [loadHealth, loadUsers]);
void loadUsage();
}, [loadHealth, loadUsage, loadUsers]);
const refreshAll = useCallback(async () => {
await Promise.all([loadHealth(), loadUsers()]);
}, [loadHealth, loadUsers]);
await Promise.all([loadHealth(), loadUsers(), loadUsage()]);
}, [loadHealth, loadUsage, loadUsers]);
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
setUserDrafts((current) => ({
@ -308,9 +357,9 @@ export default function Admin() {
variant="secondary"
leading={<Icon name="settings" size={16} />}
onClick={() => void refreshAll()}
disabled={loading || usersLoading}
disabled={loading || usersLoading || usageLoading}
>
{loading || usersLoading ? "확인 중" : "새로고침"}
{loading || usersLoading || usageLoading ? "확인 중" : "새로고침"}
</Button>
</header>
@ -355,6 +404,95 @@ export default function Admin() {
</section>
</section>
<section className="ad-section ad-usage-section" aria-label="AI 비용 관측">
<div className="ad-section__head">
<h2>AI </h2>
<span>
{usage
? `최근 ${usage.window_days}일 · ${usageSourceLabel(usage)}`
: usageSourceLabel(usage)}
</span>
</div>
{usageError ? (
<section className="ad-error" role="alert">
<Icon name="alert" size={18} />
<span>{usageError}</span>
</section>
) : null}
<div className="ad-usage-grid">
<div className="ad-usage-kpi">
<span> </span>
<b>{usage ? costLabel(usage.cost_usd) : "-"}</b>
<small>{usage ? `${countLabel(usage.metered_turns)}개 계량 턴` : "계산 중"}</small>
</div>
<div className="ad-usage-kpi">
<span> </span>
<b>{usage ? countLabel(usage.tokens_in) : "-"}</b>
<small>/</small>
</div>
<div className="ad-usage-kpi">
<span> </span>
<b>{usage ? countLabel(usage.tokens_out) : "-"}</b>
<small> </small>
</div>
<div className="ad-usage-kpi">
<span> </span>
<b>
{usage && usage.total_turns > 0
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
: usage
? "0%"
: "-"}
</b>
<small>{usage ? `${countLabel(usage.total_turns)}개 내담자 턴` : "계산 중"}</small>
</div>
</div>
{usage ? (
<section
className={`ad-usage-budget ad-usage-budget--${usage.budget.status}`}
role={usage.budget.status === "warn" || usage.budget.status === "exceeded" ? "alert" : "status"}
>
<Icon
name={usage.budget.status === "warn" || usage.budget.status === "exceeded" ? "alert" : "check"}
size={18}
/>
<div>
<b>{usageBudgetLabel(usage)}</b>
<span>{usageBudgetDetail(usage)}</span>
</div>
</section>
) : null}
<div className="ad-usage-breakdown" aria-label="모델별 비용">
<div className="ad-usage-breakdown__head">
<span>Provider / Model</span>
<span></span>
<span></span>
<span></span>
</div>
{(usage?.by_provider ?? []).map((item) => (
<div className="ad-usage-row" key={`${item.provider}:${item.model}`}>
<span>
<b>{item.provider}</b>
<small>{item.model}</small>
</span>
<span>{countLabel(item.turns)}</span>
<span>{countLabel(item.tokens_in + item.tokens_out)}</span>
<span>{costLabel(item.cost_usd)}</span>
</div>
))}
{usageLoading && !usage ? (
<div className="ad-users-empty"> .</div>
) : null}
{!usageLoading && usage && usage.by_provider.length === 0 ? (
<div className="ad-users-empty"> AI .</div>
) : null}
</div>
</section>
{error ? (
<section className="ad-error" role="alert">
<Icon name="alert" size={18} />
@ -698,9 +836,25 @@ export default function Admin() {
const ADMIN_CSS = `
.ad-root{
width:min(100%,1180px);
margin:0 auto;
position:relative;
isolation:isolate;
display:flex;
flex-direction:column;
gap:18px;
gap:16px;
}
.ad-root::before{
content:"";
position:absolute;
z-index:-1;
inset:-80px -180px auto auto;
width:min(560px,52vw);
height:420px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.045;
filter:saturate(.75);
pointer-events:none;
}
.ad-head{
display:flex;
@ -708,7 +862,7 @@ const ADMIN_CSS = `
justify-content:space-between;
gap:var(--sp-4);
flex-wrap:wrap;
padding-bottom:2px;
padding:0 2px 2px;
}
.ad-head h1{
margin:6px 0 0;
@ -726,7 +880,7 @@ const ADMIN_CSS = `
}
.ad-ops{
display:grid;
grid-template-columns:minmax(0,max-content) minmax(320px,1fr);
grid-template-columns:minmax(320px,.64fr) minmax(0,1fr);
gap:12px;
}
.ad-status{
@ -738,7 +892,8 @@ const ADMIN_CSS = `
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
background:
linear-gradient(180deg,color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface)),var(--bg-surface));
box-shadow:var(--shadow-sm);
}
.ad-status__dot{
@ -779,7 +934,7 @@ const ADMIN_CSS = `
}
.ad-kpis{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
grid-template-columns:repeat(4,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
@ -793,7 +948,8 @@ const ADMIN_CSS = `
gap:4px;
}
.ad-kpi:nth-child(even){border-left:1px solid var(--hair);}
.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);}
.ad-kpi:nth-child(n+3){border-top:0;}
.ad-kpi__lab{
display:block;
color:var(--text-muted);
@ -812,6 +968,138 @@ const ADMIN_CSS = `
font-size:12px;
line-height:1.35;
}
.ad-usage-section{
padding:14px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.ad-usage-grid{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
}
.ad-usage-kpi{
min-width:0;
display:grid;
gap:5px;
padding:12px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.ad-usage-kpi span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.ad-usage-kpi b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:22px;
line-height:1;
overflow-wrap:anywhere;
}
.ad-usage-kpi small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
}
.ad-usage-budget{
display:flex;
align-items:flex-start;
gap:10px;
min-width:0;
padding:10px 12px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-muted);
}
.ad-usage-budget svg{
flex:0 0 auto;
margin-top:1px;
}
.ad-usage-budget div{
display:grid;
gap:2px;
min-width:0;
}
.ad-usage-budget b{
color:var(--text-strong);
font-size:var(--fs-xs);
line-height:1.3;
}
.ad-usage-budget span{
font-size:12px;
line-height:1.4;
overflow-wrap:anywhere;
}
.ad-usage-budget--warn{
border-color:color-mix(in srgb,var(--warn-solid) 42%,var(--hair));
background:color-mix(in srgb,var(--warn-tint) 72%,var(--bg-surface));
color:var(--warn-text);
}
.ad-usage-budget--exceeded{
border-color:color-mix(in srgb,var(--crit-solid) 42%,var(--hair));
background:color-mix(in srgb,var(--crit-tint) 72%,var(--bg-surface));
color:var(--crit-text);
}
.ad-usage-budget--ok{
border-color:color-mix(in srgb,var(--pos-solid) 36%,var(--hair));
background:color-mix(in srgb,var(--pos-tint) 72%,var(--bg-surface));
color:var(--pos-text);
}
.ad-usage-breakdown{
display:flex;
flex-direction:column;
min-width:0;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
overflow:hidden;
background:var(--bg-surface-2);
}
.ad-usage-breakdown__head,
.ad-usage-row{
display:grid;
grid-template-columns:minmax(170px,1fr) 72px 110px 90px;
align-items:center;
gap:10px;
padding:9px 12px;
}
.ad-usage-breakdown__head{
background:var(--bg-app);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:800;
}
.ad-usage-row{
border-top:1px solid var(--hair);
color:var(--text-body);
font-family:var(--font-num);
font-size:var(--fs-sm);
}
.ad-usage-row > span{
min-width:0;
}
.ad-usage-row > span:first-child{
display:grid;
gap:2px;
font-family:var(--font-sans);
}
.ad-usage-row b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.25;
overflow-wrap:anywhere;
}
.ad-usage-row small{
color:var(--text-muted);
font-size:12px;
line-height:1.25;
overflow-wrap:anywhere;
}
.ad-section{
display:flex;
flex-direction:column;
@ -996,12 +1284,13 @@ const ADMIN_CSS = `
}
.ad-user-workspace{
display:grid;
grid-template-columns:minmax(250px,292px) minmax(0,1fr);
grid-template-columns:minmax(0,1fr) minmax(258px,300px);
align-items:stretch;
gap:14px;
min-width:0;
}
.ad-user-sidecar{
order:2;
position:sticky;
top:calc(var(--topbar-h) + 16px);
align-self:start;
@ -1011,6 +1300,7 @@ const ADMIN_CSS = `
min-width:0;
}
.ad-user-listpane{
order:1;
display:flex;
flex-direction:column;
min-width:0;
@ -1073,10 +1363,10 @@ const ADMIN_CSS = `
.ad-user{
min-width:0;
display:grid;
grid-template-columns:minmax(150px,.75fr) minmax(260px,1.35fr) minmax(104px,.45fr) max-content;
grid-template-columns:minmax(190px,.75fr) minmax(420px,1.4fr) minmax(138px,.42fr) max-content;
align-items:center;
gap:10px 12px;
padding:11px 12px;
padding:10px 12px;
border-top:1px solid var(--paper-2);
background:var(--bg-surface);
}
@ -1123,7 +1413,7 @@ const ADMIN_CSS = `
}
.ad-user__fields{
display:grid;
grid-template-columns:minmax(0,1fr) 104px;
grid-template-columns:minmax(130px,1.1fr) 96px minmax(118px,.95fr) minmax(110px,.85fr);
gap:8px;
min-width:0;
}
@ -1134,10 +1424,17 @@ const ADMIN_CSS = `
gap:6px;
}
.ad-user__fields span{
display:none;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:650;
}
.ad-user__fields input,
.ad-user__fields select{
height:32px;
font-size:12.5px;
padding-inline:9px;
}
.ad-user__meta{
display:grid;
gap:4px;
@ -1180,12 +1477,20 @@ const ADMIN_CSS = `
grid-template-columns:1fr;
}
.ad-user-sidecar{
order:0;
position:static;
}
.ad-user-listpane{order:1;}
.ad-user-create{grid-template-columns:repeat(2,minmax(0,1fr));}
.ad-user{
grid-template-columns:minmax(190px,.8fr) minmax(0,1.2fr) max-content;
}
.ad-user__fields{
grid-template-columns:minmax(0,1fr) 104px;
}
.ad-user__fields span{
display:block;
}
.ad-user__meta{
grid-column:1 / -1;
display:flex;
@ -1203,6 +1508,9 @@ const ADMIN_CSS = `
.ad-kpi:nth-child(even),
.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);}
.ad-kpi:nth-child(n+3){border-top:0;}
.ad-usage-grid{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.ad-service{
grid-template-columns:minmax(160px,.85fr) minmax(0,1fr);
}
@ -1218,6 +1526,7 @@ const ADMIN_CSS = `
}
}
@media (max-width:700px){
.ad-root::before{display:none;}
.ad-head{
align-items:flex-start;
flex-direction:column;
@ -1231,6 +1540,16 @@ const ADMIN_CSS = `
.ad-kpi:nth-child(even){border-left:1px solid var(--hair);}
.ad-kpi:nth-child(odd){border-left:0;}
.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.ad-usage-breakdown__head{
display:none;
}
.ad-usage-row{
grid-template-columns:minmax(0,1fr) auto;
gap:8px 12px;
}
.ad-usage-row > span:first-child{
grid-row:1 / span 3;
}
.ad-service{
grid-template-columns:1fr;
}
@ -1259,6 +1578,13 @@ const ADMIN_CSS = `
border-left:0;
}
.ad-kpi + .ad-kpi{border-top:1px solid var(--hair);}
.ad-usage-grid{grid-template-columns:1fr;}
.ad-usage-row{
grid-template-columns:1fr;
}
.ad-usage-row > span:first-child{
grid-row:auto;
}
.ad-service__meter{grid-template-columns:1fr;}
.ad-service__meter span{text-align:left;}
.ad-user__top{align-items:stretch;flex-direction:column;}