1
0
forked from baron/baron-sso

개요 페이지 공통 컴포넌트 및 문구 적용

This commit is contained in:
2026-05-14 17:01:35 +09:00
parent c9bf16cf8e
commit c0894eeb8a
5 changed files with 262 additions and 229 deletions

View File

@@ -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();

View File

@@ -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}
/> />

View File

@@ -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"

View File

@@ -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"

View File

@@ -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]