운영 화면과 회기 리뷰 UI 갱신
This commit is contained in:
parent
e7ebb38177
commit
3a9f70a97b
18 changed files with 2210 additions and 137 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue