import { Component, useCallback, useEffect, useMemo, useRef, useState, type ErrorInfo, type ReactNode, } from "react"; import { AppShell } from "../components/shell/AppShell"; import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui"; import { adminApi, adminUsersApi, type AdminHealthResponse, type AdminHealthStatus, type AdminManagedUser, type AdminTicketFilters, type AdminSupportTicket, type AdminTicketsResponse, type AdminUsageResponse, type AdminUptimeResponse, type AdminUserCreateRequest, type AdminUsersResponse, } from "../lib/api"; import { useAuth } from "../lib/auth"; import "./admin/admin-console.css"; export type AdminSection = "overview" | "users" | "access" | "tickets"; type UserDraft = Pick< AdminManagedUser, "display_name" | "role" | "admin_access" | "account_status" | "affiliation" | "cohort_ids" >; type NewUserDraft = Required> & Pick; type UserTab = "approval" | "manage" | "register" | "activity"; type AccessTab = "roles" | "groups" | "matrix"; type TicketStatusFilter = AdminSupportTicket["status"] | "all"; type TicketCategoryFilter = AdminSupportTicket["category"] | "all"; type TicketPriorityFilter = AdminSupportTicket["priority"] | "all"; type AdminDataWarning = { key: string; message: string; detail: string; }; const MAX_RENDERED_USERS = 40; const MAX_RESOLVED_TICKET_HISTORY = 6; const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }> = [ { value: "all", label: "전체 상태" }, { value: "open", label: "미해결" }, { value: "triaged", label: "분류됨" }, { value: "in_progress", label: "처리 중" }, { value: "resolved", label: "해결" }, { value: "closed", label: "종결" }, ]; const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: string }> = [ { value: "all", label: "전체 카테고리" }, { value: "account_access", label: "계정/권한" }, { value: "session_review", label: "세션/리뷰" }, { value: "voice_browser", label: "음성/브라우저" }, { value: "content_scenario", label: "콘텐츠/시나리오" }, { value: "safety", label: "안전" }, { value: "other", label: "기타" }, ]; const TICKET_PRIORITY_OPTIONS: Array<{ value: TicketPriorityFilter; label: string }> = [ { value: "all", label: "전체 우선순위" }, { value: "urgent", label: "긴급" }, { value: "high", label: "높음" }, { value: "normal", label: "보통" }, { value: "low", label: "낮음" }, ]; const EMPTY_NEW_USER: NewUserDraft = { email: "", display_name: "", role: "learner", admin_access: false, account_status: "approved", affiliation: "", cohort_ids: [], }; const ROLE_POLICIES = [ { role: "관리자", scope: "운영 상태, 사용자, 권한 정책, 티켓 큐", permissions: ["서비스 헬스", "사용자 등록", "권한 정책", "운영 티켓"], risk: "학습자 상담 내용은 기본 운영 화면에서 제외", }, { role: "교수자", scope: "담당 코호트 수업 운영과 회기 리뷰", permissions: ["담당 학습자 현황", "회기 리뷰", "안전 알림"], risk: "담당 범위 밖 사용자/시스템 설정 접근 불가", }, { role: "학습자", scope: "본인 회기, 리뷰, 계정 설정", permissions: ["상담 세션", "본인 리뷰", "개인 설정"], risk: "타 사용자 데이터와 운영 리소스 접근 불가", }, ]; const GROUP_POLICIES = [ { name: "서비스 운영자", scope: "서비스 상태와 장애 티켓 1차 대응", access: ["운영 콘솔", "티켓 큐", "사용자 읽기"], }, { name: "학과 관리자", scope: "사용자 등록과 코호트 배정", access: ["사용자 관리", "그룹 배정", "코호트 관리"], }, { name: "슈퍼바이저", scope: "교수자 회기 리뷰 품질 점검", access: ["교수 콘솔", "안전 알림", "리뷰 읽기"], }, ]; const PERMISSION_MATRIX = [ { resource: "운영 콘솔", admin: "전체", teacher: "없음", learner: "없음" }, { resource: "사용자 관리", admin: "등록/수정", teacher: "담당자 읽기", learner: "본인만" }, { resource: "회기 리뷰", admin: "감사 로그", teacher: "담당 코호트", learner: "본인 회기" }, { resource: "AI 운영 설정", admin: "수정", teacher: "없음", learner: "없음" }, { resource: "안전 알림", admin: "전체", teacher: "담당 코호트", learner: "없음" }, ]; function toneOf(status: AdminHealthStatus): "pos" | "warn" | "crit" { if (status === "ok") return "pos"; if (status === "degraded") return "warn"; return "crit"; } function statusLabel(status: AdminHealthStatus): string { if (status === "ok") return "정상"; if (status === "degraded") return "제한 운영"; return "중단"; } function statusMessage(data: AdminHealthResponse | null): string { if (!data) return "운영 상태를 확인하는 중입니다."; if (data.status === "ok") return "핵심 서비스가 정상 응답 중입니다."; if (data.status === "degraded") return "일부 서비스가 제한된 상태입니다."; return "운영 개입이 필요한 서비스가 있습니다."; } function environmentLabel(value: string): string { if (value === "prod") return "운영"; if (value === "staging") return "스테이징"; if (value === "dev") return "개발"; return value; } function engineModeLabel(value: string): string { if (value === "claude_cli") return "Claude CLI 게이트웨이"; if (value === "claude_api" || value === "messages_api") return "Anthropic API"; if (value === "openai") return "OpenAI 호환"; if (value === "solar") return "Solar"; return value; } function roleText(role: AdminManagedUser["role"]): string { if (role === "admin") return "관리자"; if (role === "teacher") return "교수자"; return "학습자"; } function roleBadgeTone(role: AdminManagedUser["role"]): "accent" | "neutral" | "warn" { if (role === "admin") return "warn"; if (role === "teacher") return "accent"; return "neutral"; } function accountStatusText(status: AdminManagedUser["account_status"]): string { if (status === "approved") return "승인됨"; if (status === "suspended") return "보류"; return "승인 대기"; } function accountStatusTone(status: AdminManagedUser["account_status"]): "accent" | "neutral" | "warn" | "crit" { if (status === "approved") return "accent"; if (status === "suspended") return "crit"; return "warn"; } function storeLabel(data: AdminUsersResponse | null): string { if (!data) return "대기 중"; return data.durable ? "DB 사용자" : "비영구 런타임 저장소"; } function dateTimeLabel(seconds?: number | null): string { if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return "-"; return new Date(seconds * 1000).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } 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 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 { 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 "샘플 없음"; return `${Math.round(data.ok_ratio * 1000) / 10}%`; } function lastDownLabel(data: AdminUptimeResponse | null): string { if (!data) return "-"; if (!data.durable) return "저장소 미연결"; return data.last_down_at ? dateTimeLabel(data.last_down_at) : "기록 없음"; } function ticketStatusLabel(status: AdminSupportTicket["status"]): string { if (status === "open") return "미해결"; if (status === "triaged") return "분류됨"; if (status === "in_progress") return "처리 중"; if (status === "resolved") return "해결"; return "종결"; } function ticketIsActive(ticket: AdminSupportTicket): boolean { return ticket.status !== "resolved" && ticket.status !== "closed"; } function ticketPriorityLabel(priority: AdminSupportTicket["priority"]): string { if (priority === "urgent") return "긴급"; if (priority === "high") return "높음"; if (priority === "normal") return "보통"; return "낮음"; } function ticketCategoryLabel(category: AdminSupportTicket["category"]): string { if (category === "account_access") return "계정/권한"; if (category === "session_review") return "세션/리뷰"; if (category === "voice_browser") return "음성/브라우저"; if (category === "content_scenario") return "콘텐츠/시나리오"; if (category === "safety") return "안전"; return "기타"; } function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" | "crit" { if (!ticketIsActive(ticket)) return "neutral"; if (ticket.priority === "urgent") return "crit"; if (ticket.priority === "high") return "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() ?? "?"; } function cohortInputValue(cohortIds: string[]): string { return cohortIds.join(", "); } function parseCohorts(value: string): string[] { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } function asRecord(value: unknown): Record { return value !== null && typeof value === "object" ? (value as Record) : {}; } function stringField( source: Record, field: string, fallback: string, warnings: AdminDataWarning[], label: string, ): string { const value = source[field]; if (typeof value === "string") return value; if (value == null) { warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`, detail: `${field}=null`, }); return fallback; } warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`, detail: `${field}=${typeof value}`, }); return String(value); } function booleanField( source: Record, field: string, fallback: boolean, warnings: AdminDataWarning[], label: string, ): boolean { const value = source[field]; if (typeof value === "boolean") return value; if (value == null) { warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`, detail: `${field}=null`, }); return fallback; } warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`, detail: `${field}=${typeof value}`, }); return fallback; } function numberField( source: Record, field: string, fallback: number, warnings: AdminDataWarning[], label: string, ): number { const value = source[field]; if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() !== "") { const parsed = Number(value); if (Number.isFinite(parsed)) { warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`, detail: `${field}=string`, }); return parsed; } } warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`, detail: `${field}=${value == null ? "null" : typeof value}`, }); return fallback; } function cohortIdsField( source: Record, warnings: AdminDataWarning[], label: string, ): string[] { const value = source.cohort_ids; if (!Array.isArray(value)) { warnings.push({ key: `${label}.cohort_ids`, message: `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`, detail: `cohort_ids=${value == null ? "null" : typeof value}`, }); return []; } const normalized = value.filter((item): item is string => typeof item === "string"); if (normalized.length !== value.length) { warnings.push({ key: `${label}.cohort_ids.items`, message: `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`, detail: `kept=${normalized.length}, raw=${value.length}`, }); } return normalized; } function numberRecordField( source: Record, field: string, warnings: AdminDataWarning[], label: string, ): Record { const value = source[field]; if (value == null) { warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`, detail: `${field}=null`, }); return {}; } if (typeof value !== "object" || Array.isArray(value)) { warnings.push({ key: `${label}.${field}`, message: `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`, detail: `${field}=${typeof value}`, }); return {}; } const entries = Object.entries(value as Record); const normalized: Record = {}; for (const [key, raw] of entries) { if (typeof raw === "number" && Number.isFinite(raw)) { normalized[key] = raw; continue; } const parsed = typeof raw === "string" ? Number(raw) : Number.NaN; if (Number.isFinite(parsed)) { normalized[key] = parsed; warnings.push({ key: `${label}.${field}.${key}`, message: `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`, detail: `${field}.${key}=string`, }); continue; } warnings.push({ key: `${label}.${field}.${key}`, message: `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`, detail: `${field}.${key}=${raw == null ? "null" : typeof raw}`, }); } return normalized; } function roleField( source: Record, warnings: AdminDataWarning[], label: string, ): AdminManagedUser["role"] { const value = source.role; if (value === "learner" || value === "teacher" || value === "admin") return value; warnings.push({ key: `${label}.role`, message: `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`, detail: `role=${String(value)}`, }); return "learner"; } function accountStatusField( source: Record, warnings: AdminDataWarning[], label: string, ): AdminManagedUser["account_status"] { const value = source.account_status; if (value === "pending" || value === "approved" || value === "suspended") return value; warnings.push({ key: `${label}.account_status`, message: `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`, detail: `account_status=${String(value)}`, }); return "pending"; } function userSourceField( source: Record, parentSource: AdminUsersResponse["source"], ): AdminManagedUser["source"] { const value = source.source; return value === "server_session_registry" || value === "database" ? value : parentSource; } function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): { response: AdminUsersResponse; warnings: AdminDataWarning[]; } { const warnings: AdminDataWarning[] = []; const root = asRecord(rawResponse); const source: AdminUsersResponse["source"] = root.source === "server_session_registry" ? "server_session_registry" : "database"; const durable = typeof root.durable === "boolean" ? root.durable : source === "database"; const rawUsers = Array.isArray(root.users) ? root.users : []; if (!Array.isArray(root.users)) { warnings.push({ key: "admin.users", message: "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.", detail: `users=${root.users == null ? "null" : typeof root.users}`, }); } const users = rawUsers.map((item, index): AdminManagedUser => { const sourceRecord = asRecord(item); const label = `users[${index}]`; const userId = stringField(sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label); return { user_id: userId, email: stringField(sourceRecord, "email", userId, warnings, label), display_name: stringField(sourceRecord, "display_name", userId, warnings, label), role: roleField(sourceRecord, warnings, label), admin_access: booleanField(sourceRecord, "admin_access", false, warnings, label), super_admin: booleanField(sourceRecord, "super_admin", false, warnings, label), account_status: accountStatusField(sourceRecord, warnings, label), cohort_ids: cohortIdsField(sourceRecord, warnings, label), affiliation: stringField(sourceRecord, "affiliation", "", warnings, label), active_sessions: numberField(sourceRecord, "active_sessions", 0, warnings, label), created_at: numberField(sourceRecord, "created_at", 0, warnings, label), last_seen_at: numberField(sourceRecord, "last_seen_at", 0, warnings, label), source: userSourceField(sourceRecord, source), }; }); return { response: { source, durable, users, }, warnings, }; } function normalizeAdminTicketsResponse(rawResponse: AdminTicketsResponse): { response: AdminTicketsResponse; warnings: AdminDataWarning[]; } { const warnings: AdminDataWarning[] = []; const root = asRecord(rawResponse); const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable"; const durable = typeof root.durable === "boolean" ? root.durable : source === "database"; const rawTickets = Array.isArray(root.tickets) ? root.tickets : []; if (!Array.isArray(root.tickets)) { warnings.push({ key: "admin.tickets", message: "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.", detail: `tickets=${root.tickets == null ? "null" : typeof root.tickets}`, }); } const summaryRoot = asRecord(root.summary); if (!root.summary || typeof root.summary !== "object" || Array.isArray(root.summary)) { warnings.push({ key: "admin.tickets.summary", message: "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.", detail: `summary=${root.summary == null ? "null" : typeof root.summary}`, }); } return { response: { source, durable, generated_at: numberField(root, "generated_at", 0, warnings, "admin.tickets"), tickets: rawTickets as AdminSupportTicket[], summary: { total: numberField(summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary"), open_count: numberField(summaryRoot, "open_count", 0, warnings, "admin.tickets.summary"), high_priority_count: numberField(summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary"), stale_count: numberField(summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary"), by_category: numberRecordField(summaryRoot, "by_category", warnings, "admin.tickets.summary"), by_priority: numberRecordField(summaryRoot, "by_priority", warnings, "admin.tickets.summary"), by_status: numberRecordField(summaryRoot, "by_status", warnings, "admin.tickets.summary"), }, }, warnings, }; } function isOnline(seconds?: number | null): boolean { if (!seconds || seconds <= 0) return false; return Date.now() / 1000 - seconds < 15 * 60; } interface PageHeaderProps { kicker: string; title: string; description: string; children?: ReactNode; } function PageHeader({ kicker, title, description, children }: PageHeaderProps) { return (
{kicker}

{title}

{description}

{children ?
{children}
: null}
); } function runtimeAssetLabel(): string { if (typeof document === "undefined") return "unknown"; const script = Array.from(document.scripts) .map((item) => item.getAttribute("src") ?? "") .find((src) => src.includes("/assets/index-") && src.endsWith(".js")); if (!script) return "unknown"; return script.split("/").pop() ?? script; } function AdminDiagnosticPanel({ title, body, details, }: { title: string; body: string; details: Array<[string, string]>; }) { return (
화면 진단

{title}

{body}

{details.map(([key, value]) => (
{key}
{value}
))}
); } interface AdminErrorBoundaryProps { section: AdminSection; children: ReactNode; } interface AdminErrorBoundaryState { error: Error | null; errorInfo: ErrorInfo | null; } class AdminErrorBoundary extends Component { state: AdminErrorBoundaryState = { error: null, errorInfo: null }; static getDerivedStateFromError(error: Error): AdminErrorBoundaryState { return { error, errorInfo: null }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { this.setState({ errorInfo }); console.error("[admin-render-error]", error, errorInfo.componentStack); } componentDidUpdate(prevProps: AdminErrorBoundaryProps) { if (prevProps.section !== this.props.section && this.state.error) { this.setState({ error: null, errorInfo: null }); } } render() { if (this.state.error) { return ( ); } return this.props.children; } } function AdminBlankContentProbe({ section }: { section: AdminSection }) { const markerRef = useRef(null); const [diagnostic, setDiagnostic] = useState | null>(null); useEffect(() => { setDiagnostic(null); const timer = window.setTimeout(() => { const root = markerRef.current?.closest(".ad-root") as HTMLElement | null; if (!root) return; const text = root.innerText.replace(/\s+/g, " ").trim(); const rootRect = root.getBoundingClientRect(); const visibleNodes = Array.from( root.querySelectorAll( "h1,h2,p,button,input,select,article,section,.ad-head,.ad-panel,.ad-section,.ad-users-note,.ad-status", ), ).filter((element) => { if (element.classList.contains("ad-diagnostic")) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return ( rect.width > 1 && rect.height > 1 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" ); }); if (text.length === 0 || rootRect.height < 12 || visibleNodes.length === 0) { setDiagnostic([ ["section", section], ["path", window.location.pathname], ["rootHeight", `${Math.round(rootRect.height)}px`], ["visibleNodes", String(visibleNodes.length)], ["scrollY", String(Math.round(window.scrollY))], ["asset", runtimeAssetLabel()], ]); } }, 800); return () => window.clearTimeout(timer); }, [section]); return ( <>