forked from baron/baron-sso
499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
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<string, DailyPoint>();
|
|
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<string, SeriesSummary>();
|
|
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 (
|
|
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
|
<span className="text-muted-foreground">{icon}</span>
|
|
<span className="text-muted-foreground">{label}</span>
|
|
<span className="font-semibold tabular-nums">{value}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<section className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<BarChart3 size={18} className="text-primary" />
|
|
<h3 className="text-base font-semibold">
|
|
회사별 앱별 로그인요청/기타 요청 현황
|
|
</h3>
|
|
</div>
|
|
{filters}
|
|
</div>
|
|
|
|
{daily.length === 0 ? (
|
|
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
|
표시할 RP 이용 집계가 없습니다.
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<svg
|
|
role="img"
|
|
aria-label="일 단위 RP 요청 현황"
|
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
|
className="h-[235px] min-w-[720px] w-full"
|
|
>
|
|
<title>일 단위 RP 요청 현황</title>
|
|
<g transform="translate(510 10)">
|
|
<rect
|
|
x="0"
|
|
y="3"
|
|
width="10"
|
|
height="10"
|
|
rx="2"
|
|
className="fill-sky-500/70"
|
|
/>
|
|
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
|
기타 요청
|
|
</text>
|
|
<line
|
|
x1="78"
|
|
x2="98"
|
|
y1="8"
|
|
y2="8"
|
|
className="stroke-emerald-500"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
/>
|
|
<text
|
|
x="104"
|
|
y="12"
|
|
className="fill-muted-foreground text-[11px]"
|
|
>
|
|
로그인 요청
|
|
</text>
|
|
</g>
|
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
|
const gridY = padTop + innerHeight * ratio;
|
|
const label = Math.round(maxValue * (1 - ratio));
|
|
return (
|
|
<g key={ratio}>
|
|
<line
|
|
x1={padX}
|
|
x2={chartWidth - padX}
|
|
y1={gridY}
|
|
y2={gridY}
|
|
stroke="currentColor"
|
|
className="text-border"
|
|
strokeWidth="1"
|
|
/>
|
|
<text
|
|
x={padX - 12}
|
|
y={gridY + 4}
|
|
textAnchor="end"
|
|
className="fill-muted-foreground text-[11px]"
|
|
>
|
|
{label}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
{daily.map((point, index) => {
|
|
const center = x(index);
|
|
const otherHeight =
|
|
(point.otherRequests / maxValue) * innerHeight;
|
|
return (
|
|
<g key={point.date}>
|
|
<rect
|
|
x={center - barWidth / 2}
|
|
y={padTop + innerHeight - otherHeight}
|
|
width={barWidth}
|
|
height={otherHeight}
|
|
rx="3"
|
|
className="fill-sky-500/70"
|
|
/>
|
|
<text
|
|
x={center}
|
|
y={chartHeight - 12}
|
|
textAnchor="middle"
|
|
className="fill-muted-foreground text-[11px]"
|
|
>
|
|
{formatPeriodLabel(point.date, period)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
<polyline
|
|
points={linePoints}
|
|
fill="none"
|
|
className="stroke-emerald-500"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
{daily.map((point, index) => (
|
|
<circle
|
|
key={`${point.date}-login`}
|
|
cx={x(index)}
|
|
cy={y(point.loginRequests)}
|
|
r="4"
|
|
className="fill-emerald-500 stroke-background"
|
|
strokeWidth="2"
|
|
/>
|
|
))}
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{series.length > 0 && (
|
|
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
|
{series.map((item) => (
|
|
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate font-medium">{item.clientLabel}</span>
|
|
<span className="truncate text-muted-foreground">
|
|
{item.tenantLabel}
|
|
</span>
|
|
<span className="ml-auto whitespace-nowrap tabular-nums">
|
|
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
|
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
|
{item.uniqueSubjects.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function GlobalOverviewPage() {
|
|
const [period, setPeriod] = useState<RPUsagePeriod>("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 = (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
|
{[
|
|
["day", "일"],
|
|
["week", "주"],
|
|
["month", "월"],
|
|
].map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
aria-pressed={period === value}
|
|
onClick={() => setPeriod(value as RPUsagePeriod)}
|
|
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
|
period === value
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted/60 hover:bg-muted"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
aria-label="조직 검색"
|
|
value={tenantSearch}
|
|
onChange={(event) => 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"
|
|
/>
|
|
<select
|
|
aria-label="대상 조직"
|
|
value={selectedTenantId}
|
|
onChange={(event) => setSelectedTenantId(event.target.value)}
|
|
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
|
|
>
|
|
<option value="">전체 조직</option>
|
|
{tenantOptions.map((tenant) => (
|
|
<option key={tenant.id} value={tenant.id}>
|
|
{tenant.name} ({tenant.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4 animate-in fade-in duration-500">
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<h2 className="text-2xl font-semibold tracking-tight">
|
|
{t("ui.admin.overview.title", "Dashboard")}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.overview.description",
|
|
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
|
|
<RoleGuard roles={["super_admin"]}>
|
|
<OverviewMetric
|
|
icon={<Users size={14} />}
|
|
label={t(
|
|
"ui.admin.overview.summary.total_tenants",
|
|
"전체 테넌트 수",
|
|
)}
|
|
value={metric(stats?.totalTenants)}
|
|
/>
|
|
<OverviewMetric
|
|
icon={<ShieldCheck size={14} />}
|
|
label={t(
|
|
"ui.admin.overview.summary.oidc_clients",
|
|
"OIDC 클라이언트",
|
|
)}
|
|
value={metric(stats?.oidcClients)}
|
|
/>
|
|
</RoleGuard>
|
|
<OverviewMetric
|
|
icon={<Activity size={14} />}
|
|
label={t(
|
|
"ui.admin.overview.summary.audit_events_24h",
|
|
"24시간 이벤트",
|
|
)}
|
|
value={metric(stats?.auditEvents24h)}
|
|
/>
|
|
<OverviewMetric
|
|
icon={<Database size={14} />}
|
|
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
|
value="Active"
|
|
/>
|
|
</div>
|
|
|
|
{usageQuery.isError ? (
|
|
<section className="space-y-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<h3 className="text-base font-semibold">
|
|
회사별 앱별 로그인요청/기타 요청 현황
|
|
</h3>
|
|
{chartFilters}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
|
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
|
그래프가 표시됩니다.
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<RPUsageMixedChart
|
|
rows={usageRows}
|
|
filters={chartFilters}
|
|
period={period}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default GlobalOverviewPage;
|