운영 화면과 회기 리뷰 UI 갱신

This commit is contained in:
Yun Chan 2026-06-28 20:13:21 +09:00
parent e7ebb38177
commit 3a9f70a97b
18 changed files with 2210 additions and 137 deletions

View file

@ -221,6 +221,48 @@ function usageBudgetDetail(data: AdminUsageResponse): string {
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
}
function rateLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${Math.round(value * 1000) / 10}%`;
}
function evaluatorCache(data: AdminUsageResponse): AdminUsageResponse["evaluator_cache"] {
return (
data.evaluator_cache ?? {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
}
);
}
function evaluatorCacheLabel(data: AdminUsageResponse): string {
const cache = evaluatorCache(data);
if (!cache.enabled) return "캐시 비활성";
return `${rateLabel(cache.hit_rate)} hit`;
}
function evaluatorCacheDetail(data: AdminUsageResponse): string {
const cache = evaluatorCache(data);
if (!cache.enabled) return "EVALUATOR_SEMANTIC_CACHE 설정이 꺼져 있습니다.";
return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit · entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}`;
}
function usageDailyCost(data: AdminUsageResponse): NonNullable<AdminUsageResponse["daily_cost"]> {
return data.daily_cost ?? [];
}
function usageDayLabel(day: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
if (!match) return day;
return `${match[2]}.${match[3]}`;
}
function uptimeLabel(data: AdminUptimeResponse | null): string {
if (!data) return "-";
if (!data.durable || data.sample_count === 0) return "샘플 없음";
@ -268,6 +310,10 @@ function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" |
return "accent";
}
function shortTicketId(ticketId: string): string {
return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase();
}
function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email;
return Array.from(label)[0]?.toUpperCase() ?? "?";
@ -622,6 +668,34 @@ export default function Admin({ section = "overview" }: AdminProps) {
}
};
const updateTicketParent = async (
ticket: AdminSupportTicket,
parentTicketId: string | null,
) => {
setUpdatingTicketId(ticket.ticket_id);
setTicketsError(null);
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, {
parent_ticket_id: parentTicketId ?? "",
});
setTickets((current) =>
current
? {
...current,
tickets: current.tickets.map((item) =>
item.ticket_id === updated.ticket_id ? updated : item,
),
}
: current,
);
await loadTickets();
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다.");
} finally {
setUpdatingTicketId(null);
}
};
const services = health?.services ?? [];
const users = usersData?.users ?? [];
const counts = useMemo(
@ -848,6 +922,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
{usage ? <small>{usageBudgetDetail(usage)}</small> : null}
</div>
{usage ? <ProgressBar value={Math.min(100, Math.round(usage.budget.used_ratio * 100))} slim /> : null}
{usage ? (
<div className="ad-cache">
<span> hit-rate</span>
<b>{evaluatorCacheLabel(usage)}</b>
<small>{evaluatorCacheDetail(usage)}</small>
</div>
) : null}
{usage && usageDailyCost(usage).length > 0 ? (
<div className="ad-cost-trend">
<span> </span>
{usageDailyCost(usage)
.slice(-7)
.map((day) => (
<div key={day.day}>
<b>{usageDayLabel(day.day)}</b>
<span>
{costLabel(day.cost_usd)} · {countLabel(day.turns)}
</span>
</div>
))}
</div>
) : null}
</div>
<div className="ad-panel">
@ -1658,44 +1754,84 @@ export default function Admin({ section = "overview" }: AdminProps) {
{activeTickets.length > 0 ? (
<div className="ad-ticket-list" aria-label="미해결 운영 티켓 큐">
{activeTickets.map((ticket) => (
<article className="ad-ticket" key={ticket.ticket_id}>
<div>
<div className="ad-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span> {dateTimeLabel(ticket.updated_at)}</span>
{activeTickets.map((ticket) => {
const duplicateParentId = ticket.duplicate_parent_candidate_id;
const canLinkDuplicate =
ticket.duplicate_count > 0 &&
Boolean(duplicateParentId) &&
duplicateParentId !== ticket.ticket_id &&
duplicateParentId !== ticket.parent_ticket_id;
return (
<article className="ad-ticket" key={ticket.ticket_id}>
<div>
<div className="ad-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span> {dateTimeLabel(ticket.updated_at)}</span>
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
{(ticket.duplicate_count > 0 || ticket.parent_ticket_id || ticket.child_ticket_count > 0) ? (
<div className="ad-ticket__dupes">
{ticket.parent_ticket_id ? (
<span> #{shortTicketId(ticket.parent_ticket_id)}</span>
) : ticket.duplicate_count > 0 ? (
<span> {countLabel(ticket.duplicate_count)}</span>
) : null}
{ticket.child_ticket_count > 0 ? (
<span> {countLabel(ticket.child_ticket_count)}</span>
) : null}
</div>
) : null}
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
<div className="ad-ticket__actions">
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketStatus(ticket, "in_progress")}
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
>
</Button>
<Button
size="sm"
variant="primary"
onClick={() => void updateTicketStatus(ticket, "resolved")}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
</div>
</article>
))}
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
<div className="ad-ticket__actions">
{canLinkDuplicate ? (
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketParent(ticket, duplicateParentId ?? null)}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
) : null}
{ticket.parent_ticket_id ? (
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketParent(ticket, null)}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketStatus(ticket, "in_progress")}
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
>
</Button>
<Button
size="sm"
variant="primary"
onClick={() => void updateTicketStatus(ticket, "resolved")}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
</div>
</article>
);
})}
</div>
) : null}
</section>