import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Activity, AlertTriangle, BarChart3, CheckCircle2, Layers3, ShieldCheck, } from "lucide-react"; import { useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate } from "react-router-dom"; import { type ClientSummary, type RPUsageDailyMetric, type RPUsagePeriod, fetchClients, fetchDevRPUsageDaily, fetchDevStats, fetchDeveloperRequestStatus, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { OverviewAxisNotes, OverviewMetric, OverviewSelectionChips, } from "../../../../common/core/components/overview"; type ClientDistribution = { activeClients: number; headlessClients: number; pkceClients: number; privateClients: number; }; type DailyPoint = { date: string; loginRequests: number; otherRequests: number; }; type SeriesSummary = { key: string; clientLabel: string; loginRequests: number; otherRequests: number; uniqueSubjects: number; }; type MultiLineSeries = { key: string; clientLabel: string; color: UsageChartPalette; points: DailyPoint[]; }; type ClientFilterOption = { id: string; label: string; }; type UsageChartPalette = { bar: string; line: string; point: string; }; const usageChartPalettes: UsageChartPalette[] = [ { bar: "#7dd3fc", line: "#10b981", point: "#059669" }, { bar: "#f9a8d4", line: "#f97316", point: "#ea580c" }, { bar: "#c4b5fd", line: "#6366f1", point: "#4f46e5" }, { bar: "#86efac", line: "#14b8a6", point: "#0f766e" }, { bar: "#fdba74", line: "#ef4444", point: "#dc2626" }, { bar: "#93c5fd", line: "#8b5cf6", point: "#7c3aed" }, ]; function buildClientDistribution(clients: ClientSummary[]): ClientDistribution { return clients.reduce( (summary, client) => { if (client.status === "active") { summary.activeClients += 1; } if (client.metadata?.headless_login_enabled === true) { summary.headlessClients += 1; } if (client.type === "pkce") { summary.pkceClients += 1; } else { summary.privateClients += 1; } return summary; }, { activeClients: 0, headlessClients: 0, pkceClients: 0, privateClients: 0, }, ); } function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] { const byDate = new Map(); for (const row of rows) { const current = byDate.get(row.date) ?? ({ date: row.date, loginRequests: 0, otherRequests: 0, } satisfies DailyPoint); current.loginRequests += row.loginRequests; current.otherRequests += row.otherRequests; byDate.set(row.date, current); } return Array.from(byDate.values()).sort((left, right) => left.date.localeCompare(right.date), ); } function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { const bySeries = new Map(); for (const row of rows) { const key = row.clientId; const current = bySeries.get(key) ?? ({ key, clientLabel: row.clientName || row.clientId, loginRequests: 0, otherRequests: 0, uniqueSubjects: 0, } satisfies SeriesSummary); current.loginRequests += row.loginRequests; current.otherRequests += row.otherRequests; current.uniqueSubjects = Math.max( current.uniqueSubjects, row.uniqueSubjects, ); bySeries.set(key, current); } return Array.from(bySeries.values()).sort( (left, right) => right.loginRequests + right.otherRequests - (left.loginRequests + left.otherRequests), ); } function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] { const dates = summarizeDaily(rows).map((point) => point.date); const byClient = new Map< string, { clientLabel: string; byDate: Map; } >(); for (const row of rows) { const current = byClient.get(row.clientId) ?? { clientLabel: row.clientName || row.clientId, byDate: new Map(), }; const point = current.byDate.get(row.date) ?? ({ date: row.date, loginRequests: 0, otherRequests: 0, } satisfies DailyPoint); point.loginRequests += row.loginRequests; point.otherRequests += row.otherRequests; current.byDate.set(row.date, point); byClient.set(row.clientId, current); } return Array.from(byClient.entries()) .sort((left, right) => left[1].clientLabel.localeCompare(right[1].clientLabel), ) .map(([clientId, entry], index) => ({ key: clientId, clientLabel: entry.clientLabel, color: usageChartPalettes[index % usageChartPalettes.length], points: dates.map( (date) => entry.byDate.get(date) ?? ({ date, loginRequests: 0, otherRequests: 0, } satisfies DailyPoint), ), })); } function parseDateParts(date: string) { const parts = date.split("-"); if (parts.length === 3) { return { year: Number(parts[0]), month: Number(parts[1]), day: Number(parts[2]), monthText: parts[1], dayText: parts[2], }; } return null; } function getISOWeekNumber(year: number, month: number, day: number) { const date = new Date(Date.UTC(year, month - 1, day)); const dayOfWeek = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); } function getISOWeekThursday(year: number, month: number, day: number) { const date = new Date(Date.UTC(year, month - 1, day)); const dayOfWeek = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek); return date; } function formatDate(value?: string) { if (!value) { return "-"; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return value; } return parsed.toLocaleDateString(); } function formatPeriodLabel(date: string, period: RPUsagePeriod) { const parts = parseDateParts(date); if (!parts) { return date; } if (period === "month") { return `${parts.monthText}월`; } if (period === "week") { const weekNumber = String( getISOWeekNumber(parts.year, parts.month, parts.day), ).padStart(2, "0"); const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day); const weekMonth = weekThursday.getUTCMonth() + 1; const weekDay = weekThursday.getUTCDate(); const weekMonthText = String(weekMonth).padStart(2, "0"); const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7))); return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`; } return `${parts.monthText}.${parts.dayText}`; } function formatMetric(value: number | undefined) { return value === undefined ? "-" : value.toLocaleString(); } function RPUsageMixedChart({ period, rows, palette, multiLineSeries, }: { period: RPUsagePeriod; rows: RPUsageDailyMetric[]; palette?: UsageChartPalette; multiLineSeries?: MultiLineSeries[]; }) { const colors = palette ?? usageChartPalettes[0]; const daily = summarizeDaily(rows); const series = summarizeSeries(rows); const topSeries = series.slice(0, 5); const seriesByKey = new Map(series.map((item) => [item.key, item])); const chartWidth = 720; const chartHeight = 230; const padX = 48; const padTop = 40; const padBottom = 34; const innerWidth = chartWidth - padX * 2; const innerHeight = chartHeight - padTop - padBottom; const maxValue = Math.max(1, ...daily.map((point) => point.loginRequests)); const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth; const y = (value: number) => padTop + innerHeight - (value / maxValue) * innerHeight; const x = (index: number) => padX + slot * index + slot / 2; const linePoints = daily .map((point, index) => `${x(index)},${y(point.loginRequests)}`) .join(" "); const multiLinePoints = multiLineSeries?.map((seriesItem) => ({ ...seriesItem, pointsAttr: seriesItem.points .map((point, index) => `${x(index)},${y(point.loginRequests)}`) .join(" "), })); if (daily.length === 0) { return (
{t("msg.dev.dashboard.chart.empty", "표시할 RP 이용 집계가 없습니다.")}
); } return (
{t("ui.dev.dashboard.chart.aria", "RP 요청 현황")} {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { const gridY = padTop + innerHeight * ratio; const label = Math.round(maxValue * (1 - ratio)); return ( {label} ); })} {daily.map((point, index) => { const center = x(index); return ( {formatPeriodLabel(point.date, period)} ); })} {!multiLinePoints || multiLinePoints.length === 0 ? ( <> {daily.map((point, index) => ( ))} ) : ( multiLinePoints.map((seriesItem) => ( {seriesItem.points.map((point, index) => ( ))} )) )}
{multiLinePoints && multiLinePoints.length > 0 ? (
{multiLinePoints.map((item) => { const seriesItem = seriesByKey.get(item.key); return (
{item.clientLabel} {seriesItem ? ( {t( "ui.dev.dashboard.chart.series", "로그인 {{login}} / 사용자 {{subjects}}", { login: seriesItem.loginRequests.toLocaleString(), subjects: seriesItem.uniqueSubjects.toLocaleString(), }, )} ) : null}
); })}
) : topSeries.length > 0 ? (
{topSeries.map((item) => (
{item.clientLabel} {t( "ui.dev.dashboard.chart.series", "로그인 {{login}} / 사용자 {{subjects}}", { login: item.loginRequests.toLocaleString(), subjects: item.uniqueSubjects.toLocaleString(), }, )}
))}
) : null}
); } function GlobalOverviewPage() { const navigate = useNavigate(); const auth = useAuth(); const hasAccessToken = Boolean(auth.user?.access_token); const userProfile = auth.user?.profile as Record | undefined; const role = resolveProfileRole(userProfile); const tenantId = userProfile?.tenant_id as string | undefined; const [period, setPeriod] = useState("day"); const [selectedClientIds, setSelectedClientIds] = useState([]); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({ queryKey: ["developer-request", tenantId], queryFn: () => fetchDeveloperRequestStatus(tenantId), enabled: hasAccessToken && role === "user", }); const statsQuery = useQuery({ queryKey: ["dev-dashboard-stats"], queryFn: fetchDevStats, retry: false, }); const clientsQuery = useQuery({ queryKey: ["dev-dashboard-clients"], queryFn: fetchClients, retry: false, }); const usageQuery = useQuery({ queryKey: ["dev-dashboard-rp-usage", usageDays, period], queryFn: () => fetchDevRPUsageDaily({ days: usageDays, period, }), retry: false, }); const clients = clientsQuery.data?.items ?? []; const hasDeveloperAccess = role === "super_admin" || role === "tenant_admin" || role === "rp_admin" || requestStatus?.status === "approved"; const isDeveloperRequestPending = requestStatus?.status === "pending"; const canRequestDeveloperAccess = (role === "user" || role === "tenant_member") && !isLoadingRequestStatus && !hasDeveloperAccess && !isDeveloperRequestPending; const distribution = useMemo( () => buildClientDistribution(clients), [clients], ); const visibleClients = useMemo( () => [...clients] .sort((left, right) => { const statusCompare = (left.status || "").localeCompare( right.status || "", ); if (statusCompare !== 0) { return statusCompare; } return (left.name || left.id).localeCompare(right.name || right.id); }) .slice(0, 6), [clients], ); const clientFilterOptions = useMemo( () => [...clients] .map((client) => ({ id: client.id, label: client.name || client.id, })) .sort((left, right) => left.label.localeCompare(right.label)), [clients], ); const stats = statsQuery.data; const usageRows = usageQuery.data?.items ?? []; const filteredUsageRows = useMemo(() => { if (selectedClientIds.length === 0) { return usageRows; } const selectedSet = new Set(selectedClientIds); return usageRows.filter((row) => selectedSet.has(row.clientId)); }, [selectedClientIds, usageRows]); const selectedMultiLineSeries = useMemo( () => buildMultiLineSeries(filteredUsageRows), [filteredUsageRows], ); const usageError = usageQuery.error as AxiosError<{ error?: string }> | null; const usageStatus = usageError?.response?.status; const usageErrorMessage = usageError?.response?.data?.error ?? usageError?.message ?? ""; const usageErrorText = usageStatus === 403 ? t( "msg.dev.dashboard.chart.forbidden", "현재 계정에는 RP 이용 통계를 볼 권한이 없습니다.", ) : usageStatus === 503 ? t( "msg.dev.dashboard.chart.service_unavailable", "RP 이용 통계 집계 서비스가 아직 준비되지 않았습니다.", ) : usageStatus === 500 ? t( "msg.dev.dashboard.chart.server_error", "RP 이용 통계 조회 중 서버 오류가 발생했습니다.", ) : t( "msg.dev.dashboard.chart.unavailable_with_reason", "RP 이용 통계 API 응답을 확인할 수 없습니다. {{reason}}", { reason: usageErrorMessage || t("err.common.unknown", "알 수 없는 오류"), }, ); const isAllClientsSelected = selectedClientIds.length === 0; const toggleClientSelection = (clientId: string) => { setSelectedClientIds((current) => { if (current.includes(clientId)) { const next = current.filter((item) => item !== clientId); return next; } return [...current, clientId]; }); }; const selectAllClients = () => { setSelectedClientIds([]); }; if ((role === "user" || role === "tenant_member") && isLoadingRequestStatus) { return (
{t("ui.common.loading", "Loading...")}
); } if (!hasDeveloperAccess) { return (

{t("ui.common.overview.title", "운영 현황")}

{isDeveloperRequestPending ? t( "msg.dev.dashboard.access_pending", "개발자 권한 신청을 검토 중입니다.", ) : t( "msg.dev.dashboard.access_denied", "대시보드는 개발자 권한이 있어야 볼 수 있습니다.", )}

{isDeveloperRequestPending ? t( "msg.dev.dashboard.access_pending_detail", "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.", ) : t( "msg.dev.dashboard.access_denied_detail", "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.", )}

{(isDeveloperRequestPending || canRequestDeveloperAccess) && ( )}
); } return (

{t("ui.common.overview.title", "운영 현황")}

{t( "msg.dev.dashboard.description", "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.", )}

} label={t("ui.dev.dashboard.summary.total_clients", "총 RP 수")} value={formatMetric(stats?.total_clients ?? clients.length)} /> } label={t("ui.dev.dashboard.summary.active_clients", "활성 RP 수")} value={formatMetric(distribution.activeClients)} /> } label={t("ui.dev.dashboard.summary.active_sessions", "활성 세션 수")} value={formatMetric(stats?.active_sessions)} /> } label={t( "ui.dev.dashboard.summary.auth_failures_24h", "24시간 인증 실패 수", )} value={formatMetric(stats?.auth_failures_24h)} />

{t( "ui.dev.dashboard.chart.title", "애플리케이션별 로그인요청/기타 요청 현황", )}

{t( "msg.dev.dashboard.chart.filter_description", "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.", )}

{[ ["day", t("ui.common.chart.period.day", "일")], ["week", t("ui.common.chart.period.week", "주")], ["month", t("ui.common.chart.period.month", "월")], ].map(([value, label]) => ( ))}
{usageQuery.isError ? (
{usageErrorText}
) : isAllClientsSelected ? ( ) : ( )}

{t( "ui.dev.dashboard.distribution.title", "애플리케이션 구성 요약", )}

{t( "msg.dev.dashboard.distribution.description", "애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.", )}

{t("ui.dev.dashboard.distribution.private", "Server side App")}

{distribution.privateClients.toLocaleString()}

{t( "ui.dev.dashboard.distribution.headless_hint", "이 중 Headless Login 사용 {{count}}", { count: distribution.headlessClients.toLocaleString(), }, )}

{t("ui.dev.dashboard.distribution.pkce", "PKCE")}

{distribution.pkceClients.toLocaleString()}

{t("ui.dev.dashboard.recent.title", "내 애플리케이션")}

{t( "msg.dev.dashboard.recent.empty", "현재 계정이 접근할 수 있는 RP를 확인합니다.", )}

{visibleClients.length === 0 ? (

{t( "msg.dev.dashboard.recent.none", "표시할 연동 앱이 없습니다.", )}

) : ( visibleClients.map((client) => (

{client.name || t("ui.dev.clients.untitled", "Untitled")}

{client.id}

{client.metadata?.headless_login_enabled === true ? t( "ui.dev.clients.type.private_headless", "Server side App (Headless Login)", ) : client.type === "private" ? t("ui.dev.clients.type.private", "Server side App") : t("ui.dev.clients.type.pkce", "PKCE")}

{client.status === "active" ? t("ui.dev.clients.status.active", "활성") : client.status === "inactive" ? t("ui.dev.clients.status.inactive", "비활성") : client.status || "-"}

{t("ui.dev.clients.table.created_at", "생성일")}{" "} {formatDate(client.createdAt)}

)) )}
); } export default GlobalOverviewPage;