forked from baron/baron-sso
개요 페이지 공통 컴포넌트 및 문구 적용
This commit is contained in:
@@ -188,18 +188,14 @@ describe("admin overview and auth guard pages", () => {
|
|||||||
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
|
||||||
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
fireEvent.click(screen.getByRole("button", { name: "월" }));
|
||||||
fireEvent.change(screen.getByLabelText("조직 검색"), {
|
fireEvent.click(
|
||||||
target: { value: "개발" },
|
screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }),
|
||||||
});
|
);
|
||||||
fireEvent.change(screen.getByLabelText("대상 조직"), {
|
|
||||||
target: { value: "org-1" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
|
||||||
days: 90,
|
days: 90,
|
||||||
period: "month",
|
period: "month",
|
||||||
tenantId: "org-1",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
fetchDataIntegrityReport,
|
fetchDataIntegrityReport,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import {
|
||||||
|
OverviewAxisNotes,
|
||||||
|
OverviewMetric,
|
||||||
|
OverviewSelectionChips,
|
||||||
|
} from "../../../../common/core/components/overview";
|
||||||
|
|
||||||
type DailyPoint = {
|
type DailyPoint = {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -30,10 +35,8 @@ type DailyPoint = {
|
|||||||
|
|
||||||
type SeriesSummary = {
|
type SeriesSummary = {
|
||||||
key: string;
|
key: string;
|
||||||
tenantLabel: string;
|
|
||||||
clientLabel: string;
|
clientLabel: string;
|
||||||
loginRequests: number;
|
loginRequests: number;
|
||||||
otherRequests: number;
|
|
||||||
uniqueSubjects: number;
|
uniqueSubjects: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,30 +62,21 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
|||||||
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||||
const bySeries = new Map<string, SeriesSummary>();
|
const bySeries = new Map<string, SeriesSummary>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = `${row.tenantId}:${row.clientId}`;
|
const key = row.clientId;
|
||||||
const current =
|
const current =
|
||||||
bySeries.get(key) ??
|
bySeries.get(key) ??
|
||||||
({
|
({
|
||||||
key,
|
key,
|
||||||
tenantLabel: row.tenantName || row.tenantId || "-",
|
|
||||||
clientLabel: row.clientName || row.clientId,
|
clientLabel: row.clientName || row.clientId,
|
||||||
loginRequests: 0,
|
loginRequests: 0,
|
||||||
otherRequests: 0,
|
|
||||||
uniqueSubjects: 0,
|
uniqueSubjects: 0,
|
||||||
} satisfies SeriesSummary);
|
} satisfies SeriesSummary);
|
||||||
current.loginRequests += row.loginRequests;
|
current.loginRequests += row.loginRequests;
|
||||||
current.otherRequests += row.otherRequests;
|
current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects);
|
||||||
current.uniqueSubjects = Math.max(
|
|
||||||
current.uniqueSubjects,
|
|
||||||
row.uniqueSubjects,
|
|
||||||
);
|
|
||||||
bySeries.set(key, current);
|
bySeries.set(key, current);
|
||||||
}
|
}
|
||||||
return Array.from(bySeries.values())
|
return Array.from(bySeries.values())
|
||||||
.sort(
|
.sort((a, b) => b.loginRequests - a.loginRequests)
|
||||||
(a, b) =>
|
|
||||||
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
|
||||||
)
|
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,24 +131,6 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
|||||||
return `${parts.monthText}.${parts.dayText}`;
|
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 formatOverviewDateTime(value?: string) {
|
function formatOverviewDateTime(value?: string) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -168,11 +144,11 @@ function formatOverviewDateTime(value?: string) {
|
|||||||
function integrityStatusText(status: DataIntegrityStatus) {
|
function integrityStatusText(status: DataIntegrityStatus) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "pass":
|
case "pass":
|
||||||
return "정상";
|
return t("ui.admin.integrity.status.pass", "정상");
|
||||||
case "warning":
|
case "warning":
|
||||||
return "주의";
|
return t("ui.admin.integrity.status.warning", "주의");
|
||||||
default:
|
default:
|
||||||
return "실패";
|
return t("ui.admin.integrity.status.fail", "실패");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +175,12 @@ function IntegrityOverviewSummary() {
|
|||||||
<section className="border-t border-border/60 pt-4">
|
<section className="border-t border-border/60 pt-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<AlertTriangle size={16} />
|
<AlertTriangle size={16} />
|
||||||
<span>정합성 최종 검증 결과를 불러오지 못했습니다.</span>
|
<span>
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.fetch_error",
|
||||||
|
"정합성 최종 검증 결과를 불러오지 못했습니다.",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -218,7 +199,12 @@ function IntegrityOverviewSummary() {
|
|||||||
) : (
|
) : (
|
||||||
<AlertTriangle size={18} className="text-amber-600" />
|
<AlertTriangle size={18} className="text-amber-600" />
|
||||||
)}
|
)}
|
||||||
<h3 className="text-base font-semibold">정합성 최종 검증</h3>
|
<h3 className="text-base font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.summary.title",
|
||||||
|
"정합성 최종 검증",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
<span
|
<span
|
||||||
@@ -226,7 +212,13 @@ function IntegrityOverviewSummary() {
|
|||||||
>
|
>
|
||||||
{integrityStatusText(data.status)}
|
{integrityStatusText(data.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">실패 {data.summary.failures}건</span>
|
<span className="tabular-nums">
|
||||||
|
{t(
|
||||||
|
"ui.admin.integrity.summary.failures_text",
|
||||||
|
"실패 {{count}}건",
|
||||||
|
{ count: data.summary.failures },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{formatOverviewDateTime(data.checkedAt)}
|
{formatOverviewDateTime(data.checkedAt)}
|
||||||
</span>
|
</span>
|
||||||
@@ -238,7 +230,7 @@ function IntegrityOverviewSummary() {
|
|||||||
key={section.key}
|
key={section.key}
|
||||||
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
|
||||||
>
|
>
|
||||||
<span>{section.label}</span>
|
<span>{integritySectionLabel(section.key, section.label)}</span>
|
||||||
<span
|
<span
|
||||||
className={`font-medium ${integrityStatusClass(section.status)}`}
|
className={`font-medium ${integrityStatusClass(section.status)}`}
|
||||||
>
|
>
|
||||||
@@ -251,12 +243,25 @@ function IntegrityOverviewSummary() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function integritySectionLabel(key: string, fallback: string) {
|
||||||
|
switch (key) {
|
||||||
|
case "tenant_integrity":
|
||||||
|
return t("ui.admin.integrity.section.tenant_integrity", fallback);
|
||||||
|
case "user_integrity":
|
||||||
|
return t("ui.admin.integrity.section.user_integrity", fallback);
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function RPUsageMixedChart({
|
function RPUsageMixedChart({
|
||||||
rows,
|
rows,
|
||||||
|
periodControls,
|
||||||
filters,
|
filters,
|
||||||
period,
|
period,
|
||||||
}: {
|
}: {
|
||||||
rows: RPUsageDailyMetric[];
|
rows: RPUsageDailyMetric[];
|
||||||
|
periodControls: ReactNode;
|
||||||
filters: ReactNode;
|
filters: ReactNode;
|
||||||
period: RPUsagePeriod;
|
period: RPUsagePeriod;
|
||||||
}) {
|
}) {
|
||||||
@@ -288,152 +293,144 @@ function RPUsageMixedChart({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 size={18} className="text-primary" />
|
<BarChart3 size={18} className="text-primary" />
|
||||||
<h3 className="text-base font-semibold">
|
<div className="space-y-1">
|
||||||
회사별 앱별 로그인요청/기타 요청 현황
|
<h3 className="text-base font-semibold">
|
||||||
</h3>
|
{t(
|
||||||
|
"ui.admin.overview.chart.title",
|
||||||
|
"회사별 앱별 로그인 요청 현황",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.overview.chart.description",
|
||||||
|
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{filters}
|
{periodControls}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filters}
|
||||||
|
|
||||||
{daily.length === 0 ? (
|
{daily.length === 0 ? (
|
||||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||||
표시할 RP 이용 집계가 없습니다.
|
표시할 RP 이용 집계가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-3">
|
||||||
<svg
|
<div className="overflow-x-auto">
|
||||||
role="img"
|
<svg
|
||||||
aria-label="일 단위 RP 요청 현황"
|
role="img"
|
||||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
aria-label="일 단위 RP 요청 현황"
|
||||||
className="h-[235px] min-w-[720px] w-full"
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
>
|
className="h-[235px] min-w-[720px] w-full"
|
||||||
<title>일 단위 RP 요청 현황</title>
|
>
|
||||||
<g transform="translate(510 10)">
|
<title>일 단위 RP 요청 현황</title>
|
||||||
<rect
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||||
x="0"
|
const gridY = padTop + innerHeight * ratio;
|
||||||
y="3"
|
const label = Math.round(maxValue * (1 - ratio));
|
||||||
width="10"
|
return (
|
||||||
height="10"
|
<g key={ratio}>
|
||||||
rx="2"
|
<line
|
||||||
className="fill-sky-500/70"
|
x1={padX}
|
||||||
/>
|
x2={chartWidth - padX}
|
||||||
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
y1={gridY}
|
||||||
기타 요청
|
y2={gridY}
|
||||||
</text>
|
stroke="currentColor"
|
||||||
<line
|
className="text-border"
|
||||||
x1="78"
|
strokeWidth="1"
|
||||||
x2="98"
|
/>
|
||||||
y1="8"
|
<text
|
||||||
y2="8"
|
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"
|
className="stroke-emerald-500"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<text
|
{daily.map((point, index) => (
|
||||||
x="104"
|
<circle
|
||||||
y="12"
|
key={`${point.date}-login`}
|
||||||
className="fill-muted-foreground text-[11px]"
|
cx={x(index)}
|
||||||
>
|
cy={y(point.loginRequests)}
|
||||||
로그인 요청
|
r="4"
|
||||||
</text>
|
className="fill-emerald-500 stroke-background"
|
||||||
</g>
|
strokeWidth="2"
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
/>
|
||||||
const gridY = padTop + innerHeight * ratio;
|
))}
|
||||||
const label = Math.round(maxValue * (1 - ratio));
|
</svg>
|
||||||
return (
|
</div>
|
||||||
<g key={ratio}>
|
<OverviewAxisNotes
|
||||||
<line
|
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
|
||||||
x1={padX}
|
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{series.length > 0 && (
|
{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">
|
<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) => (
|
{series.map((item) => (
|
||||||
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
<div key={item.key} className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className="truncate font-medium">{item.clientLabel}</span>
|
<span className="font-medium">{item.clientLabel}</span>
|
||||||
<span className="truncate text-muted-foreground">
|
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
{item.tenantLabel}
|
{t(
|
||||||
</span>
|
"ui.common.chart.series_summary.login_users",
|
||||||
<span className="ml-auto whitespace-nowrap tabular-nums">
|
"로그인 {{login}} / 사용자 {{subjects}}",
|
||||||
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
{
|
||||||
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
login: item.loginRequests.toLocaleString(),
|
||||||
{item.uniqueSubjects.toLocaleString()}
|
subjects: item.uniqueSubjects.toLocaleString(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GlobalOverviewPage() {
|
function GlobalOverviewPage() {
|
||||||
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
|
||||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
|
||||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||||
const statsQuery = useQuery({
|
const statsQuery = useQuery({
|
||||||
queryKey: ["admin-overview-stats"],
|
queryKey: ["admin-overview-stats"],
|
||||||
@@ -446,78 +443,72 @@ function GlobalOverviewPage() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const tenantOptions = useMemo(() => {
|
const tenantOptions = useMemo(() => {
|
||||||
const term = tenantSearch.trim().toLowerCase();
|
return (tenantsQuery.data?.items ?? []).filter(
|
||||||
return (tenantsQuery.data?.items ?? [])
|
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||||
.filter(
|
);
|
||||||
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
}, [tenantsQuery.data?.items]);
|
||||||
)
|
|
||||||
.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({
|
const usageQuery = useQuery({
|
||||||
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
queryKey: ["admin-rp-usage-daily", usageDays, period],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchAdminRPUsageDaily({
|
fetchAdminRPUsageDaily({
|
||||||
days: usageDays,
|
days: usageDays,
|
||||||
period,
|
period,
|
||||||
tenantId: selectedTenantId || undefined,
|
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const stats = statsQuery.data;
|
const stats = statsQuery.data;
|
||||||
const visibleTenantCount = tenantsQuery.data?.items.length;
|
const visibleTenantCount = tenantsQuery.data?.items.length;
|
||||||
const usageRows = usageQuery.data?.items ?? [];
|
const usageRows = usageQuery.data?.items ?? [];
|
||||||
|
const filteredUsageRows = useMemo(() => {
|
||||||
|
if (selectedTenantIds.length === 0) {
|
||||||
|
return usageRows;
|
||||||
|
}
|
||||||
|
const selectedSet = new Set(selectedTenantIds);
|
||||||
|
return usageRows.filter((row) => selectedSet.has(row.tenantId));
|
||||||
|
}, [selectedTenantIds, usageRows]);
|
||||||
const metric = (value: number | undefined) =>
|
const metric = (value: number | undefined) =>
|
||||||
value === undefined ? "-" : value.toLocaleString();
|
value === undefined ? "-" : value.toLocaleString();
|
||||||
|
const periodControls = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
const chartFilters = (
|
const chartFilters = (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div>
|
||||||
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
<OverviewSelectionChips
|
||||||
{[
|
allLabel="전체"
|
||||||
["day", "일"],
|
options={tenantOptions.map((tenant) => ({
|
||||||
["week", "주"],
|
id: tenant.id,
|
||||||
["month", "월"],
|
label: `${tenant.name} (${tenant.slug})`,
|
||||||
].map(([value, label]) => (
|
}))}
|
||||||
<button
|
selectedIds={selectedTenantIds}
|
||||||
key={value}
|
onSelectAll={() => setSelectedTenantIds([])}
|
||||||
type="button"
|
onToggle={(tenantId) => {
|
||||||
aria-pressed={period === value}
|
setSelectedTenantIds((current) =>
|
||||||
onClick={() => setPeriod(value as RPUsagePeriod)}
|
current.includes(tenantId)
|
||||||
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
? current.filter((item) => item !== tenantId)
|
||||||
period === value
|
: [...current, tenantId],
|
||||||
? "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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -526,7 +517,7 @@ function GlobalOverviewPage() {
|
|||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
{t("ui.admin.overview.title", "Dashboard")}
|
{t("ui.common.overview.title", "운영 현황")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -579,11 +570,26 @@ function GlobalOverviewPage() {
|
|||||||
{usageQuery.isError ? (
|
{usageQuery.isError ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold">
|
<div className="flex items-center gap-2">
|
||||||
회사별 앱별 로그인요청/기타 요청 현황
|
<BarChart3 size={18} className="text-primary" />
|
||||||
</h3>
|
<div className="space-y-1">
|
||||||
{chartFilters}
|
<h3 className="text-base font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.admin.overview.chart.title",
|
||||||
|
"회사별 앱별 로그인 요청 현황",
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.overview.chart.description",
|
||||||
|
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{periodControls}
|
||||||
</div>
|
</div>
|
||||||
|
{chartFilters}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||||
@@ -592,7 +598,8 @@ function GlobalOverviewPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<RPUsageMixedChart
|
<RPUsageMixedChart
|
||||||
rows={usageRows}
|
rows={filteredUsageRows}
|
||||||
|
periodControls={periodControls}
|
||||||
filters={chartFilters}
|
filters={chartFilters}
|
||||||
period={period}
|
period={period}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ import_error = "An error occurred during organization chart import."
|
|||||||
import_success = "Organization chart imported successfully."
|
import_success = "Organization chart imported successfully."
|
||||||
|
|
||||||
[msg.admin.overview]
|
[msg.admin.overview]
|
||||||
description = "Description"
|
description = "Check shared metrics and policy status across all tenants in one place."
|
||||||
idp_primary = "IDP: Ory primary"
|
idp_primary = "IDP: Ory primary"
|
||||||
|
|
||||||
[msg.admin.overview.playbook]
|
[msg.admin.overview.playbook]
|
||||||
@@ -862,6 +862,7 @@ subtitle = "Manage your organization"
|
|||||||
kicker = "System"
|
kicker = "System"
|
||||||
loading = "Loading data integrity report..."
|
loading = "Loading data integrity report..."
|
||||||
title = "Data Integrity Check"
|
title = "Data Integrity Check"
|
||||||
|
fetch_error = "Unable to load the final integrity check result."
|
||||||
|
|
||||||
[ui.admin.integrity.forbidden]
|
[ui.admin.integrity.forbidden]
|
||||||
title = "Access denied"
|
title = "Access denied"
|
||||||
@@ -891,7 +892,9 @@ warning = "Warning"
|
|||||||
[ui.admin.integrity.summary]
|
[ui.admin.integrity.summary]
|
||||||
checked_at = "Checked at"
|
checked_at = "Checked at"
|
||||||
failures = "Failures"
|
failures = "Failures"
|
||||||
|
failures_text = "Failures {{count}}"
|
||||||
passed = "Passed"
|
passed = "Passed"
|
||||||
|
title = "Final integrity check"
|
||||||
total_checks = "Checks"
|
total_checks = "Checks"
|
||||||
|
|
||||||
[ui.admin.integrity.table]
|
[ui.admin.integrity.table]
|
||||||
@@ -903,6 +906,10 @@ select_item = "Select {{loginId}}"
|
|||||||
tenant = "Tenant"
|
tenant = "Tenant"
|
||||||
user = "User"
|
user = "User"
|
||||||
|
|
||||||
|
[ui.admin.integrity.section]
|
||||||
|
tenant_integrity = "Tenant integrity"
|
||||||
|
user_integrity = "User integrity"
|
||||||
|
|
||||||
[ui.admin.nav]
|
[ui.admin.nav]
|
||||||
org_chart = "Org Chart"
|
org_chart = "Org Chart"
|
||||||
api_keys = "API Keys"
|
api_keys = "API Keys"
|
||||||
@@ -926,7 +933,10 @@ start_import = "Start Import"
|
|||||||
|
|
||||||
[ui.admin.overview]
|
[ui.admin.overview]
|
||||||
kicker = "Global Overview"
|
kicker = "Global Overview"
|
||||||
title = "Tenant-independent control plane"
|
|
||||||
|
[ui.admin.overview.chart]
|
||||||
|
description = "Check the graph by all or selected organizations."
|
||||||
|
title = "Login request status by company and app"
|
||||||
|
|
||||||
[ui.admin.overview.playbook]
|
[ui.admin.overview.playbook]
|
||||||
title = "Admin playbook"
|
title = "Admin playbook"
|
||||||
|
|||||||
@@ -864,6 +864,7 @@ subtitle = "Manage your organization"
|
|||||||
kicker = "시스템"
|
kicker = "시스템"
|
||||||
loading = "불러오는 중"
|
loading = "불러오는 중"
|
||||||
title = "데이터 정합성 검증"
|
title = "데이터 정합성 검증"
|
||||||
|
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||||
|
|
||||||
[ui.admin.integrity.forbidden]
|
[ui.admin.integrity.forbidden]
|
||||||
title = "접근 권한이 없습니다"
|
title = "접근 권한이 없습니다"
|
||||||
@@ -893,7 +894,9 @@ warning = "주의"
|
|||||||
[ui.admin.integrity.summary]
|
[ui.admin.integrity.summary]
|
||||||
checked_at = "검사 시각"
|
checked_at = "검사 시각"
|
||||||
failures = "실패 건수"
|
failures = "실패 건수"
|
||||||
|
failures_text = "실패 {{count}}건"
|
||||||
passed = "정상"
|
passed = "정상"
|
||||||
|
title = "정합성 최종 검증"
|
||||||
total_checks = "검사 항목"
|
total_checks = "검사 항목"
|
||||||
|
|
||||||
[ui.admin.integrity.table]
|
[ui.admin.integrity.table]
|
||||||
@@ -905,6 +908,10 @@ select_item = "{{loginId}} 선택"
|
|||||||
tenant = "테넌트"
|
tenant = "테넌트"
|
||||||
user = "사용자"
|
user = "사용자"
|
||||||
|
|
||||||
|
[ui.admin.integrity.section]
|
||||||
|
tenant_integrity = "테넌트 정합성"
|
||||||
|
user_integrity = "사용자 정합성"
|
||||||
|
|
||||||
[ui.admin.nav]
|
[ui.admin.nav]
|
||||||
org_chart = "조직도"
|
org_chart = "조직도"
|
||||||
api_keys = "API 키"
|
api_keys = "API 키"
|
||||||
@@ -928,7 +935,10 @@ start_import = "임포트 시작"
|
|||||||
|
|
||||||
[ui.admin.overview]
|
[ui.admin.overview]
|
||||||
kicker = "Global Overview"
|
kicker = "Global Overview"
|
||||||
title = "Tenant-independent control plane"
|
|
||||||
|
[ui.admin.overview.chart]
|
||||||
|
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||||
|
title = "회사별 앱별 로그인 요청 현황"
|
||||||
|
|
||||||
[ui.admin.overview.playbook]
|
[ui.admin.overview.playbook]
|
||||||
title = "Admin playbook"
|
title = "Admin playbook"
|
||||||
|
|||||||
@@ -877,6 +877,7 @@ subtitle = ""
|
|||||||
kicker = ""
|
kicker = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
fetch_error = ""
|
||||||
|
|
||||||
[ui.admin.integrity.forbidden]
|
[ui.admin.integrity.forbidden]
|
||||||
title = ""
|
title = ""
|
||||||
@@ -906,7 +907,9 @@ warning = ""
|
|||||||
[ui.admin.integrity.summary]
|
[ui.admin.integrity.summary]
|
||||||
checked_at = ""
|
checked_at = ""
|
||||||
failures = ""
|
failures = ""
|
||||||
|
failures_text = ""
|
||||||
passed = ""
|
passed = ""
|
||||||
|
title = ""
|
||||||
total_checks = ""
|
total_checks = ""
|
||||||
|
|
||||||
[ui.admin.integrity.table]
|
[ui.admin.integrity.table]
|
||||||
@@ -918,6 +921,10 @@ select_item = ""
|
|||||||
tenant = ""
|
tenant = ""
|
||||||
user = ""
|
user = ""
|
||||||
|
|
||||||
|
[ui.admin.integrity.section]
|
||||||
|
tenant_integrity = ""
|
||||||
|
user_integrity = ""
|
||||||
|
|
||||||
[ui.admin.nav]
|
[ui.admin.nav]
|
||||||
org_chart = ""
|
org_chart = ""
|
||||||
api_keys = ""
|
api_keys = ""
|
||||||
@@ -941,6 +948,9 @@ start_import = ""
|
|||||||
|
|
||||||
[ui.admin.overview]
|
[ui.admin.overview]
|
||||||
kicker = ""
|
kicker = ""
|
||||||
|
|
||||||
|
[ui.admin.overview.chart]
|
||||||
|
description = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.overview.playbook]
|
[ui.admin.overview.playbook]
|
||||||
|
|||||||
Reference in New Issue
Block a user