현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -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;}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue