import { useQuery } from "@tanstack/react-query"; import { Activity, AlertTriangle, CheckCircle2, Database, LayoutDashboard, ShieldCheck, Users, } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; import { OverviewAxisNotes, OverviewMetric, OverviewSelectionChips, } from "../../../../common/core/components/overview"; import { RoleGuard } from "../../components/auth/RoleGuard"; import { type DataIntegrityStatus, type RPUsageDailyMetric, type RPUsagePeriod, type TenantSummary, fetchAdminOverviewStats, fetchAdminRPUsageDaily, fetchAllTenants, fetchDataIntegrityReport, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; type DailyPoint = { date: string; loginRequests: number; otherRequests: number; }; type SeriesSummary = { key: string; clientLabel: string; loginRequests: number; uniqueSubjects: number; }; 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((a, b) => a.date.localeCompare(b.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, uniqueSubjects: 0, } satisfies SeriesSummary); current.loginRequests += row.loginRequests; current.uniqueSubjects = Math.max( current.uniqueSubjects, row.uniqueSubjects, ); bySeries.set(key, current); } return Array.from(bySeries.values()) .sort((a, b) => b.loginRequests - a.loginRequests) .slice(0, 5); } 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 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 formatOverviewDateTime(value?: string) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("ko-KR", { dateStyle: "medium", timeStyle: "short", }).format(date); } function integrityStatusText(status: DataIntegrityStatus) { switch (status) { case "pass": return t("ui.admin.integrity.status.pass", "정상"); case "warning": return t("ui.admin.integrity.status.warning", "주의"); default: return t("ui.admin.integrity.status.fail", "실패"); } } function integrityStatusClass(status: DataIntegrityStatus) { switch (status) { case "pass": return "text-emerald-700 dark:text-emerald-300"; case "warning": return "text-amber-700 dark:text-amber-300"; default: return "text-destructive"; } } function IntegrityOverviewSummary() { const { data, isError } = useQuery({ queryKey: ["admin-overview-integrity"], queryFn: fetchDataIntegrityReport, retry: false, }); if (isError) { return (
{t( "ui.admin.integrity.fetch_error", "정합성 최종 검증 결과를 불러오지 못했습니다.", )}
); } if (!data) { return null; } return (
{data.status === "pass" ? ( ) : ( )}

{t( "ui.admin.integrity.summary.title", "정합성 최종 검증", )}

{integrityStatusText(data.status)} {t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", { count: data.summary.failures, })} {formatOverviewDateTime(data.checkedAt)}
{data.sections.map((section) => (
{integritySectionLabel(section.key, section.label)} {integrityStatusText(section.status)}
))}
); } function integritySectionLabel(key: string, fallback: string) { switch (key) { case "tenant_integrity": return t("ui.admin.integrity.section.tenant_integrity", fallback); case "user_integrity": return t("ui.admin.integrity.section.user_integrity", fallback); default: return fallback; } } function RPUsageMixedChart({ rows, periodControls, filters, period, }: { rows: RPUsageDailyMetric[]; periodControls: ReactNode; filters: ReactNode; period: RPUsagePeriod; }) { const daily = summarizeDaily(rows); const series = summarizeSeries(rows); const chartWidth = 720; const chartHeight = 230; const padX = 48; const padTop = 32; const padBottom = 34; const innerWidth = chartWidth - padX * 2; const innerHeight = chartHeight - padTop - padBottom; const maxValue = Math.max( 1, ...daily.map((point) => point.loginRequests + point.otherRequests), ...daily.map((point) => point.loginRequests), ); const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth; const barWidth = Math.min(28, Math.max(10, slot * 0.42)); 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(" "); return (

{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}

{t( "ui.admin.overview.chart.description", "전체 또는 선택한 조직 기준으로 그래프를 확인합니다.", )}

{periodControls}
{filters} {daily.length === 0 ? (
표시할 RP 이용 집계가 없습니다.
) : (
일 단위 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); const otherHeight = (point.otherRequests / maxValue) * innerHeight; return ( {formatPeriodLabel(point.date, period)} ); })} {daily.map((point, index) => ( ))}
)} {series.length > 0 && (
{series.map((item) => (
{item.clientLabel} {t( "ui.common.chart.series_summary.login_users", "로그인 {{login}} / 사용자 {{subjects}}", { login: item.loginRequests.toLocaleString(), subjects: item.uniqueSubjects.toLocaleString(), }, )}
))}
)}
); } function GlobalOverviewPage() { const [period, setPeriod] = useState("day"); const [selectedTenantIds, setSelectedTenantIds] = useState([]); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const statsQuery = useQuery({ queryKey: ["admin-overview-stats"], queryFn: fetchAdminOverviewStats, retry: false, }); const tenantsQuery = useQuery({ queryKey: ["admin-overview-tenant-options"], queryFn: () => fetchAllTenants(), retry: false, }); const tenantOptions = useMemo(() => { return (tenantsQuery.data?.items ?? []).filter( (tenant) => tenant.type === "COMPANY", ); }, [tenantsQuery.data?.items]); const usageQuery = useQuery({ queryKey: ["admin-rp-usage-daily", usageDays, period], queryFn: () => fetchAdminRPUsageDaily({ days: usageDays, period, }), retry: false, }); const stats = statsQuery.data; const visibleTenantCount = tenantsQuery.data?.items.length; const usageRows = usageQuery.data?.items ?? []; const filteredUsageRows = useMemo(() => { if (selectedTenantIds.length === 0) { return usageRows; } const selectedSet = new Set(selectedTenantIds); return usageRows.filter((row) => selectedSet.has(row.tenantId)); }, [selectedTenantIds, usageRows]); const metric = (value: number | undefined) => value === undefined ? "-" : value.toLocaleString(); const periodControls = (
{[ ["day", t("ui.common.chart.period.day", "일")], ["week", t("ui.common.chart.period.week", "주")], ["month", t("ui.common.chart.period.month", "월")], ].map(([value, label]) => ( ))}
); const chartFilters = (
({ id: tenant.id, label: `${tenant.name} (${tenant.slug})`, }))} selectedIds={selectedTenantIds} onSelectAll={() => setSelectedTenantIds([])} onToggle={(tenantId) => { setSelectedTenantIds((current) => current.includes(tenantId) ? current.filter((item) => item !== tenantId) : [...current, tenantId], ); }} />
); return (

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

{t( "msg.admin.overview.description", "시스템 전반의 주요 현황을 확인하고 관리합니다.", )}

} label={t( "ui.admin.overview.summary.total_tenants", "전체 테넌트 수", )} value={metric(visibleTenantCount ?? stats?.totalTenants)} /> } label={t( "ui.admin.overview.summary.oidc_clients", "OIDC 클라이언트", )} value={metric(stats?.oidcClients)} /> } label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")} value={metric(stats?.totalUsers)} /> } label={t( "ui.admin.overview.summary.audit_events_24h", "24시간 이벤트", )} value={metric(stats?.auditEvents24h)} /> } label={t("ui.admin.overview.summary.policy_gate", "정책 상태")} value="Active" />
{usageQuery.isError ? (

{t( "ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황", )}

{t( "ui.admin.overview.chart.description", "전체 또는 선택한 조직 기준으로 그래프를 확인합니다.", )}

{periodControls}
{chartFilters}
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작 이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위 그래프가 표시됩니다.
) : ( )}
); } export default GlobalOverviewPage;