import { useQuery } from "@tanstack/react-query"; import { Activity, BarChart3, Database, ShieldCheck, Users, } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; import { RoleGuard } from "../../components/auth/RoleGuard"; import { type RPUsageDailyMetric, type RPUsagePeriod, type TenantSummary, fetchAdminOverviewStats, fetchAdminRPUsageDaily, fetchAllTenants, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; type DailyPoint = { date: string; loginRequests: number; otherRequests: number; }; type SeriesSummary = { key: string; tenantLabel: string; clientLabel: string; loginRequests: number; otherRequests: 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.tenantId}:${row.clientId}`; const current = bySeries.get(key) ?? ({ key, tenantLabel: row.tenantName || row.tenantId || "-", 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( (a, b) => b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests), ) .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 OverviewMetric({ icon, label, value, }: { icon: ReactNode; label: string; value: string; }) { return ( {icon} {label} {value} ); } function RPUsageMixedChart({ rows, filters, period, }: { rows: RPUsageDailyMetric[]; 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 (

회사별 앱별 로그인요청/기타 요청 현황

{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} {item.tenantLabel} 로그인 {item.loginRequests.toLocaleString()} / 기타{" "} {item.otherRequests.toLocaleString()} / 사용자{" "} {item.uniqueSubjects.toLocaleString()}
))}
)}
); } function GlobalOverviewPage() { const [period, setPeriod] = useState("day"); const [tenantSearch, setTenantSearch] = useState(""); const [selectedTenantId, setSelectedTenantId] = 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(() => { const term = tenantSearch.trim().toLowerCase(); return (tenantsQuery.data?.items ?? []) .filter( (tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION", ) .filter((tenant) => { if (!term) return true; return ( tenant.name.toLowerCase().includes(term) || tenant.slug.toLowerCase().includes(term) || tenant.id.toLowerCase().includes(term) ); }); }, [tenantSearch, tenantsQuery.data?.items]); const usageQuery = useQuery({ queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId], queryFn: () => fetchAdminRPUsageDaily({ days: usageDays, period, tenantId: selectedTenantId || undefined, }), retry: false, }); const stats = statsQuery.data; const usageRows = usageQuery.data?.items ?? []; const metric = (value: number | undefined) => value === undefined ? "-" : value.toLocaleString(); const chartFilters = (
{[ ["day", "일"], ["week", "주"], ["month", "월"], ].map(([value, label]) => ( ))}
setTenantSearch(event.target.value)} placeholder="조직 검색" className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44" />
); return (

{t("ui.admin.overview.title", "Dashboard")}

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

} label={t( "ui.admin.overview.summary.total_tenants", "전체 테넌트 수", )} value={metric(stats?.totalTenants)} /> } label={t( "ui.admin.overview.summary.oidc_clients", "OIDC 클라이언트", )} value={metric(stats?.oidcClients)} /> } 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 ? (

회사별 앱별 로그인요청/기타 요청 현황

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