diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index aed8bb51..f155d9e9 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react"; +import { Filter, Plus, Search, ShieldHalf, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; @@ -44,10 +44,7 @@ import { import { Textarea } from "../../components/ui/textarea"; import { type ClientSummary, - type DevAuditLog, - fetchDevUser, fetchClients, - fetchDevAuditLogs, fetchDeveloperRequestStatus, fetchDevStats, fetchMyTenants, @@ -59,198 +56,10 @@ import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; import { resolveClientCreateAccess } from "./clientCreateAccess"; import { ClientLogo } from "./components/ClientLogo"; -import { - formatAuditDateParts, - formatAuditValue, - parseAuditDetails, - resolveAuditActor, -} from "../../../../common/core/audit"; type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; - -type RecentClientChange = { - eventId: string; - clientId: string; - clientName: string; - actorId: string; - actorName: string; - action: string; - actionLabel: string; - timestamp: string; - detailLabels: Array<{ label: string; value: string }>; -}; - -const recentClientChangesInitialCount = 5; -const recentClientChangesBatchSize = 5; const clientListPreviewCount = 5; -const recentClientActions = new Set([ - "CREATE_CLIENT", - "UPDATE_CLIENT", - "UPDATE_CLIENT_STATUS", - "ROTATE_SECRET", - "ADD_RELATION", - "REMOVE_RELATION", - "DELETE_CLIENT", -]); - -const recentChangeGuideItems = [ - { - titleKey: "ui.dev.clients.recent_changes.guide.create", - titleFallback: "앱 생성", - descriptionKey: "msg.dev.clients.recent_changes.guide.create_desc", - descriptionFallback: - "새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.settings", - titleFallback: "설정 변경", - descriptionKey: "msg.dev.clients.recent_changes.guide.settings_desc", - descriptionFallback: - "앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.status", - titleFallback: "상태 변경", - descriptionKey: "msg.dev.clients.recent_changes.guide.status_desc", - descriptionFallback: "Active / Inactive 전환이 여기에 포함됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.relation", - titleFallback: "관계 변경", - descriptionKey: "msg.dev.clients.recent_changes.guide.relation_desc", - descriptionFallback: "관계 추가와 삭제가 함께 표시됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.secret", - titleFallback: "클라이언트 시크릿 재발급", - descriptionKey: "msg.dev.clients.recent_changes.guide.secret_desc", - descriptionFallback: "시크릿 재발급 이력이 보입니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.delete", - titleFallback: "앱 삭제", - descriptionKey: "msg.dev.clients.recent_changes.guide.delete_desc", - descriptionFallback: "앱 삭제도 최근 변경 이력에 포함됩니다.", - }, -] as const; - -const recentClientFieldLabels: Record = { - name: "이름", - type: "유형", - status: "상태", - scopes: "스코프", - tenant_access_restricted: "테넌트 접근 제한", - allowed_tenants: "허용 테넌트", - id_token_claims: "커스텀 클레임", - token_endpoint_auth_method: "인증 방식", - jwks_uri: "JWKS URI", - backchannel_logout_uri: "Backchannel Logout URI", - backchannel_logout_session_required: "세션 필수", - headless_login_enabled: "헤드리스 로그인", - headless_token_endpoint_auth_method: "헤드리스 인증 방식", - headless_jwks_uri: "헤드리스 JWKS URI", - redirect_uri_count: "Redirect URI 수", - scope_count: "Scope 수", - relation: "관계", - subject: "대상", -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function getRecentClientActionLabel(action: string) { - switch (action) { - case "CREATE_CLIENT": - return "클라이언트 생성"; - case "UPDATE_CLIENT": - return "설정 변경"; - case "UPDATE_CLIENT_STATUS": - return "상태 변경"; - case "ROTATE_SECRET": - return "클라이언트 시크릿 재발급"; - case "ADD_RELATION": - return "관계 추가"; - case "REMOVE_RELATION": - return "관계 삭제"; - case "DELETE_CLIENT": - return "클라이언트 삭제"; - default: - return action; - } -} - -function buildRecentClientChangeDetails( - action: string, - details: ReturnType, -) { - const before = isRecord(details.before) ? details.before : {}; - const after = isRecord(details.after) ? details.after : {}; - - if (action === "ROTATE_SECRET") { - return [{ label: "클라이언트 시크릿", value: "재발급" }]; - } - - if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { - const source = action === "ADD_RELATION" ? after : before; - return [ - ...(source.relation - ? [{ label: "관계", value: formatAuditValue(source.relation) }] - : []), - ...(source.subject - ? [{ label: "대상", value: formatAuditValue(source.subject) }] - : []), - ]; - } - - const keys = Array.from( - new Set([...Object.keys(before), ...Object.keys(after)]), - ); - - const changes = keys - .map((key) => { - const beforeValue = before[key]; - const afterValue = after[key]; - - if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { - if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { - return null; - } - } - - const label = recentClientFieldLabels[key] ?? key; - if (action === "CREATE_CLIENT") { - if (afterValue === undefined) { - return null; - } - return { label, value: formatAuditValue(afterValue) }; - } - if (action === "DELETE_CLIENT") { - if (beforeValue === undefined) { - return null; - } - return { label, value: formatAuditValue(beforeValue) }; - } - if (beforeValue === undefined && afterValue === undefined) { - return null; - } - if (beforeValue === undefined) { - return { label, value: formatAuditValue(afterValue) }; - } - if (afterValue === undefined) { - return { label, value: formatAuditValue(beforeValue) }; - } - return { - label, - value: `${formatAuditValue(beforeValue)} → ${formatAuditValue(afterValue)}`, - }; - }) - .filter((item): item is { label: string; value: string } => Boolean(item)); - - return changes.slice(0, 3); -} - function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -315,11 +124,7 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); - const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] = - useState(false); const [isClientListExpanded, setIsClientListExpanded] = useState(false); - const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = - useState(recentClientChangesInitialCount); const [sortConfig, setSortConfig] = useState | null>({ key: "createdAt", @@ -327,61 +132,6 @@ function ClientsPage() { }); const clients = data?.items || []; - const visibleClientIds = useMemo( - () => clients.map((client) => client.id).filter(Boolean), - [clients], - ); - - const { data: recentAuditData, isLoading: isLoadingRecentAudit } = useQuery({ - queryKey: ["dev-audit-logs", "clients-recent", visibleClientIds.join("|")], - queryFn: async () => { - const globalLogs = await fetchDevAuditLogs(50); - if (globalLogs.items.length > 0 || profileRole === "super_admin") { - return globalLogs; - } - - if (visibleClientIds.length === 0) { - return globalLogs; - } - - const perClientLogs = await Promise.all( - visibleClientIds.slice(0, 20).map(async (clientId) => { - try { - const result = await fetchDevAuditLogs(5, undefined, { - client_id: clientId, - }); - return result.items; - } catch { - return []; - } - }), - ); - - const merged = perClientLogs - .flat() - .filter( - (item, index, self) => - self.findIndex( - (candidate) => candidate.event_id === item.event_id, - ) === index, - ) - .sort( - (left, right) => - new Date(right.timestamp).getTime() - - new Date(left.timestamp).getTime(), - ) - .slice(0, 50); - - return { - items: merged, - limit: 50, - cursor: globalLogs.cursor, - next_cursor: globalLogs.next_cursor, - }; - }, - enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole), - retry: false, - }); const clientSortResolvers = useMemo< SortResolverMap @@ -488,105 +238,9 @@ function ClientsPage() { }, ]; - const recentClientChanges = useMemo(() => { - const clientNameById = new Map( - clients.map((client) => [client.id, client.name || client.id]), - ); - return (recentAuditData?.items || []) - .map((item: DevAuditLog) => { - const details = parseAuditDetails(item.details); - const action = details.action || ""; - const clientId = String(details.target_id || ""); - if (!recentClientActions.has(action) || !clientId) { - return null; - } - return { - eventId: item.event_id, - clientId, - clientName: clientNameById.get(clientId) || clientId, - actorId: resolveAuditActor(item, details), - actorName: "", - action, - actionLabel: getRecentClientActionLabel(action), - timestamp: item.timestamp, - detailLabels: buildRecentClientChangeDetails(action, details), - }; - }) - .filter((item): item is RecentClientChange => Boolean(item)) - .sort( - (left, right) => - new Date(right.timestamp).getTime() - - new Date(left.timestamp).getTime(), - ); - }, [clients, recentAuditData?.items]); - - const recentClientActorIds = useMemo(() => { - return Array.from( - new Set( - recentClientChanges - .map((item) => item.actorId.trim()) - .filter((actorId) => actorId && actorId !== "-"), - ), - ); - }, [recentClientChanges]); - - const { data: recentClientActors } = useQuery({ - queryKey: ["recent-client-actors", recentClientActorIds], - queryFn: async () => { - const entries = await Promise.all( - recentClientActorIds.map(async (actorId) => { - try { - const user = await fetchDevUser(actorId); - return [actorId, user.name || actorId] as const; - } catch { - return [actorId, actorId] as const; - } - }), - ); - return Object.fromEntries(entries); - }, - enabled: recentClientActorIds.length > 0, - }); - - const recentClientChangesWithActors = useMemo(() => { - return recentClientChanges.map((item) => ({ - ...item, - actorName: recentClientActors?.[item.actorId] || item.actorId, - })); - }, [recentClientActors, recentClientChanges]); - - const recentChangedClientCount = useMemo(() => { - return new Set(recentClientChangesWithActors.map((item) => item.clientId)) - .size; - }, [recentClientChangesWithActors]); - - const visibleRecentClientChanges = useMemo(() => { - return recentClientChangesWithActors.slice( - 0, - visibleRecentClientChangesCount, - ); - }, [recentClientChangesWithActors, visibleRecentClientChangesCount]); - - const hasMoreRecentClientChanges = - recentClientChangesWithActors.length > visibleRecentClientChanges.length; - - useEffect(() => { - if ( - visibleRecentClientChangesCount > recentClientChangesWithActors.length - ) { - setVisibleRecentClientChangesCount( - Math.max( - recentClientChangesInitialCount, - recentClientChangesWithActors.length, - ), - ); - } - }, [recentClientChangesWithActors.length, visibleRecentClientChangesCount]); - const isLoading = isLoadingClients || isLoadingStats || - isLoadingRecentAudit || isLoadingRequest || (hasAccessToken && !profileRole && isLoadingMe); @@ -1084,164 +738,6 @@ function ClientsPage() { - - -
-
- - {t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")} - - -
- - {t( - "msg.dev.clients.recent_changes.description", - "총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.", - { count: recentChangedClientCount }, - )} - -

- {t( - "msg.dev.clients.recent_changes.permission_note", - "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.", - )} -

- {isRecentChangesGuideOpen && ( -
-

- {t( - "ui.dev.clients.recent_changes.guide_title", - "최근 변경 항목 안내", - )} -

-
- {recentChangeGuideItems.map((item) => ( -
-

- {t(item.titleKey, item.titleFallback)} -

-

- {t(item.descriptionKey, item.descriptionFallback)} -

-
- ))} -

- {t( - "msg.dev.clients.recent_changes.guide.audit_only", - "동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.", - )} -

-
-
- )} -
- -
- - {visibleRecentClientChanges.length === 0 ? ( -
- {t( - "msg.dev.clients.recent_changes.empty", - "최근 변경 로그가 아직 없습니다.", - )} -
- ) : ( - visibleRecentClientChanges.map((item) => { - const { date, time } = formatAuditDateParts(item.timestamp); - return ( -
-
-
- - {item.clientName} - - - {item.clientId} - - {item.actorName} - - {item.actorId} - - {item.actionLabel} -
-
- {item.detailLabels.length > 0 ? ( - item.detailLabels.map((detail) => ( - - {detail.label}: {detail.value} - - )) - ) : ( - - {t( - "msg.dev.clients.recent_changes.no_detail", - "변경 항목을 확인할 수 없습니다.", - )} - - )} -
-

- {date} {time} -

-
- -
- ); - }) - )} - {hasMoreRecentClientChanges ? ( -
- -
- ) : null} -
-
- setIsRequestModalOpen(false)} diff --git a/devfront/src/features/overview/GlobalOverviewPage.tsx b/devfront/src/features/overview/GlobalOverviewPage.tsx index 44925543..f60c244d 100644 --- a/devfront/src/features/overview/GlobalOverviewPage.tsx +++ b/devfront/src/features/overview/GlobalOverviewPage.tsx @@ -4,31 +4,42 @@ import { Activity, AlertTriangle, CheckCircle2, + Clock3, + ChevronDown, Layers3, LayoutDashboard, ShieldCheck, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { OverviewAxisNotes, OverviewMetric, OverviewSelectionChips, } from "../../../../common/core/components/overview"; +import { formatAuditDateParts } from "../../../../common/core/audit"; import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { type ClientSummary, fetchClients, + fetchDevAuditLogs, fetchDevRPUsageDaily, fetchDevStats, + fetchDevUser, type RPUsageDailyMetric, type RPUsagePeriod, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; +import { + buildRecentClientChanges, + type RecentClientChange, +} from "./recentClientChanges"; type ClientDistribution = { activeClients: number; @@ -69,6 +80,28 @@ type UsageChartPalette = { point: string; }; +const deletedRecentChangeFilterId = "__deleted_recent_clients__"; + +type RecentChangePoint = { + date: string; + changeCount: number; + uniqueActors: number; +}; + +type RecentChangeSeriesSummary = { + key: string; + clientLabel: string; + changeCount: number; + uniqueActors: number; +}; + +type RecentChangeSeries = { + key: string; + clientLabel: string; + color: UsageChartPalette; + points: RecentChangePoint[]; +}; + const usageChartPalettes: UsageChartPalette[] = [ { bar: "#7dd3fc", line: "#10b981", point: "#059669" }, { bar: "#f9a8d4", line: "#f97316", point: "#ea580c" }, @@ -154,6 +187,24 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { ); } +function toPeriodBucket(date: string, period: RPUsagePeriod) { + const parts = parseDateParts(date); + if (!parts) { + return date; + } + if (period === "month") { + return `${parts.year}-${parts.monthText}-01`; + } + if (period === "week") { + const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day); + const weekYear = weekThursday.getUTCFullYear(); + const weekMonth = String(weekThursday.getUTCMonth() + 1).padStart(2, "0"); + const weekDay = String(weekThursday.getUTCDate()).padStart(2, "0"); + return `${weekYear}-${weekMonth}-${weekDay}`; + } + return date; +} + function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] { const dates = summarizeDaily(rows).map((point) => point.date); const byClient = new Map< @@ -268,6 +319,119 @@ function formatMetric(value: number | undefined) { return value === undefined ? "-" : value.toLocaleString(); } +function summarizeRecentChanges( + items: RecentClientChange[], + period: RPUsagePeriod, +): RecentChangePoint[] { + const byDate = new Map }>(); + for (const item of items) { + const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); + const current = byDate.get(bucket) ?? { + changeCount: 0, + actors: new Set(), + }; + current.changeCount += 1; + if (item.actorId && item.actorId !== "-") { + current.actors.add(item.actorId); + } + byDate.set(bucket, current); + } + + return Array.from(byDate.entries()) + .map(([date, current]) => ({ + date, + changeCount: current.changeCount, + uniqueActors: current.actors.size, + })) + .sort((left, right) => left.date.localeCompare(right.date)); +} + +function summarizeRecentChangeSeries( + items: RecentClientChange[], +): RecentChangeSeriesSummary[] { + const bySeries = new Map< + string, + { clientLabel: string; changeCount: number; actors: Set } + >(); + for (const item of items) { + const current = bySeries.get(item.clientId) ?? { + clientLabel: item.clientName, + changeCount: 0, + actors: new Set(), + }; + current.changeCount += 1; + if (item.actorId && item.actorId !== "-") { + current.actors.add(item.actorId); + } + bySeries.set(item.clientId, current); + } + + return Array.from(bySeries.entries()) + .map(([key, current]) => ({ + key, + clientLabel: current.clientLabel, + changeCount: current.changeCount, + uniqueActors: current.actors.size, + })) + .sort((left, right) => right.changeCount - left.changeCount); +} + +function buildRecentChangeSeries( + items: RecentClientChange[], + period: RPUsagePeriod, +): RecentChangeSeries[] { + const dates = summarizeRecentChanges(items, period).map((point) => point.date); + const byClient = new Map< + string, + { + clientLabel: string; + byDate: Map; + actorIdsByDate: Map>; + } + >(); + + for (const item of items) { + const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); + const current = byClient.get(item.clientId) ?? { + clientLabel: item.clientName, + byDate: new Map(), + actorIdsByDate: new Map>(), + }; + const point = current.byDate.get(bucket) ?? { + date: bucket, + changeCount: 0, + uniqueActors: 0, + }; + point.changeCount += 1; + const actorIds = current.actorIdsByDate.get(bucket) ?? new Set(); + if (item.actorId && item.actorId !== "-") { + actorIds.add(item.actorId); + } + point.uniqueActors = actorIds.size; + current.byDate.set(bucket, point); + current.actorIdsByDate.set(bucket, actorIds); + byClient.set(item.clientId, current); + } + + return Array.from(byClient.entries()) + .sort((left, right) => + left[1].clientLabel.localeCompare(right[1].clientLabel), + ) + .map(([clientId, entry], index) => ({ + key: clientId, + clientLabel: entry.clientLabel, + color: usageChartPalettes[index % usageChartPalettes.length], + points: dates.map( + (date) => + entry.byDate.get(date) ?? { + date, + changeCount: 0, + uniqueActors: 0, + }, + ), + })); +} + function RPUsageMixedChart({ period, rows, @@ -475,6 +639,215 @@ function RPUsageMixedChart({ ); } +function RecentClientChangesChart({ + items, + period, + multiLineSeries, +}: { + items: RecentClientChange[]; + period: RPUsagePeriod; + multiLineSeries?: RecentChangeSeries[]; +}) { + const daily = summarizeRecentChanges(items, period); + const series = summarizeRecentChangeSeries(items); + const topSeries = series.slice(0, 5); + const seriesByKey = new Map(series.map((item) => [item.key, item])); + const chartWidth = 720; + const chartHeight = 230; + const padX = 48; + const padTop = 40; + const padBottom = 34; + const innerWidth = chartWidth - padX * 2; + const innerHeight = chartHeight - padTop - padBottom; + const maxValue = Math.max(1, ...daily.map((point) => point.changeCount)); + const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth; + const y = (value: number) => + padTop + innerHeight - (value / maxValue) * innerHeight; + const x = (index: number) => padX + slot * index + slot / 2; + const linePoints = daily + .map((point, index) => `${x(index)},${y(point.changeCount)}`) + .join(" "); + const multiLinePoints = multiLineSeries?.map((seriesItem) => ({ + ...seriesItem, + pointsAttr: seriesItem.points + .map((point, index) => `${x(index)},${y(point.changeCount)}`) + .join(" "), + })); + + if (daily.length === 0) { + return ( +
+ {t( + "msg.dev.dashboard.recent_changes.empty", + "최근 변경 로그가 아직 없습니다.", + )} +
+ ); + } + + return ( +
+
+ + + {t("ui.dev.dashboard.recent_changes.aria", "최근 변경된 앱 현황")} + + {[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) => ( + + + {formatPeriodLabel(point.date, period)} + + + ))} + {!multiLinePoints || multiLinePoints.length === 0 ? ( + <> + + {daily.map((point, index) => ( + + ))} + + ) : ( + multiLinePoints.map((seriesItem) => ( + + + {seriesItem.points.map((point, index) => ( + + ))} + + )) + )} + +
+ + + + {multiLinePoints && multiLinePoints.length > 0 ? ( +
+ {multiLinePoints.map((item) => { + const seriesItem = seriesByKey.get(item.key); + return ( +
+ + {item.clientLabel} + {seriesItem ? ( + + {t( + "ui.dev.dashboard.recent_changes.series", + "변경 {{changes}} / 작업자 {{actors}}", + { + changes: seriesItem.changeCount.toLocaleString(), + actors: seriesItem.uniqueActors.toLocaleString(), + }, + )} + + ) : null} +
+ ); + })} +
+ ) : topSeries.length > 0 ? ( +
+ {topSeries.map((item) => ( +
+ {item.clientLabel} + + {t( + "ui.dev.dashboard.recent_changes.series", + "변경 {{changes}} / 작업자 {{actors}}", + { + changes: item.changeCount.toLocaleString(), + actors: item.uniqueActors.toLocaleString(), + }, + )} + +
+ ))} +
+ ) : null} +
+ ); +} + function GlobalOverviewPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -490,6 +863,14 @@ function GlobalOverviewPage() { const profileRole = me?.role?.trim() || role; const [period, setPeriod] = useState("day"); const [selectedClientIds, setSelectedClientIds] = useState([]); + const [recentChangesPeriod, setRecentChangesPeriod] = + useState("week"); + const [selectedRecentChangeClientIds, setSelectedRecentChangeClientIds] = + useState([]); + const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = + useState(6); + const [isRecentChangesDetailOpen, setIsRecentChangesDetailOpen] = + useState(false); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const statsQuery = useQuery({ queryKey: ["dev-dashboard-stats"], @@ -527,21 +908,6 @@ function GlobalOverviewPage() { () => buildClientDistribution(clients), [clients], ); - const visibleClients = useMemo( - () => - [...clients] - .sort((left, right) => { - const statusCompare = (left.status || "").localeCompare( - right.status || "", - ); - if (statusCompare !== 0) { - return statusCompare; - } - return (left.name || left.id).localeCompare(right.name || right.id); - }) - .slice(0, 6), - [clients], - ); const clientFilterOptions = useMemo( () => [...clients] @@ -552,6 +918,14 @@ function GlobalOverviewPage() { .sort((left, right) => left.label.localeCompare(right.label)), [clients], ); + const visibleClientIds = useMemo( + () => clients.map((client) => client.id).filter(Boolean), + [clients], + ); + const currentClientIdSet = useMemo( + () => new Set(visibleClientIds), + [visibleClientIds], + ); const stats = statsQuery.data; const usageRows = usageQuery.data?.items ?? []; const filteredUsageRows = useMemo(() => { @@ -565,6 +939,194 @@ function GlobalOverviewPage() { () => buildMultiLineSeries(filteredUsageRows), [filteredUsageRows], ); + const { data: recentAuditData } = useQuery({ + queryKey: [ + "dev-dashboard-audit-logs", + "clients-recent", + visibleClientIds.join("|"), + profileRole, + ], + queryFn: async () => { + const globalLogs = await fetchDevAuditLogs(50); + if (globalLogs.items.length > 0 || profileRole === "super_admin") { + return globalLogs; + } + + if (visibleClientIds.length === 0) { + return globalLogs; + } + + const perClientLogs = await Promise.all( + visibleClientIds.slice(0, 20).map(async (clientId) => { + try { + const result = await fetchDevAuditLogs(5, undefined, { + client_id: clientId, + }); + return result.items; + } catch { + return []; + } + }), + ); + + const merged = perClientLogs + .flat() + .filter( + (item, index, self) => + self.findIndex( + (candidate) => candidate.event_id === item.event_id, + ) === index, + ) + .sort( + (left, right) => + new Date(right.timestamp).getTime() - + new Date(left.timestamp).getTime(), + ) + .slice(0, 50); + + return { + items: merged, + limit: 50, + cursor: globalLogs.cursor, + next_cursor: globalLogs.next_cursor, + }; + }, + enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole), + retry: false, + }); + const recentClientChanges = useMemo( + () => buildRecentClientChanges(recentAuditData?.items ?? [], clients), + [clients, recentAuditData?.items], + ); + const recentClientActorIds = useMemo( + () => + Array.from( + new Set( + recentClientChanges + .map((item) => item.actorId.trim()) + .filter((actorId) => actorId && actorId !== "-"), + ), + ), + [recentClientChanges], + ); + const { data: recentClientActors } = useQuery({ + queryKey: ["dev-dashboard-recent-client-actors", recentClientActorIds], + queryFn: async () => { + const entries = await Promise.all( + recentClientActorIds.map(async (actorId) => { + try { + const user = await fetchDevUser(actorId); + return [actorId, user.name || actorId] as const; + } catch { + return [actorId, actorId] as const; + } + }), + ); + return Object.fromEntries(entries); + }, + enabled: recentClientActorIds.length > 0, + }); + const recentClientChangesWithActors = useMemo( + () => + recentClientChanges.map((item) => ({ + ...item, + actorName: recentClientActors?.[item.actorId] || item.actorId, + })), + [recentClientActors, recentClientChanges], + ); + const deletedRecentChangeClientIds = useMemo( + () => + Array.from( + new Set( + recentClientChangesWithActors + .map((item) => item.clientId) + .filter((clientId) => !currentClientIdSet.has(clientId)), + ), + ), + [currentClientIdSet, recentClientChangesWithActors], + ); + const recentChangeFilterOptions = useMemo( + () => { + const activeOptions = Array.from( + new Map( + recentClientChangesWithActors + .filter((item) => currentClientIdSet.has(item.clientId)) + .map((item) => [ + item.clientId, + { id: item.clientId, label: item.clientName }, + ]), + ).values(), + ).sort((left, right) => left.label.localeCompare(right.label)); + + if (deletedRecentChangeClientIds.length === 0) { + return activeOptions; + } + + return [ + ...activeOptions, + { + id: deletedRecentChangeFilterId, + label: t( + "ui.dev.dashboard.recent_changes.deleted_group", + "삭제된 RP", + ), + }, + ]; + }, + [ + currentClientIdSet, + deletedRecentChangeClientIds.length, + recentClientChangesWithActors, + ], + ); + const filteredRecentClientChanges = useMemo(() => { + if (selectedRecentChangeClientIds.length === 0) { + return recentClientChangesWithActors; + } + const selectedSet = new Set(selectedRecentChangeClientIds); + const includeDeletedGroup = selectedSet.has(deletedRecentChangeFilterId); + return recentClientChangesWithActors.filter( + (item) => + selectedSet.has(item.clientId) || + (includeDeletedGroup && deletedRecentChangeClientIds.includes(item.clientId)), + ); + }, [ + deletedRecentChangeClientIds, + recentClientChangesWithActors, + selectedRecentChangeClientIds, + ]); + const selectedRecentChangeSeries = useMemo( + () => buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod), + [filteredRecentClientChanges, recentChangesPeriod], + ); + const recentChangedClientCount = useMemo( + () => + new Set( + filteredRecentClientChanges + .map((item) => item.clientId) + .filter((clientId) => currentClientIdSet.has(clientId)), + ).size, + [currentClientIdSet, filteredRecentClientChanges], + ); + const deletedRecentChangedClientCount = useMemo( + () => + new Set( + filteredRecentClientChanges + .map((item) => item.clientId) + .filter((clientId) => deletedRecentChangeClientIds.includes(clientId)), + ).size, + [deletedRecentChangeClientIds, filteredRecentClientChanges], + ); + const recentChangeCount = filteredRecentClientChanges.length; + const latestRecentChange = filteredRecentClientChanges[0]; + const visibleRecentClientChanges = filteredRecentClientChanges.slice( + 0, + visibleRecentClientChangesCount, + ); + const hasMoreRecentClientChanges = + filteredRecentClientChanges.length > visibleRecentClientChanges.length; + const isAllRecentChangeClientsSelected = + selectedRecentChangeClientIds.length === 0; const usageError = usageQuery.error as AxiosError<{ error?: string }> | null; const usageStatus = usageError?.response?.status; const usageErrorMessage = @@ -609,6 +1171,23 @@ function GlobalOverviewPage() { const selectAllClients = () => { setSelectedClientIds([]); }; + const toggleRecentChangeClientSelection = (clientId: string) => { + setSelectedRecentChangeClientIds((current) => { + if (current.includes(clientId)) { + return current.filter((item) => item !== clientId); + } + return [...current, clientId]; + }); + }; + const selectAllRecentChangeClients = () => { + setSelectedRecentChangeClientIds([]); + }; + + useEffect(() => { + setVisibleRecentClientChangesCount((current) => + Math.min(Math.max(6, current), filteredRecentClientChanges.length), + ); + }, [filteredRecentClientChanges.length, selectedRecentChangeClientIds]); if (isLoadingDeveloperAccessGate) { return ( @@ -696,7 +1275,7 @@ function GlobalOverviewPage() {
-

+

{t( "ui.dev.dashboard.chart.title", "애플리케이션별 로그인요청/기타 요청 현황", @@ -756,116 +1335,204 @@ function GlobalOverviewPage() { )}

-
-
-
- -

+
+
+
+

+ {t("ui.dev.dashboard.recent_changes.title", "최근 변경된 앱")} +

+

{t( - "ui.dev.dashboard.distribution.title", - "애플리케이션 구성 요약", + "msg.dev.dashboard.recent_changes.description", + "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다.", )} -

+

-

- {t( - "msg.dev.dashboard.distribution.description", - "애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.", - )} -

-
-
-

- {t("ui.dev.dashboard.distribution.private", "Server side App")} -

-

- {distribution.privateClients.toLocaleString()} -

-

- {t( - "ui.dev.dashboard.distribution.headless_hint", - "이 중 Headless Login 사용 {{count}}", - { - count: distribution.headlessClients.toLocaleString(), - }, - )} -

-
-
-

- {t("ui.dev.dashboard.distribution.pkce", "PKCE")} -

-

- {distribution.pkceClients.toLocaleString()} -

-
-
-
- -
- -

- {t("ui.dev.dashboard.recent.title", "내 애플리케이션")} -

-
-

- {t( - "msg.dev.dashboard.recent.empty", - "현재 계정이 접근할 수 있는 RP를 확인합니다.", - )} -

-
- {visibleClients.length === 0 ? ( -

- {t( - "msg.dev.dashboard.recent.none", - "표시할 연동 앱이 없습니다.", - )} -

- ) : ( - visibleClients.map((client) => ( -
+ {[ + ["day", t("ui.common.chart.period.day", "일")], + ["week", t("ui.common.chart.period.week", "주")], + ["month", t("ui.common.chart.period.month", "월")], + ].map(([value, label]) => ( +
- )) - )} + {label} + + ))} +
-
-
+ + +
+ } + label={t( + "ui.dev.dashboard.recent_changes.summary.total_changes", + "최근 변경 건수", + )} + value={recentChangeCount.toLocaleString()} + /> + } + label={t( + "ui.dev.dashboard.recent_changes.summary.changed_clients", + "변경된 앱 수", + )} + value={recentChangedClientCount.toLocaleString()} + /> + } + label={t( + "ui.dev.dashboard.recent_changes.summary.deleted_clients", + "삭제된 RP 수", + )} + value={deletedRecentChangedClientCount.toLocaleString()} + /> + } + label={t( + "ui.dev.dashboard.recent_changes.summary.latest_change", + "마지막 변경일", + )} + value={formatDate(latestRecentChange?.timestamp)} + /> +
+ + + + {isAllRecentChangeClientsSelected ? ( + + ) : ( + + )} + + + + {isRecentChangesDetailOpen ? ( +
+
+ {filteredRecentClientChanges.length === 0 ? ( +
+ {t( + "msg.dev.dashboard.recent_changes.empty", + "최근 변경 로그가 아직 없습니다.", + )} +
+ ) : ( + visibleRecentClientChanges.map((item) => { + const { date, time } = formatAuditDateParts(item.timestamp); + return ( +
+
+ + {item.clientName} + + {item.actionLabel} + + {item.actorName} + +
+
+ {item.detailLabels.length > 0 ? ( + item.detailLabels.map((detail) => ( + + {detail.label}: {detail.value} + + )) + ) : ( + + {t( + "msg.dev.clients.recent_changes.no_detail", + "변경 항목을 확인할 수 없습니다.", + )} + + )} +
+

+ {date} {time} +

+
+ ); + }) + )} + {hasMoreRecentClientChanges ? ( +
+ +
+ ) : null} +
+
+ ) : null} + + ); } diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts new file mode 100644 index 00000000..dd31e081 --- /dev/null +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -0,0 +1,183 @@ +import { + formatAuditValue, + parseAuditDetails, + resolveAuditActor, + type AuditDetails, + type CommonAuditLog, +} from "../../../../common/core/audit"; +import type { ClientSummary, DevAuditLog } from "../../lib/devApi"; + +export type RecentClientChange = { + eventId: string; + clientId: string; + clientName: string; + actorId: string; + action: string; + actionLabel: string; + timestamp: string; + detailLabels: Array<{ label: string; value: string }>; +}; + +const recentClientActions = new Set([ + "CREATE_CLIENT", + "UPDATE_CLIENT", + "UPDATE_CLIENT_STATUS", + "ROTATE_SECRET", + "ADD_RELATION", + "REMOVE_RELATION", + "DELETE_CLIENT", +]); + +const recentClientFieldLabels: Record = { + name: "이름", + type: "유형", + status: "상태", + scopes: "스코프", + tenant_access_restricted: "테넌트 접근 제한", + allowed_tenants: "허용 테넌트", + id_token_claims: "커스텀 클레임", + token_endpoint_auth_method: "인증 방식", + jwks_uri: "JWKS URI", + backchannel_logout_uri: "Backchannel Logout URI", + backchannel_logout_session_required: "세션 필수", + headless_login_enabled: "헤드리스 로그인", + headless_token_endpoint_auth_method: "헤드리스 인증 방식", + headless_jwks_uri: "헤드리스 JWKS URI", + redirect_uri_count: "Redirect URI 수", + scope_count: "Scope 수", + relation: "관계", + subject: "대상", +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function getRecentClientActionLabel(action: string) { + switch (action) { + case "CREATE_CLIENT": + return "클라이언트 생성"; + case "UPDATE_CLIENT": + return "설정 변경"; + case "UPDATE_CLIENT_STATUS": + return "상태 변경"; + case "ROTATE_SECRET": + return "클라이언트 시크릿 재발급"; + case "ADD_RELATION": + return "관계 추가"; + case "REMOVE_RELATION": + return "관계 삭제"; + case "DELETE_CLIENT": + return "클라이언트 삭제"; + default: + return action; + } +} + +export function buildRecentClientChangeDetails( + action: string, + details: AuditDetails, +) { + const before = isRecord(details.before) ? details.before : {}; + const after = isRecord(details.after) ? details.after : {}; + + if (action === "ROTATE_SECRET") { + return [{ label: "클라이언트 시크릿", value: "재발급" }]; + } + + if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { + const source = action === "ADD_RELATION" ? after : before; + return [ + ...(source.relation + ? [{ label: "관계", value: formatAuditValue(source.relation) }] + : []), + ...(source.subject + ? [{ label: "대상", value: formatAuditValue(source.subject) }] + : []), + ]; + } + + const keys = Array.from( + new Set([...Object.keys(before), ...Object.keys(after)]), + ); + + const changes = keys + .map((key) => { + const beforeValue = before[key]; + const afterValue = after[key]; + + if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { + if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { + return null; + } + } + + const label = recentClientFieldLabels[key] ?? key; + if (action === "CREATE_CLIENT") { + if (afterValue === undefined) { + return null; + } + return { label, value: formatAuditValue(afterValue) }; + } + if (action === "DELETE_CLIENT") { + if (beforeValue === undefined) { + return null; + } + return { label, value: formatAuditValue(beforeValue) }; + } + if (beforeValue === undefined && afterValue === undefined) { + return null; + } + if (beforeValue === undefined) { + return { label, value: formatAuditValue(afterValue) }; + } + if (afterValue === undefined) { + return { label, value: formatAuditValue(beforeValue) }; + } + return { + label, + value: `${formatAuditValue(beforeValue)} → ${formatAuditValue(afterValue)}`, + }; + }) + .filter((item): item is { label: string; value: string } => Boolean(item)); + + return changes.slice(0, 3); +} + +export function buildRecentClientChanges( + auditLogs: DevAuditLog[], + clients: ClientSummary[], +) { + const clientNameById = new Map( + clients.map((client) => [client.id, client.name || client.id]), + ); + + return auditLogs + .map((item) => { + const details = parseAuditDetails(item.details); + const action = details.action || ""; + const clientId = String(details.target_id || ""); + if (!recentClientActions.has(action) || !clientId) { + return null; + } + return { + eventId: item.event_id, + clientId, + clientName: clientNameById.get(clientId) || clientId, + actorId: resolveAuditActor( + item as Pick, + details, + ), + action, + actionLabel: getRecentClientActionLabel(action), + timestamp: item.timestamp, + detailLabels: buildRecentClientChangeDetails(action, details), + } satisfies RecentClientChange; + }) + .filter((item): item is RecentClientChange => Boolean(item)) + .sort( + (left, right) => + new Date(right.timestamp).getTime() - + new Date(left.timestamp).getTime(), + ); +} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index b94625b6..b41f378e 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -544,6 +544,10 @@ new_client = "Configure redirect URIs, grant types, and authentication methods." empty = "Review the relying parties this account can access." none = "No connected applications to display." +[msg.dev.dashboard.recent_changes] +description = "Review trends for changed or deleted applications on the dashboard." +empty = "There are no recent change logs yet." + [msg.dev.dashboard.notice] consent_audit = "Consent Audit" dev_scope = "Dev Scope" @@ -1741,6 +1745,20 @@ title = "Quick links" [ui.dev.dashboard.recent] title = "My Applications" +[ui.dev.dashboard.recent_changes] +deleted_group = "Deleted RPs" +aria = "Recent application changes" +period = "Recent change aggregation period" +series = "Changes {{changes}} / Actors {{actors}}" +title = "Recently Changed Applications" +y_axis = "Y axis: change count" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "Changed apps" +deleted_clients = "Deleted RPs" +latest_change = "Latest change" +total_changes = "Recent changes" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 42fe9402..d169bd38 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -544,6 +544,10 @@ new_client = "redirect URI, grant type, 인증 방식을 설정합니다." empty = "현재 계정이 접근할 수 있는 RP를 확인합니다." none = "표시할 연동 앱이 없습니다." +[msg.dev.dashboard.recent_changes] +description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다." +empty = "최근 변경 로그가 아직 없습니다." + [msg.dev.dashboard.notice] consent_audit = "Consent 회수는 감사 로그와 연계" dev_scope = "RP 정책은 dev scope에서만 적용" @@ -1740,6 +1744,20 @@ title = "빠른 이동" [ui.dev.dashboard.recent] title = "내 애플리케이션" +[ui.dev.dashboard.recent_changes] +deleted_group = "삭제된 RP" +aria = "최근 변경된 앱 현황" +period = "최근 변경 집계 단위" +series = "변경 {{changes}} / 작업자 {{actors}}" +title = "최근 변경된 앱" +y_axis = "Y축: 변경 수" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "변경된 앱 수" +deleted_clients = "삭제된 RP 수" +latest_change = "마지막 변경일" +total_changes = "최근 변경 건수" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 85b2c1dc..141eacba 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -582,6 +582,10 @@ new_client = "" empty = "" none = "" +[msg.dev.dashboard.recent_changes] +description = "" +empty = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1797,6 +1801,20 @@ title = "" [ui.dev.dashboard.recent] title = "" +[ui.dev.dashboard.recent_changes] +deleted_group = "" +aria = "" +period = "" +series = "" +title = "" +y_axis = "" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "" +deleted_clients = "" +latest_change = "" +total_changes = "" + [ui.dev.dashboard.stack] notes = "" subtitle = "" diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index 9777876f..f5fe0a0d 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -47,7 +47,7 @@ test("clients page loads correctly", async ({ page }) => { ).toBeVisible(); }); -test("clients page shows recent RP changes", async ({ page }) => { +test("overview page shows recent RP changes", async ({ page }) => { await seedAuth(page, "super_admin"); await installDevApiMock(page, { clients: [ @@ -89,7 +89,7 @@ test("clients page shows recent RP changes", async ({ page }) => { auditLogsByCursor: undefined, }); - await page.goto("/clients"); + await page.goto("/"); await expect( page.getByRole("heading", { name: "최근 변경된 앱" }), ).toBeVisible(); @@ -153,7 +153,7 @@ test("clients page shows only five apps by default and expands with more button" ).toHaveCount(0); }); -test("clients page shows user-delete relation cleanup in recent changes", async ({ +test("overview page shows user-delete relation cleanup in recent changes", async ({ page, }) => { await seedAuth(page, "super_admin"); @@ -195,7 +195,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async auditLogsByCursor: undefined, }); - await page.goto("/clients"); + await page.goto("/"); await expect( page.getByRole("heading", { name: "최근 변경된 앱" }), ).toBeVisible(); @@ -210,7 +210,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async ).toBeVisible(); }); -test("clients page expands recent changes with more button", async ({ +test("clients page no longer shows recent changes card", async ({ page, }) => { await seedAuth(page, "super_admin"); @@ -246,23 +246,8 @@ test("clients page expands recent changes with more button", async ({ await page.goto("/clients"); await expect( page.getByRole("heading", { name: "최근 변경된 앱" }), - ).toBeVisible(); + ).toHaveCount(0); await expect( - page.getByRole("link", { name: "Recent App 1", exact: true }), + page.getByRole("heading", { name: "연동 앱 목록" }), ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Recent App 5", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Recent App 6", exact: true }), - ).not.toBeVisible(); - - const moreButton = page.getByRole("button", { name: "더 보기" }); - await expect(moreButton).toBeVisible(); - await moreButton.click(); - - await expect( - page.getByRole("link", { name: "Recent App 6", exact: true }), - ).toBeVisible(); - await expect(moreButton).toHaveCount(0); });