diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx index 15a2bd36..e4e19203 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -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("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" }, - }); + fireEvent.click( + screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }), + ); await waitFor(() => { expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({ days: 90, period: "month", - tenantId: "org-1", }); }); expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument(); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 28431105..58397d25 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -21,6 +21,11 @@ import { fetchDataIntegrityReport, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; +import { + OverviewAxisNotes, + OverviewMetric, + OverviewSelectionChips, +} from "../../../../common/core/components/overview"; type DailyPoint = { date: string; @@ -30,10 +35,8 @@ type DailyPoint = { type SeriesSummary = { key: string; - tenantLabel: string; clientLabel: string; loginRequests: number; - otherRequests: number; uniqueSubjects: number; }; @@ -59,30 +62,21 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] { function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { const bySeries = new Map(); for (const row of rows) { - const key = `${row.tenantId}:${row.clientId}`; + const key = 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, - ); + 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), - ) + .sort((a, b) => b.loginRequests - a.loginRequests) .slice(0, 5); } @@ -137,24 +131,6 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) { return `${parts.monthText}.${parts.dayText}`; } -function OverviewMetric({ - icon, - label, - value, -}: { - icon: ReactNode; - label: string; - value: string; -}) { - return ( - - {icon} - {label} - {value} - - ); -} - function formatOverviewDateTime(value?: string) { if (!value) return "-"; const date = new Date(value); @@ -168,11 +144,11 @@ function formatOverviewDateTime(value?: string) { function integrityStatusText(status: DataIntegrityStatus) { switch (status) { case "pass": - return "정상"; + return t("ui.admin.integrity.status.pass", "정상"); case "warning": - return "주의"; + return t("ui.admin.integrity.status.warning", "주의"); default: - return "실패"; + return t("ui.admin.integrity.status.fail", "실패"); } } @@ -199,7 +175,12 @@ function IntegrityOverviewSummary() {
- 정합성 최종 검증 결과를 불러오지 못했습니다. + + {t( + "ui.admin.integrity.fetch_error", + "정합성 최종 검증 결과를 불러오지 못했습니다.", + )} +
); @@ -218,7 +199,12 @@ function IntegrityOverviewSummary() { ) : ( )} -

정합성 최종 검증

+

+ {t( + "ui.admin.integrity.summary.title", + "정합성 최종 검증", + )} +

{integrityStatusText(data.status)} - 실패 {data.summary.failures}건 + + {t( + "ui.admin.integrity.summary.failures_text", + "실패 {{count}}건", + { count: data.summary.failures }, + )} + {formatOverviewDateTime(data.checkedAt)} @@ -238,7 +230,7 @@ function IntegrityOverviewSummary() { key={section.key} className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2" > - {section.label} + {integritySectionLabel(section.key, section.label)} @@ -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({ rows, + periodControls, filters, period, }: { rows: RPUsageDailyMetric[]; + periodControls: ReactNode; filters: ReactNode; period: RPUsagePeriod; }) { @@ -288,152 +293,144 @@ function RPUsageMixedChart({
-

- 회사별 앱별 로그인요청/기타 요청 현황 -

+
+

+ {t( + "ui.admin.overview.chart.title", + "회사별 앱별 로그인 요청 현황", + )} +

+

+ {t( + "ui.admin.overview.chart.description", + "전체 또는 선택한 조직 기준으로 그래프를 확인합니다.", + )} +

+
- {filters} + {periodControls}
+ {filters} + {daily.length === 0 ? (
표시할 RP 이용 집계가 없습니다.
) : ( -
- - 일 단위 RP 요청 현황 - - - - 기타 요청 - - +
+ + 일 단위 RP 요청 현황 + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const gridY = padTop + innerHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => { + const center = x(index); + const otherHeight = + (point.otherRequests / maxValue) * innerHeight; + return ( + + + + {formatPeriodLabel(point.date, period)} + + + ); + })} + - - 로그인 요청 - - - {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { - const gridY = padTop + innerHeight * ratio; - const label = Math.round(maxValue * (1 - ratio)); - return ( - - - - {label} - - - ); - })} - {daily.map((point, index) => { - const center = x(index); - const otherHeight = - (point.otherRequests / maxValue) * innerHeight; - return ( - - - - {formatPeriodLabel(point.date, period)} - - - ); - })} - - {daily.map((point, index) => ( - - ))} - + {daily.map((point, index) => ( + + ))} + +
+
)} {series.length > 0 && (
{series.map((item) => ( -
- {item.clientLabel} - - {item.tenantLabel} - - - 로그인 {item.loginRequests.toLocaleString()} / 기타{" "} - {item.otherRequests.toLocaleString()} / 사용자{" "} - {item.uniqueSubjects.toLocaleString()} +
+ {item.clientLabel} + + {t( + "ui.common.chart.series_summary.login_users", + "로그인 {{login}} / 사용자 {{subjects}}", + { + login: item.loginRequests.toLocaleString(), + subjects: item.uniqueSubjects.toLocaleString(), + }, + )}
))}
)} + ); } function GlobalOverviewPage() { const [period, setPeriod] = useState("day"); - const [tenantSearch, setTenantSearch] = useState(""); - const [selectedTenantId, setSelectedTenantId] = useState(""); + const [selectedTenantIds, setSelectedTenantIds] = useState([]); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const statsQuery = useQuery({ queryKey: ["admin-overview-stats"], @@ -446,78 +443,72 @@ function GlobalOverviewPage() { 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]); + return (tenantsQuery.data?.items ?? []).filter( + (tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION", + ); + }, [tenantsQuery.data?.items]); const usageQuery = useQuery({ - queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId], + queryKey: ["admin-rp-usage-daily", usageDays, period], queryFn: () => fetchAdminRPUsageDaily({ days: usageDays, period, - tenantId: selectedTenantId || undefined, }), retry: false, }); const stats = statsQuery.data; const visibleTenantCount = tenantsQuery.data?.items.length; 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) => value === undefined ? "-" : value.toLocaleString(); + const periodControls = ( +
+ {[ + ["day", t("ui.common.chart.period.day", "일")], + ["week", t("ui.common.chart.period.week", "주")], + ["month", t("ui.common.chart.period.month", "월")], + ].map(([value, label]) => ( + + ))} +
+ ); const chartFilters = ( -
-
- {[ - ["day", "일"], - ["week", "주"], - ["month", "월"], - ].map(([value, label]) => ( - - ))} -
- 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" +
+ ({ + id: tenant.id, + label: `${tenant.name} (${tenant.slug})`, + }))} + selectedIds={selectedTenantIds} + onSelectAll={() => setSelectedTenantIds([])} + onToggle={(tenantId) => { + setSelectedTenantIds((current) => + current.includes(tenantId) + ? current.filter((item) => item !== tenantId) + : [...current, tenantId], + ); + }} /> -
); @@ -526,7 +517,7 @@ function GlobalOverviewPage() {

- {t("ui.admin.overview.title", "Dashboard")} + {t("ui.common.overview.title", "운영 현황")}

{t( @@ -579,11 +570,26 @@ function GlobalOverviewPage() { {usageQuery.isError ? (

-

- 회사별 앱별 로그인요청/기타 요청 현황 -

- {chartFilters} +
+ +
+

+ {t( + "ui.admin.overview.chart.title", + "회사별 앱별 로그인 요청 현황", + )} +

+

+ {t( + "ui.admin.overview.chart.description", + "전체 또는 선택한 조직 기준으로 그래프를 확인합니다.", + )} +

+
+
+ {periodControls}
+ {chartFilters}
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작 이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위 @@ -592,7 +598,8 @@ function GlobalOverviewPage() {
) : ( diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index e762bb3d..ea6d6797 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -186,7 +186,7 @@ import_error = "An error occurred during organization chart import." import_success = "Organization chart imported successfully." [msg.admin.overview] -description = "Description" +description = "Check shared metrics and policy status across all tenants in one place." idp_primary = "IDP: Ory primary" [msg.admin.overview.playbook] @@ -862,6 +862,7 @@ subtitle = "Manage your organization" kicker = "System" loading = "Loading data integrity report..." title = "Data Integrity Check" +fetch_error = "Unable to load the final integrity check result." [ui.admin.integrity.forbidden] title = "Access denied" @@ -891,7 +892,9 @@ warning = "Warning" [ui.admin.integrity.summary] checked_at = "Checked at" failures = "Failures" +failures_text = "Failures {{count}}" passed = "Passed" +title = "Final integrity check" total_checks = "Checks" [ui.admin.integrity.table] @@ -903,6 +906,10 @@ select_item = "Select {{loginId}}" tenant = "Tenant" user = "User" +[ui.admin.integrity.section] +tenant_integrity = "Tenant integrity" +user_integrity = "User integrity" + [ui.admin.nav] org_chart = "Org Chart" api_keys = "API Keys" @@ -926,7 +933,10 @@ start_import = "Start Import" [ui.admin.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] title = "Admin playbook" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 8a1bc6ad..7dc34a81 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -864,6 +864,7 @@ subtitle = "Manage your organization" kicker = "시스템" loading = "불러오는 중" title = "데이터 정합성 검증" +fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다." [ui.admin.integrity.forbidden] title = "접근 권한이 없습니다" @@ -893,7 +894,9 @@ warning = "주의" [ui.admin.integrity.summary] checked_at = "검사 시각" failures = "실패 건수" +failures_text = "실패 {{count}}건" passed = "정상" +title = "정합성 최종 검증" total_checks = "검사 항목" [ui.admin.integrity.table] @@ -905,6 +908,10 @@ select_item = "{{loginId}} 선택" tenant = "테넌트" user = "사용자" +[ui.admin.integrity.section] +tenant_integrity = "테넌트 정합성" +user_integrity = "사용자 정합성" + [ui.admin.nav] org_chart = "조직도" api_keys = "API 키" @@ -928,7 +935,10 @@ start_import = "임포트 시작" [ui.admin.overview] kicker = "Global Overview" -title = "Tenant-independent control plane" + +[ui.admin.overview.chart] +description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다." +title = "회사별 앱별 로그인 요청 현황" [ui.admin.overview.playbook] title = "Admin playbook" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index f0d6aedb..a0cf383a 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -877,6 +877,7 @@ subtitle = "" kicker = "" loading = "" title = "" +fetch_error = "" [ui.admin.integrity.forbidden] title = "" @@ -906,7 +907,9 @@ warning = "" [ui.admin.integrity.summary] checked_at = "" failures = "" +failures_text = "" passed = "" +title = "" total_checks = "" [ui.admin.integrity.table] @@ -918,6 +921,10 @@ select_item = "" tenant = "" user = "" +[ui.admin.integrity.section] +tenant_integrity = "" +user_integrity = "" + [ui.admin.nav] org_chart = "" api_keys = "" @@ -941,6 +948,9 @@ start_import = "" [ui.admin.overview] kicker = "" + +[ui.admin.overview.chart] +description = "" title = "" [ui.admin.overview.playbook]