forked from baron/baron-sso
adminfront 개요 통계 추가
This commit is contained in:
@@ -1,109 +1,25 @@
|
||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
const flows = [
|
||||
{
|
||||
title: "Admin login",
|
||||
description:
|
||||
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||
pill: "15m TTL",
|
||||
},
|
||||
{
|
||||
title: "Tenant pick",
|
||||
description:
|
||||
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||
pill: "Header-ready",
|
||||
},
|
||||
{
|
||||
title: "Device approval",
|
||||
description:
|
||||
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||
pill: "App session",
|
||||
},
|
||||
];
|
||||
import { KeyRound } from "lucide-react";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Build the admin-only login flow first, keeping app login separate.
|
||||
Respect the “fallback only when user chooses” rule for SMS/email
|
||||
vs app approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||
IDP session placeholder
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Connect auth layer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.title}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||
<span>{flow.pill}</span>
|
||||
<Fingerprint size={14} />
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{flow.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Smartphone size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
App-based approvals
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
App session as MFA replacement
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
If the admin keeps the mobile app signed in and opts in, use
|
||||
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||
SMS/email based on user choice.
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<KeyRound size={22} className="text-primary" />
|
||||
인증가드
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ArrowRight size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
TTL discipline
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Keep admin sessions short
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||
with step-up MFA when critical actions (rotate secret, export logs)
|
||||
happen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function PermissionChecker() {
|
||||
const result = checkMutation.data;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--color-panel)] border-primary/20">
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
@@ -100,7 +100,7 @@ function PermissionChecker() {
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full md:w-auto px-12"
|
||||
className="w-full px-12 md:w-auto"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
</Button>
|
||||
@@ -108,17 +108,17 @@ function PermissionChecker() {
|
||||
|
||||
{checkMutation.isSuccess && result && (
|
||||
<div
|
||||
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
||||
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
? "bg-green-500/10 border-green-500/50 text-green-600"
|
||||
: "bg-destructive/10 border-destructive/50 text-destructive"
|
||||
? "border-green-500/50 bg-green-500/10 text-green-600"
|
||||
: "border-destructive/50 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
<p className="text-center text-sm opacity-80">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||
포함)
|
||||
</p>
|
||||
@@ -127,7 +127,7 @@ function PermissionChecker() {
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-xl font-bold">Access DENIED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
<p className="text-center text-sm opacity-80">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||
</p>
|
||||
</>
|
||||
186
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
186
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
|
||||
import AuthPage from "../auth/AuthPage";
|
||||
import GlobalOverviewPage from "./GlobalOverviewPage";
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
|
||||
fetchAdminOverviewStats: vi.fn(async () => ({
|
||||
totalTenants: 10,
|
||||
oidcClients: 3,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "company-1",
|
||||
type: "COMPANY",
|
||||
name: "한맥",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "org-1",
|
||||
type: "ORGANIZATION",
|
||||
name: "개발팀",
|
||||
slug: "dev-team",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "personal-1",
|
||||
type: "PERSONAL",
|
||||
name: "개인",
|
||||
slug: "personal",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-06T00:00:00Z",
|
||||
updatedAt: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 3,
|
||||
})),
|
||||
fetchAdminRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
period: "day",
|
||||
items: [
|
||||
{
|
||||
date: "2026-05-05",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "orgfront",
|
||||
clientName: "OrgFront",
|
||||
loginRequests: 12,
|
||||
otherRequests: 4,
|
||||
uniqueSubjects: 8,
|
||||
},
|
||||
{
|
||||
date: "2026-05-06",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "adminfront",
|
||||
clientName: "AdminFront",
|
||||
loginRequests: 7,
|
||||
otherRequests: 3,
|
||||
uniqueSubjects: 5,
|
||||
},
|
||||
{
|
||||
date: "2026-09-28",
|
||||
tenantId: "company-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "한맥",
|
||||
clientId: "devfront",
|
||||
clientName: "DevFront",
|
||||
loginRequests: 2,
|
||||
otherRequests: 1,
|
||||
uniqueSubjects: 2,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin overview and auth guard pages", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders usage trend chart without quick navigation or permission checker", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("일 단위 RP 요청 현황"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("05.05")).toBeInTheDocument();
|
||||
expect(await screen.findByText("05.06")).toBeInTheDocument();
|
||||
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders overview summary metrics from the admin stats API", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
expect(
|
||||
(await screen.findByText("전체 테넌트 수")).parentElement,
|
||||
).toHaveTextContent("10");
|
||||
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
|
||||
"3",
|
||||
);
|
||||
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
|
||||
"18",
|
||||
);
|
||||
});
|
||||
|
||||
it("changes the RP usage perspective and targets a permitted organization", async () => {
|
||||
renderWithProviders(<GlobalOverviewPage />);
|
||||
|
||||
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
|
||||
fireEvent.click(screen.getByRole("button", { name: "주" }));
|
||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||
fireEvent.change(screen.getByLabelText("조직 검색"), {
|
||||
target: { value: "개발" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("대상 조직"), {
|
||||
target: { value: "org-1" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||
days: 90,
|
||||
period: "month",
|
||||
tenantId: "org-1",
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
|
||||
renderWithProviders(<AuthPage />);
|
||||
|
||||
expect(screen.getByText("인증가드")).toBeInTheDocument();
|
||||
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("IDP session placeholder"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,433 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
Database,
|
||||
Key,
|
||||
PlusCircle,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
type DailyPoint = {
|
||||
date: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
};
|
||||
|
||||
type SeriesSummary = {
|
||||
key: string;
|
||||
tenantLabel: string;
|
||||
clientLabel: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||
const byDate = new Map<string, DailyPoint>();
|
||||
for (const row of rows) {
|
||||
const current =
|
||||
byDate.get(row.date) ??
|
||||
({
|
||||
date: row.date,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
} satisfies DailyPoint);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
byDate.set(row.date, current);
|
||||
}
|
||||
return Array.from(byDate.values()).sort((a, b) =>
|
||||
a.date.localeCompare(b.date),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||
const bySeries = new Map<string, SeriesSummary>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.tenantId}:${row.clientId}`;
|
||||
const current =
|
||||
bySeries.get(key) ??
|
||||
({
|
||||
key,
|
||||
tenantLabel: row.tenantName || row.tenantId || "-",
|
||||
clientLabel: row.clientName || row.clientId,
|
||||
loginRequests: 0,
|
||||
otherRequests: 0,
|
||||
uniqueSubjects: 0,
|
||||
} satisfies SeriesSummary);
|
||||
current.loginRequests += row.loginRequests;
|
||||
current.otherRequests += row.otherRequests;
|
||||
current.uniqueSubjects = Math.max(
|
||||
current.uniqueSubjects,
|
||||
row.uniqueSubjects,
|
||||
);
|
||||
bySeries.set(key, current);
|
||||
}
|
||||
return Array.from(bySeries.values())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function parseDateParts(date: string) {
|
||||
const parts = date.split("-");
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
year: Number(parts[0]),
|
||||
month: Number(parts[1]),
|
||||
day: Number(parts[2]),
|
||||
monthText: parts[1],
|
||||
dayText: parts[2],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getISOWeekNumber(year: number, month: number, day: number) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const dayOfWeek = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
function getISOWeekThursday(year: number, month: number, day: number) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const dayOfWeek = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||
const parts = parseDateParts(date);
|
||||
if (!parts) {
|
||||
return date;
|
||||
}
|
||||
if (period === "month") {
|
||||
return `${parts.monthText}월`;
|
||||
}
|
||||
if (period === "week") {
|
||||
const weekNumber = String(
|
||||
getISOWeekNumber(parts.year, parts.month, parts.day),
|
||||
).padStart(2, "0");
|
||||
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
|
||||
const weekMonth = weekThursday.getUTCMonth() + 1;
|
||||
const weekDay = weekThursday.getUTCDate();
|
||||
const weekMonthText = String(weekMonth).padStart(2, "0");
|
||||
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
|
||||
return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`;
|
||||
}
|
||||
return `${parts.monthText}.${parts.dayText}`;
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RPUsageMixedChart({
|
||||
rows,
|
||||
filters,
|
||||
period,
|
||||
}: {
|
||||
rows: RPUsageDailyMetric[];
|
||||
filters: ReactNode;
|
||||
period: RPUsagePeriod;
|
||||
}) {
|
||||
const daily = summarizeDaily(rows);
|
||||
const series = summarizeSeries(rows);
|
||||
const chartWidth = 720;
|
||||
const chartHeight = 230;
|
||||
const padX = 48;
|
||||
const padTop = 32;
|
||||
const padBottom = 34;
|
||||
const innerWidth = chartWidth - padX * 2;
|
||||
const innerHeight = chartHeight - padTop - padBottom;
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...daily.map((point) => point.loginRequests + point.otherRequests),
|
||||
...daily.map((point) => point.loginRequests),
|
||||
);
|
||||
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
|
||||
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
|
||||
const y = (value: number) =>
|
||||
padTop + innerHeight - (value / maxValue) * innerHeight;
|
||||
const x = (index: number) => padX + slot * index + slot / 2;
|
||||
const linePoints = daily
|
||||
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
</div>
|
||||
{filters}
|
||||
</div>
|
||||
|
||||
{daily.length === 0 ? (
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||
표시할 RP 이용 집계가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="일 단위 RP 요청 현황"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
className="h-[235px] min-w-[720px] w-full"
|
||||
>
|
||||
<title>일 단위 RP 요청 현황</title>
|
||||
<g transform="translate(510 10)">
|
||||
<rect
|
||||
x="0"
|
||||
y="3"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
||||
기타 요청
|
||||
</text>
|
||||
<line
|
||||
x1="78"
|
||||
x2="98"
|
||||
y1="8"
|
||||
y2="8"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="104"
|
||||
y="12"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
로그인 요청
|
||||
</text>
|
||||
</g>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const gridY = padTop + innerHeight * ratio;
|
||||
const label = Math.round(maxValue * (1 - ratio));
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padX}
|
||||
x2={chartWidth - padX}
|
||||
y1={gridY}
|
||||
y2={gridY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padX - 12}
|
||||
y={gridY + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{daily.map((point, index) => {
|
||||
const center = x(index);
|
||||
const otherHeight =
|
||||
(point.otherRequests / maxValue) * innerHeight;
|
||||
return (
|
||||
<g key={point.date}>
|
||||
<rect
|
||||
x={center - barWidth / 2}
|
||||
y={padTop + innerHeight - otherHeight}
|
||||
width={barWidth}
|
||||
height={otherHeight}
|
||||
rx="3"
|
||||
className="fill-sky-500/70"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={chartHeight - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{formatPeriodLabel(point.date, period)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
className="stroke-emerald-500"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{daily.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.date}-login`}
|
||||
cx={x(index)}
|
||||
cy={y(point.loginRequests)}
|
||||
r="4"
|
||||
className="fill-emerald-500 stroke-background"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||
{series.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{item.clientLabel}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{item.tenantLabel}
|
||||
</span>
|
||||
<span className="ml-auto whitespace-nowrap tabular-nums">
|
||||
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
||||
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
||||
{item.uniqueSubjects.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["admin-overview-stats"],
|
||||
queryFn: fetchAdminOverviewStats,
|
||||
retry: false,
|
||||
});
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["admin-overview-tenant-options"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
const term = tenantSearch.trim().toLowerCase();
|
||||
return (tenantsQuery.data?.items ?? [])
|
||||
.filter(
|
||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||
)
|
||||
.filter((tenant) => {
|
||||
if (!term) return true;
|
||||
return (
|
||||
tenant.name.toLowerCase().includes(term) ||
|
||||
tenant.slug.toLowerCase().includes(term) ||
|
||||
tenant.id.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [tenantSearch, tenantsQuery.data?.items]);
|
||||
const usageQuery = useQuery({
|
||||
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
||||
queryFn: () =>
|
||||
fetchAdminRPUsageDaily({
|
||||
days: usageDays,
|
||||
period,
|
||||
tenantId: selectedTenantId || undefined,
|
||||
}),
|
||||
retry: false,
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
const usageRows = usageQuery.data?.items ?? [];
|
||||
const metric = (value: number | undefined) =>
|
||||
value === undefined ? "-" : value.toLocaleString();
|
||||
const chartFilters = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||
{[
|
||||
["day", "일"],
|
||||
["week", "주"],
|
||||
["month", "월"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={period === value}
|
||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||
period === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
aria-label="조직 검색"
|
||||
value={tenantSearch}
|
||||
onChange={(event) => setTenantSearch(event.target.value)}
|
||||
placeholder="조직 검색"
|
||||
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
|
||||
/>
|
||||
<select
|
||||
aria-label="대상 조직"
|
||||
value={selectedTenantId}
|
||||
onChange={(event) => setSelectedTenantId(event.target.value)}
|
||||
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
|
||||
>
|
||||
<option value="">전체 조직</option>
|
||||
{tenantOptions.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id}>
|
||||
{tenant.name} ({tenant.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<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-3xl font-bold tracking-tight">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.title", "Dashboard")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
@@ -36,166 +436,61 @@ function GlobalOverviewPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
활성화된 테넌트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
등록된 OIDC 앱
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.oidc_clients",
|
||||
"OIDC 클라이언트",
|
||||
)}
|
||||
value={metric(stats?.oidcClients)}
|
||||
/>
|
||||
</RoleGuard>
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"최근 감사 로그 (24h)",
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
발생한 이벤트 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
|
||||
<Database size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
|
||||
Active
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
접근 제어 정상 동작
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OverviewMetric
|
||||
icon={<Activity size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"24시간 이벤트",
|
||||
)}
|
||||
value={metric(stats?.auditEvents24h)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Database size={14} />}
|
||||
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
value="Active"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/tenants/new"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<PlusCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-primary">
|
||||
테넌트 추가
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
새로운 조직이나 그룹을 생성합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</RoleGuard>
|
||||
|
||||
<Link
|
||||
to="/users"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
|
||||
사용자 관리
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
전체 사용자를 조회하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/api-keys"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
|
||||
<Key size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
|
||||
API 키 관리
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
시스템 연동을 위한 키를 발급합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</RoleGuard>
|
||||
|
||||
<Link
|
||||
to="/audit-logs"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
|
||||
감사 로그
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
보안 이벤트를 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<div className="pt-4">
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
</RoleGuard>
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
{chartFilters}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||
그래프가 표시됩니다.
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={usageRows}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user