forked from baron/baron-sso
devfront: 개요 페이지 경로와 레이아웃 정리
This commit is contained in:
882
devfront/src/features/overview/GlobalOverviewPage.tsx
Normal file
882
devfront/src/features/overview/GlobalOverviewPage.tsx
Normal file
@@ -0,0 +1,882 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Layers3,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
type ClientSummary,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
fetchClients,
|
||||
fetchDevRPUsageDaily,
|
||||
fetchDevStats,
|
||||
fetchDeveloperRequestStatus,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import {
|
||||
OverviewAxisNotes,
|
||||
OverviewMetric,
|
||||
OverviewSelectionChips,
|
||||
} from "../../../../common/core/components/overview";
|
||||
|
||||
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 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 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 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 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 [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
|
||||
queryKey: ["developer-request", tenantId],
|
||||
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
||||
enabled: hasAccessToken && role === "user",
|
||||
});
|
||||
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 =
|
||||
role === "super_admin" ||
|
||||
role === "tenant_admin" ||
|
||||
role === "rp_admin" ||
|
||||
requestStatus?.status === "approved";
|
||||
const isDeveloperRequestPending = requestStatus?.status === "pending";
|
||||
const canRequestDeveloperAccess =
|
||||
(role === "user" || role === "tenant_member") &&
|
||||
!isLoadingRequestStatus &&
|
||||
!hasDeveloperAccess &&
|
||||
!isDeveloperRequestPending;
|
||||
const distribution = useMemo(
|
||||
() => buildClientDistribution(clients),
|
||||
[clients],
|
||||
);
|
||||
const visibleClients = useMemo(
|
||||
() =>
|
||||
[...clients]
|
||||
.sort((left, right) => {
|
||||
const statusCompare = (left.status || "").localeCompare(
|
||||
right.status || "",
|
||||
);
|
||||
if (statusCompare !== 0) {
|
||||
return statusCompare;
|
||||
}
|
||||
return (left.name || left.id).localeCompare(right.name || right.id);
|
||||
})
|
||||
.slice(0, 6),
|
||||
[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 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 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([]);
|
||||
};
|
||||
|
||||
if ((role === "user" || role === "tenant_member") && isLoadingRequestStatus) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("ui.common.loading", "Loading...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasDeveloperAccess) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="font-medium text-foreground">
|
||||
{isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.dashboard.access_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.dashboard.access_denied",
|
||||
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.dashboard.access_pending_detail",
|
||||
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.dashboard.access_denied_detail",
|
||||
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
|
||||
)}
|
||||
</p>
|
||||
{(isDeveloperRequestPending || canRequestDeveloperAccess) && (
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold text-primary hover:underline"
|
||||
onClick={() => navigate("/developer-requests")}
|
||||
>
|
||||
{isDeveloperRequestPending
|
||||
? t("ui.dev.nav.developer_request", "개발자 권한 신청")
|
||||
: t("ui.dev.welcome.btn_request", "개발자 등록 신청하기")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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.common.overview.title", "운영 현황")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.dashboard.description",
|
||||
"연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</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="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.dev.dashboard.chart.title",
|
||||
"애플리케이션별 로그인요청/기타 요청 현황",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.dashboard.chart.filter_description",
|
||||
"전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[0.9fr,1.1fr]">
|
||||
<section className="rounded-xl border border-border/60 bg-card p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers3 size={18} className="text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
{t(
|
||||
"ui.dev.dashboard.distribution.title",
|
||||
"애플리케이션 구성 요약",
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.dashboard.distribution.description",
|
||||
"애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("ui.dev.dashboard.distribution.private", "Server side App")}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold tabular-nums">
|
||||
{distribution.privateClients.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.dashboard.distribution.headless_hint",
|
||||
"이 중 Headless Login 사용 {{count}}",
|
||||
{
|
||||
count: distribution.headlessClients.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("ui.dev.dashboard.distribution.pkce", "PKCE")}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold tabular-nums">
|
||||
{distribution.pkceClients.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/60 bg-card p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers3 size={18} className="text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("ui.dev.dashboard.recent.title", "내 애플리케이션")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.dashboard.recent.empty",
|
||||
"현재 계정이 접근할 수 있는 RP를 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{visibleClients.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.dashboard.recent.none",
|
||||
"표시할 연동 앱이 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
visibleClients.map((client) => (
|
||||
<div
|
||||
key={client.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/40 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">
|
||||
{client.name || t("ui.dev.clients.untitled", "Untitled")}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{client.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>
|
||||
{client.metadata?.headless_login_enabled === true
|
||||
? t(
|
||||
"ui.dev.clients.type.private_headless",
|
||||
"Server side App (Headless Login)",
|
||||
)
|
||||
: client.type === "private"
|
||||
? t("ui.dev.clients.type.private", "Server side App")
|
||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</p>
|
||||
<p>
|
||||
{client.status === "active"
|
||||
? t("ui.dev.clients.status.active", "활성")
|
||||
: client.status === "inactive"
|
||||
? t("ui.dev.clients.status.inactive", "비활성")
|
||||
: client.status || "-"}
|
||||
</p>
|
||||
<p>
|
||||
{t("ui.dev.clients.table.created_at", "생성일")}{" "}
|
||||
{formatDate(client.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalOverviewPage;
|
||||
Reference in New Issue
Block a user