1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/overview/GlobalOverviewPage.tsx

1610 lines
52 KiB
TypeScript

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<ClientDistribution>(
(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<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((left, right) =>
left.date.localeCompare(right.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
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<string, DailyPoint>;
}
>();
for (const row of rows) {
const current = byClient.get(row.clientId) ?? {
clientLabel: row.clientName || row.clientId,
byDate: new Map<string, DailyPoint>(),
};
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<string> }
>();
for (const item of items) {
const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period);
const current = byDate.get(bucket) ?? {
changeCount: 0,
actors: new Set<string>(),
};
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<string> }
>();
for (const item of items) {
const current = bySeries.get(item.clientId) ?? {
clientLabel: item.clientName,
changeCount: 0,
actors: new Set<string>(),
};
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<string, RecentChangePoint>;
actorIdsByDate: Map<string, Set<string>>;
}
>();
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<string, RecentChangePoint>(),
actorIdsByDate: new Map<string, Set<string>>(),
};
const point = current.byDate.get(bucket) ?? {
date: bucket,
changeCount: 0,
uniqueActors: 0,
};
point.changeCount += 1;
const actorIds = current.actorIdsByDate.get(bucket) ?? new Set<string>();
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 (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
{t("msg.dev.dashboard.chart.empty", "표시할 RP 이용 집계가 없습니다.")}
</div>
);
}
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={t("ui.dev.dashboard.chart.aria", "RP 요청 현황")}
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title>{t("ui.dev.dashboard.chart.aria", "RP 요청 현황")}</title>
{[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);
return (
<g key={point.date}>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
{!multiLinePoints || multiLinePoints.length === 0 ? (
<>
<polyline
points={linePoints}
fill="none"
stroke={colors.line}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
fill={colors.point}
stroke="hsl(var(--background))"
strokeWidth="2"
/>
))}
</>
) : (
multiLinePoints.map((seriesItem) => (
<g key={seriesItem.key}>
<polyline
points={seriesItem.pointsAttr}
fill="none"
stroke={seriesItem.color.line}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{seriesItem.points.map((point, index) => (
<circle
key={`${seriesItem.key}-${point.date}`}
cx={x(index)}
cy={y(point.loginRequests)}
r="3.5"
fill={seriesItem.color.point}
stroke="hsl(var(--background))"
strokeWidth="2"
/>
))}
</g>
))
)}
</svg>
</div>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
/>
{multiLinePoints && multiLinePoints.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">
{multiLinePoints.map((item) => {
const seriesItem = seriesByKey.get(item.key);
return (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color.line }}
/>
<span className="font-medium">{item.clientLabel}</span>
{seriesItem ? (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.dev.dashboard.chart.series",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: seriesItem.loginRequests.toLocaleString(),
subjects: seriesItem.uniqueSubjects.toLocaleString(),
},
)}
</span>
) : null}
</div>
);
})}
</div>
) : topSeries.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">
{topSeries.map((item) => (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span className="font-medium">{item.clientLabel}</span>
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.dev.dashboard.chart.series",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: item.loginRequests.toLocaleString(),
subjects: item.uniqueSubjects.toLocaleString(),
},
)}
</span>
</div>
))}
</div>
) : null}
</div>
);
}
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 (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.recent_changes.empty",
"최근 변경 로그가 아직 없습니다.",
)}
</div>
);
}
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={t(
"ui.dev.dashboard.recent_changes.aria",
"최근 변경된 앱 현황",
)}
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title>
{t("ui.dev.dashboard.recent_changes.aria", "최근 변경된 앱 현황")}
</title>
{[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) => (
<g key={point.date}>
<text
x={x(index)}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
))}
{!multiLinePoints || multiLinePoints.length === 0 ? (
<>
<polyline
points={linePoints}
fill="none"
stroke={usageChartPalettes[1].line}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-change`}
cx={x(index)}
cy={y(point.changeCount)}
r="4"
fill={usageChartPalettes[1].point}
stroke="hsl(var(--background))"
strokeWidth="2"
/>
))}
</>
) : (
multiLinePoints.map((seriesItem) => (
<g key={seriesItem.key}>
<polyline
points={seriesItem.pointsAttr}
fill="none"
stroke={seriesItem.color.line}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{seriesItem.points.map((point, index) => (
<circle
key={`${seriesItem.key}-${point.date}`}
cx={x(index)}
cy={y(point.changeCount)}
r="3.5"
fill={seriesItem.color.point}
stroke="hsl(var(--background))"
strokeWidth="2"
/>
))}
</g>
))
)}
</svg>
</div>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.dev.dashboard.recent_changes.y_axis", "Y축: 변경 수")}
/>
{multiLinePoints && multiLinePoints.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">
{multiLinePoints.map((item) => {
const seriesItem = seriesByKey.get(item.key);
return (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color.line }}
/>
<span className="font-medium">{item.clientLabel}</span>
{seriesItem ? (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.dev.dashboard.recent_changes.series",
"변경 {{changes}} / 작업자 {{actors}}",
{
changes: seriesItem.changeCount.toLocaleString(),
actors: seriesItem.uniqueActors.toLocaleString(),
},
)}
</span>
) : null}
</div>
);
})}
</div>
) : topSeries.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">
{topSeries.map((item) => (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span className="font-medium">{item.clientLabel}</span>
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.dev.dashboard.recent_changes.series",
"변경 {{changes}} / 작업자 {{actors}}",
{
changes: item.changeCount.toLocaleString(),
actors: item.uniqueActors.toLocaleString(),
},
)}
</span>
</div>
))}
</div>
) : null}
</div>
);
}
function GlobalOverviewPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | 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<RPUsagePeriod>("day");
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
const [recentChangesPeriod, setRecentChangesPeriod] =
useState<RPUsagePeriod>("week");
const [selectedRecentChangeClientIds, setSelectedRecentChangeClientIds] =
useState<string[]>([]);
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<ClientFilterOption[]>(
() =>
[...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<RecentClientChange[]>(
() => 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<ClientFilterOption[]>(() => {
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 (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
if (!hasDeveloperAccess) {
return (
<DeveloperAccessRequestCard
title={t("ui.common.overview.title", "운영 현황")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedMessage={t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>
);
}
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<LayoutDashboard size={20} />
</div>
<div className="space-y-1">
<h2 className="text-3xl font-semibold">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.description",
"연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.",
)}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t("ui.dev.dashboard.summary.total_clients", "총 RP 수")}
value={formatMetric(stats?.total_clients ?? clients.length)}
/>
<OverviewMetric
icon={<CheckCircle2 size={14} />}
label={t("ui.dev.dashboard.summary.active_clients", "활성 RP 수")}
value={formatMetric(distribution.activeClients)}
/>
<OverviewMetric
icon={<Activity size={14} />}
label={t("ui.dev.dashboard.summary.active_sessions", "활성 세션 수")}
value={formatMetric(stats?.active_sessions)}
/>
<OverviewMetric
icon={<AlertTriangle size={14} />}
label={t(
"ui.dev.dashboard.summary.auth_failures_24h",
"24시간 인증 실패 수",
)}
value={formatMetric(stats?.auth_failures_24h)}
/>
</div>
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold">
{t(
"ui.dev.dashboard.chart.title",
"애플리케이션별 로그인요청/기타 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.chart.filter_description",
"전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
<fieldset
className="flex h-8 items-center gap-1"
aria-label="집계 단위"
>
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
["month", t("ui.common.chart.period.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>
))}
</fieldset>
</div>
<OverviewSelectionChips
allLabel={t("ui.dev.dashboard.chart.filter_all", "전체")}
options={clientFilterOptions}
selectedIds={selectedClientIds}
onSelectAll={selectAllClients}
onToggle={toggleClientSelection}
/>
{usageQuery.isError ? (
<div className="text-sm text-muted-foreground">{usageErrorText}</div>
) : isAllClientsSelected ? (
<RPUsageMixedChart rows={filteredUsageRows} period={period} />
) : (
<RPUsageMixedChart
rows={filteredUsageRows}
period={period}
multiLineSeries={selectedMultiLineSeries}
/>
)}
</section>
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold">
{t("ui.dev.dashboard.recent_changes.title", "최근 변경된 앱")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.recent_changes.description",
"변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<fieldset
className="flex h-8 items-center gap-1"
aria-label={t(
"ui.dev.dashboard.recent_changes.period",
"최근 변경 집계 단위",
)}
>
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
["month", t("ui.common.chart.period.month", "월")],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={recentChangesPeriod === value}
onClick={() => setRecentChangesPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
recentChangesPeriod === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</fieldset>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<OverviewMetric
icon={<Clock3 size={14} />}
label={t(
"ui.dev.dashboard.recent_changes.summary.total_changes",
"최근 변경 건수",
)}
value={recentChangeCount.toLocaleString()}
/>
<OverviewMetric
icon={<Layers3 size={14} />}
label={t(
"ui.dev.dashboard.recent_changes.summary.changed_clients",
"변경된 앱 수",
)}
value={recentChangedClientCount.toLocaleString()}
/>
<OverviewMetric
icon={<Layers3 size={14} />}
label={t(
"ui.dev.dashboard.recent_changes.summary.deleted_clients",
"삭제된 앱 수",
)}
value={deletedRecentChangedClientCount.toLocaleString()}
/>
<OverviewMetric
icon={<CheckCircle2 size={14} />}
label={t(
"ui.dev.dashboard.recent_changes.summary.latest_change",
"마지막 변경일",
)}
value={formatDate(latestRecentChange?.timestamp)}
/>
</div>
<OverviewSelectionChips
allLabel={t("ui.dev.dashboard.chart.filter_all", "전체")}
options={recentChangeFilterOptions}
selectedIds={selectedRecentChangeClientIds}
onSelectAll={selectAllRecentChangeClients}
onToggle={toggleRecentChangeClientSelection}
/>
{isAllRecentChangeClientsSelected ? (
<RecentClientChangesChart
items={filteredRecentClientChanges}
period={recentChangesPeriod}
/>
) : (
<RecentClientChangesChart
items={filteredRecentClientChanges}
period={recentChangesPeriod}
multiLineSeries={selectedRecentChangeSeries}
/>
)}
<button
type="button"
className="mt-8 flex w-full items-center justify-between rounded-xl border border-border/40 bg-card/60 px-4 py-3 text-left transition-colors hover:bg-card/80"
aria-expanded={isRecentChangesDetailOpen}
onClick={() => setIsRecentChangesDetailOpen((current) => !current)}
>
<span className="text-sm font-medium text-foreground">
{isRecentChangesDetailOpen
? t("ui.common.collapse", "접기")
: t("ui.common.details", "상세정보")}
</span>
<ChevronDown
size={16}
className={`text-muted-foreground transition-transform ${
isRecentChangesDetailOpen ? "rotate-180" : ""
}`}
/>
</button>
{isRecentChangesDetailOpen ? (
<div className="mt-3 rounded-2xl border border-border/40 bg-card/70 p-4">
<div className="grid gap-3 lg:grid-cols-2">
{filteredRecentClientChanges.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/40 bg-card/80 p-5 text-sm text-muted-foreground lg:col-span-2">
{t(
"msg.dev.dashboard.recent_changes.empty",
"최근 변경 로그가 아직 없습니다.",
)}
</div>
) : (
visibleRecentClientChanges.map((item) => {
const { date, time } = formatRecentChangeTimestamp(
item.timestamp,
);
return (
<div
key={item.eventId}
className="rounded-xl border border-border/40 bg-card/80 p-4"
>
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/clients/${item.clientId}`}
className="font-semibold transition-colors hover:text-primary"
>
{item.clientName}
</Link>
<Badge variant="muted">{item.actionLabel}</Badge>
<span className="text-xs text-muted-foreground">
{item.actorName}
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{item.detailLabels.length > 0 ? (
item.detailLabels.map((detail) => (
<Badge
key={`${item.eventId}-${detail.label}`}
variant="outline"
>
{detail.label}: {detail.value}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.no_detail",
"변경 항목을 확인할 수 없습니다.",
)}
</span>
)}
</div>
<p className="mt-3 text-xs text-muted-foreground">
{date} {time}
</p>
</div>
);
})
)}
{hasMoreRecentClientChanges ? (
<div className="pt-2 text-center lg:col-span-2">
<Button
type="button"
variant="outline"
onClick={() =>
setVisibleRecentClientChangesCount((current) =>
Math.min(
current + 6,
filteredRecentClientChanges.length,
),
)
}
>
{t("ui.common.load_more", "더 보기")}
</Button>
</div>
) : null}
</div>
</div>
) : null}
</section>
</div>
);
}
export default GlobalOverviewPage;