import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Activity, AlertTriangle, CheckCircle2, ChevronDown, Clock3, Layers3, LayoutDashboard, ShieldCheck, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; import { OverviewAxisNotes, OverviewMetric, OverviewSelectionChips, } from "../../../../common/core/components/overview"; import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { type ClientSummary, fetchClients, fetchDevAuditLogs, fetchDevRPUsageDaily, fetchDevStats, fetchDevUser, type RPUsageDailyMetric, type RPUsagePeriod, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { buildRecentClientChanges, type RecentClientChange, } from "./recentClientChanges"; 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 deletedRecentChangeFilterId = "__deleted_recent_clients__"; const localeStorageKey = "locale"; type RecentChangePoint = { date: string; changeCount: number; uniqueActors: number; }; type RecentChangeSeriesSummary = { key: string; clientLabel: string; changeCount: number; uniqueActors: number; }; type RecentChangeSeries = { key: string; clientLabel: string; color: UsageChartPalette; points: RecentChangePoint[]; }; type AppLocale = "ko" | "en"; function resolveAppLocale(): AppLocale { if (typeof window === "undefined") { return "ko"; } const stored = window.localStorage.getItem(localeStorageKey); if (stored === "ko" || stored === "en") { return stored; } const pathLocale = window.location.pathname.split("/")[1]; if (pathLocale === "ko" || pathLocale === "en") { return pathLocale; } return window.navigator.language.toLowerCase().startsWith("ko") ? "ko" : "en"; } function formatRecentChangeTimestamp(value: string) { if (!value) { return { date: "-", time: "-" }; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return { date: value, time: "-" }; } const locale = resolveAppLocale(); if (locale === "ko") { const date = parsed.toISOString().slice(0, 10); const timeParts = new Intl.DateTimeFormat("ko-KR", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", }).formatToParts(parsed); const hour = timeParts.find((part) => part.type === "hour")?.value ?? "00"; const minute = timeParts.find((part) => part.type === "minute")?.value ?? "00"; const second = timeParts.find((part) => part.type === "second")?.value ?? "00"; return { date, time: `${hour}시 ${minute}분 ${second}초`, }; } return { date: new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric", }).format(parsed), time: new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit", }).format(parsed), }; } 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 toPeriodBucket(date: string, period: RPUsagePeriod) { const parts = parseDateParts(date); if (!parts) { return date; } if (period === "month") { return `${parts.year}-${parts.monthText}-01`; } if (period === "week") { const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day); const weekYear = weekThursday.getUTCFullYear(); const weekMonth = String(weekThursday.getUTCMonth() + 1).padStart(2, "0"); const weekDay = String(weekThursday.getUTCDate()).padStart(2, "0"); return `${weekYear}-${weekMonth}-${weekDay}`; } return date; } 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 summarizeRecentChanges( items: RecentClientChange[], period: RPUsagePeriod, ): RecentChangePoint[] { const byDate = new Map< string, { changeCount: number; actors: Set } >(); for (const item of items) { const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); const current = byDate.get(bucket) ?? { changeCount: 0, actors: new Set(), }; current.changeCount += 1; if (item.actorId && item.actorId !== "-") { current.actors.add(item.actorId); } byDate.set(bucket, current); } return Array.from(byDate.entries()) .map(([date, current]) => ({ date, changeCount: current.changeCount, uniqueActors: current.actors.size, })) .sort((left, right) => left.date.localeCompare(right.date)); } function summarizeRecentChangeSeries( items: RecentClientChange[], ): RecentChangeSeriesSummary[] { const bySeries = new Map< string, { clientLabel: string; changeCount: number; actors: Set } >(); for (const item of items) { const current = bySeries.get(item.clientId) ?? { clientLabel: item.clientName, changeCount: 0, actors: new Set(), }; current.changeCount += 1; if (item.actorId && item.actorId !== "-") { current.actors.add(item.actorId); } bySeries.set(item.clientId, current); } return Array.from(bySeries.entries()) .map(([key, current]) => ({ key, clientLabel: current.clientLabel, changeCount: current.changeCount, uniqueActors: current.actors.size, })) .sort((left, right) => right.changeCount - left.changeCount); } function buildRecentChangeSeries( items: RecentClientChange[], period: RPUsagePeriod, ): RecentChangeSeries[] { const dates = summarizeRecentChanges(items, period).map( (point) => point.date, ); const byClient = new Map< string, { clientLabel: string; byDate: Map; actorIdsByDate: Map>; } >(); for (const item of items) { const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); const current = byClient.get(item.clientId) ?? { clientLabel: item.clientName, byDate: new Map(), actorIdsByDate: new Map>(), }; const point = current.byDate.get(bucket) ?? { date: bucket, changeCount: 0, uniqueActors: 0, }; point.changeCount += 1; const actorIds = current.actorIdsByDate.get(bucket) ?? new Set(); if (item.actorId && item.actorId !== "-") { actorIds.add(item.actorId); } point.uniqueActors = actorIds.size; current.byDate.set(bucket, point); current.actorIdsByDate.set(bucket, actorIds); byClient.set(item.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, changeCount: 0, uniqueActors: 0, }, ), })); } 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 RecentClientChangesChart({ items, period, multiLineSeries, }: { items: RecentClientChange[]; period: RPUsagePeriod; multiLineSeries?: RecentChangeSeries[]; }) { const daily = summarizeRecentChanges(items, period); const series = summarizeRecentChangeSeries(items); 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.changeCount)); 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.changeCount)}`) .join(" "); const multiLinePoints = multiLineSeries?.map((seriesItem) => ({ ...seriesItem, pointsAttr: seriesItem.points .map((point, index) => `${x(index)},${y(point.changeCount)}`) .join(" "), })); if (daily.length === 0) { return (
{t( "msg.dev.dashboard.recent_changes.empty", "최근 변경 로그가 아직 없습니다.", )}
); } return (
{t("ui.dev.dashboard.recent_changes.aria", "최근 변경된 앱 현황")} {[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) => ( {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.recent_changes.series", "변경 {{changes}} / 작업자 {{actors}}", { changes: seriesItem.changeCount.toLocaleString(), actors: seriesItem.uniqueActors.toLocaleString(), }, )} ) : null}
); })}
) : topSeries.length > 0 ? (
{topSeries.map((item) => (
{item.clientLabel} {t( "ui.dev.dashboard.recent_changes.series", "변경 {{changes}} / 작업자 {{actors}}", { changes: item.changeCount.toLocaleString(), actors: item.uniqueActors.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 { data: me, isLoading: isLoadingMe } = useQuery({ queryKey: ["userMe"], queryFn: fetchMe, enabled: hasAccessToken, }); const profileRole = me?.role?.trim() || role; const [period, setPeriod] = useState("day"); const [selectedClientIds, setSelectedClientIds] = useState([]); const [recentChangesPeriod, setRecentChangesPeriod] = useState("week"); const [selectedRecentChangeClientIds, setSelectedRecentChangeClientIds] = useState([]); const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = useState(6); const [isRecentChangesDetailOpen, setIsRecentChangesDetailOpen] = useState(true); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; 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, isDeveloperRequestPending, canRequestDeveloperAccess, isLoadingDeveloperAccessGate, } = useDeveloperAccessGate({ hasAccessToken, profileRole, tenantId, isLoadingIdentity: isLoadingMe, }); const distribution = useMemo( () => buildClientDistribution(clients), [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 visibleClientIds = useMemo( () => clients.map((client) => client.id).filter(Boolean), [clients], ); const currentClientIdSet = useMemo( () => new Set(visibleClientIds), [visibleClientIds], ); 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 { data: recentAuditData } = useQuery({ queryKey: [ "dev-dashboard-audit-logs", "clients-recent", visibleClientIds.join("|"), profileRole, ], queryFn: async () => { const globalLogs = await fetchDevAuditLogs(50); if (globalLogs.items.length > 0 || profileRole === "super_admin") { return globalLogs; } if (visibleClientIds.length === 0) { return globalLogs; } const perClientLogs = await Promise.all( visibleClientIds.slice(0, 20).map(async (clientId) => { try { const result = await fetchDevAuditLogs(5, undefined, { client_id: clientId, }); return result.items; } catch { return []; } }), ); const merged = perClientLogs .flat() .filter( (item, index, self) => self.findIndex( (candidate) => candidate.event_id === item.event_id, ) === index, ) .sort( (left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(), ) .slice(0, 50); return { items: merged, limit: 50, cursor: globalLogs.cursor, next_cursor: globalLogs.next_cursor, }; }, enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole), retry: false, }); const recentClientChanges = useMemo( () => buildRecentClientChanges(recentAuditData?.items ?? [], clients), [clients, recentAuditData?.items], ); const recentClientActorIds = useMemo( () => Array.from( new Set( recentClientChanges .map((item) => item.actorId.trim()) .filter((actorId) => actorId && actorId !== "-"), ), ), [recentClientChanges], ); const { data: recentClientActors } = useQuery({ queryKey: ["dev-dashboard-recent-client-actors", recentClientActorIds], queryFn: async () => { const entries = await Promise.all( recentClientActorIds.map(async (actorId) => { try { const user = await fetchDevUser(actorId); return [actorId, user.name || actorId] as const; } catch { return [actorId, actorId] as const; } }), ); return Object.fromEntries(entries); }, enabled: recentClientActorIds.length > 0, }); const recentClientChangesWithActors = useMemo( () => recentClientChanges.map((item) => ({ ...item, actorName: recentClientActors?.[item.actorId] || item.actorId, })), [recentClientActors, recentClientChanges], ); const deletedRecentChangeClientIds = useMemo( () => Array.from( new Set( recentClientChangesWithActors .map((item) => item.clientId) .filter((clientId) => !currentClientIdSet.has(clientId)), ), ), [currentClientIdSet, recentClientChangesWithActors], ); const recentChangeFilterOptions = useMemo(() => { const activeOptions = Array.from( new Map( recentClientChangesWithActors .filter((item) => currentClientIdSet.has(item.clientId)) .map((item) => [ item.clientId, { id: item.clientId, label: item.clientName }, ]), ).values(), ).sort((left, right) => left.label.localeCompare(right.label)); if (deletedRecentChangeClientIds.length === 0) { return activeOptions; } return [ ...activeOptions, { id: deletedRecentChangeFilterId, label: t("ui.dev.dashboard.recent_changes.deleted_group", "삭제된 앱"), }, ]; }, [ currentClientIdSet, deletedRecentChangeClientIds.length, recentClientChangesWithActors, ]); const filteredRecentClientChanges = useMemo(() => { if (selectedRecentChangeClientIds.length === 0) { return recentClientChangesWithActors; } const selectedSet = new Set(selectedRecentChangeClientIds); const includeDeletedGroup = selectedSet.has(deletedRecentChangeFilterId); return recentClientChangesWithActors.filter( (item) => selectedSet.has(item.clientId) || (includeDeletedGroup && deletedRecentChangeClientIds.includes(item.clientId)), ); }, [ deletedRecentChangeClientIds, recentClientChangesWithActors, selectedRecentChangeClientIds, ]); const selectedRecentChangeSeries = useMemo( () => buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod), [filteredRecentClientChanges, recentChangesPeriod], ); const recentChangedClientCount = useMemo( () => new Set( filteredRecentClientChanges .map((item) => item.clientId) .filter((clientId) => currentClientIdSet.has(clientId)), ).size, [currentClientIdSet, filteredRecentClientChanges], ); const deletedRecentChangedClientCount = useMemo( () => new Set( filteredRecentClientChanges .map((item) => item.clientId) .filter((clientId) => deletedRecentChangeClientIds.includes(clientId), ), ).size, [deletedRecentChangeClientIds, filteredRecentClientChanges], ); const recentChangeCount = filteredRecentClientChanges.length; const latestRecentChange = filteredRecentClientChanges[0]; const visibleRecentClientChanges = filteredRecentClientChanges.slice( 0, visibleRecentClientChangesCount, ); const hasMoreRecentClientChanges = filteredRecentClientChanges.length > visibleRecentClientChanges.length; const isAllRecentChangeClientsSelected = selectedRecentChangeClientIds.length === 0; 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([]); }; const toggleRecentChangeClientSelection = (clientId: string) => { setSelectedRecentChangeClientIds((current) => { if (current.includes(clientId)) { return current.filter((item) => item !== clientId); } return [...current, clientId]; }); }; const selectAllRecentChangeClients = () => { setSelectedRecentChangeClientIds([]); }; useEffect(() => { setVisibleRecentClientChangesCount((current) => Math.min(Math.max(6, current), filteredRecentClientChanges.length), ); }, [filteredRecentClientChanges.length]); if (isLoadingDeveloperAccessGate) { return (
{t("ui.common.loading", "Loading...")}
); } if (!hasDeveloperAccess) { return ( navigate("/developer-requests")} /> ); } 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.recent_changes.title", "최근 변경된 앱")}

{t( "msg.dev.dashboard.recent_changes.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]) => ( ))}
} label={t( "ui.dev.dashboard.recent_changes.summary.total_changes", "최근 변경 건수", )} value={recentChangeCount.toLocaleString()} /> } label={t( "ui.dev.dashboard.recent_changes.summary.changed_clients", "변경된 앱 수", )} value={recentChangedClientCount.toLocaleString()} /> } label={t( "ui.dev.dashboard.recent_changes.summary.deleted_clients", "삭제된 앱 수", )} value={deletedRecentChangedClientCount.toLocaleString()} /> } label={t( "ui.dev.dashboard.recent_changes.summary.latest_change", "마지막 변경일", )} value={formatDate(latestRecentChange?.timestamp)} />
{isAllRecentChangeClientsSelected ? ( ) : ( )} {isRecentChangesDetailOpen ? (
{filteredRecentClientChanges.length === 0 ? (
{t( "msg.dev.dashboard.recent_changes.empty", "최근 변경 로그가 아직 없습니다.", )}
) : ( visibleRecentClientChanges.map((item) => { const { date, time } = formatRecentChangeTimestamp( item.timestamp, ); return (
{item.clientName} {item.actionLabel} {item.actorName}
{item.detailLabels.length > 0 ? ( item.detailLabels.map((detail) => ( {detail.label}: {detail.value} )) ) : ( {t( "msg.dev.clients.recent_changes.no_detail", "변경 항목을 확인할 수 없습니다.", )} )}

{date} {time}

); }) )} {hasMoreRecentClientChanges ? (
) : null}
) : null}
); } export default GlobalOverviewPage;