forked from baron/baron-sso
1610 lines
52 KiB
TypeScript
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;
|