diff --git a/AGENTS.md b/AGENTS.md index cc97434a..2216aaaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Before committing any code changes, you MUST run and pass: ## UI Theme Overrides +- For every Console UI, interaction, settings, form, dialog, table, responsive-layout, or visual-review change, read and follow `skills/rustfs-console-design-guide/SKILL.md` before editing. Use `skills/ui-audit/SKILL.md` as the audit workflow and the Console design guide as the source of design decisions. - Apply visual tweaks (e.g. removing shadows, altering colors) at usage sites via classes such as `class="shadow-none"`. - When extending shadcn components, create wrapper components (e.g. `BucketSelector.tsx`) instead of forking primitives. - Do not change base colors or theme variables defined in `console-new` unless explicitly required by the migration plan. diff --git a/app/(auth)/config/page.tsx b/app/(auth)/config/page.tsx index b7065e32..f65f6e7c 100644 --- a/app/(auth)/config/page.tsx +++ b/app/(auth)/config/page.tsx @@ -102,29 +102,31 @@ function ConfigPageContent() { } return ( -
+
-
-
+
+
-
-
- -
-

{t("Server Configuration")}

+
+
+ +
+

+ {t("Server Configuration")} +

{t("Please configure your RustFS server address")}

-
-
+
+
{t("Server Address")} @@ -138,6 +140,7 @@ function ConfigPageContent() { type="url" autoComplete="off" spellCheck={false} + className="h-11 text-base sm:h-8 sm:text-xs" placeholder={t("Please enter server address (e.g., http://localhost:9000)")} /> @@ -147,14 +150,18 @@ function ConfigPageContent() {
-
-
-

+

+

{t("Need help?")}{" "} {t("View Documentation")}

-
- -
-
+
-
-
diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index c93eac6b..7a1b6511 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,6 +1,6 @@ export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
) diff --git a/app/(dashboard)/_components/performance-backend-card.tsx b/app/(dashboard)/_components/performance-backend-card.tsx index 0733dde8..1f0ba951 100644 --- a/app/(dashboard)/_components/performance-backend-card.tsx +++ b/app/(dashboard)/_components/performance-backend-card.tsx @@ -1,6 +1,6 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" export interface BackendInfoItem { icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }> @@ -11,26 +11,29 @@ export interface BackendInfoItem { export function PerformanceBackendCard({ items, t }: { items: BackendInfoItem[]; t: (key: string) => string }) { return ( - - {items.length ? t("Backend Services") : ""} - - {items.length ? t("Key services and configuration values reported by the cluster.") : ""} - + +

+ {t("Storage Configuration")} +

+ {t("Parity and backend settings reported by the cluster.")}
-
+
{items.map((item) => ( - - - {item.title} - - - -

{item.value ?? "-"}

-
-
+
+ +
+
{item.title}
+
+ {item.value ?? t("Unknown")} +
+
+
))} -
+
) diff --git a/app/(dashboard)/_components/performance-infrastructure-card.tsx b/app/(dashboard)/_components/performance-infrastructure-card.tsx index 950c6665..b5d46ca4 100644 --- a/app/(dashboard)/_components/performance-infrastructure-card.tsx +++ b/app/(dashboard)/_components/performance-infrastructure-card.tsx @@ -1,72 +1,112 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" + +function HealthMetric({ label, value, unavailableLabel }: { label: string; value?: number; unavailableLabel: string }) { + return ( +
+
{label}
+
{value ?? unavailableLabel}
+
+ ) +} export function PerformanceInfrastructureCard({ onlineServers, offlineServers, - unknownServers, degradedServers, + initializingServers, + unknownServers, onlineDisks, offlineDisks, unknownDisks, t, }: { - onlineServers: number - offlineServers: number - unknownServers: number - degradedServers: number - onlineDisks: number - offlineDisks: number - unknownDisks: number + onlineServers?: number + offlineServers?: number + degradedServers?: number + initializingServers?: number + unknownServers?: number + onlineDisks?: number + offlineDisks?: number + unknownDisks?: number t: (key: string) => string }) { + const needsAttention = Boolean((offlineServers ?? 0) + (degradedServers ?? 0) + (offlineDisks ?? 0)) + const hasUnknownState = Boolean((initializingServers ?? 0) + (unknownServers ?? 0) + (unknownDisks ?? 0)) + const serverCounts = [onlineServers, offlineServers, degradedServers, initializingServers, unknownServers] + const diskCounts = [onlineDisks, offlineDisks, unknownDisks] + const dataUnavailable = + serverCounts.some((value) => value === undefined) || + diskCounts.some((value) => value === undefined) || + serverCounts.reduce((total, value) => total + (value ?? 0), 0) === 0 || + diskCounts.reduce((total, value) => total + (value ?? 0), 0) === 0 + const status = needsAttention + ? t("Needs attention") + : hasUnknownState || dataUnavailable + ? t("Unknown") + : t("Healthy") + const description = needsAttention + ? t("Offline or degraded resources require attention.") + : hasUnknownState || dataUnavailable + ? t("Some resource health data is unavailable or still initializing.") + : t("All reported servers and disks are online.") + return ( - + - {t("Infrastructure Health")} - {t("Real-time status of cluster servers and backend storage devices.")} +
+
+

+ {t("Cluster Health")} +

+ {description} +
+ + {status} + +
-
-
-

{t("Servers")}

-
-
-

{t("Online")}

-

{onlineServers}

-
-
-

{t("Offline")}

-

{offlineServers}

-
-
-

{t("Unknown")}

-

{unknownServers}

-
-
-

{t("Degraded")}

-

{degradedServers}

-
-
-
-
-

{t("Disks")}

-
-
-

{t("Online")}

-

{onlineDisks}

-
-
-

{t("Offline")}

-

{offlineDisks}

-
-
-

{t("Unknown")}

-

{unknownDisks}

-
-
-
+
+
+

+ {t("Servers")} +

+
+ + + {degradedServers ? ( + + ) : null} + {initializingServers ? ( + + ) : null} + {unknownServers ? ( + + ) : null} +
+
+
+

+ {t("Disks")} +

+
+ + + {unknownDisks ? ( + + ) : null} +
+
diff --git a/app/(dashboard)/_components/performance-server-list.tsx b/app/(dashboard)/_components/performance-server-list.tsx index 33a45b6c..85da5e0b 100644 --- a/app/(dashboard)/_components/performance-server-list.tsx +++ b/app/(dashboard)/_components/performance-server-list.tsx @@ -2,69 +2,89 @@ import * as React from "react" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" -import { ScrollArea } from "@/components/ui/scroll-area" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { niceBytes } from "@/lib/functions" import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data" import { cn } from "@/lib/utils" -import dayjs from "dayjs" -import relativeTime from "dayjs/plugin/relativeTime" import type { ServerInfo } from "@/hooks/use-performance-data" -dayjs.extend(relativeTime) +type Translate = (key: string) => string -function countOnlineDrives(server: ServerInfo, type: string) { - return (server?.drives || []).filter((d) => d.state === type).length +function countOnlineDrives(server: ServerInfo) { + return (server.drives ?? []).filter((drive) => { + const state = drive.state?.toLowerCase() + return state === "ok" || state === "online" + }).length } -function countOnlineNetworks(server: ServerInfo, type: string) { - return Object.values(server?.network || {}).filter((state) => state === type).length +function countOnlineNetworks(server: ServerInfo) { + return Object.values(server.network ?? {}).filter((state) => state.toLowerCase() === "online").length } -type PerformanceServerSort = - | "default" - | "endpoint-asc" - | "endpoint-desc" - | "status-online" - | "status-offline" - | "status-unknown" - | "status-degraded" - | "uptime-desc" - | "uptime-asc" +function getStateLabel(state: ServerHealthState, t: Translate) { + switch (state) { + case "online": + return t("Online") + case "offline": + return t("Offline") + case "degraded": + return t("Degraded") + case "initializing": + return t("Initializing") + default: + return t("Unknown") + } +} -type PerformanceServerFilter = "all" | ServerHealthState +function getStatePriority(state: ServerHealthState) { + return { offline: 0, degraded: 1, unknown: 2, initializing: 3, online: 4 }[state] +} -const serverStates: ServerHealthState[] = ["online", "offline", "unknown", "degraded"] +function getStateVariant(state: ServerHealthState): "secondary" | "destructive" | "outline" { + if (state === "online") return "secondary" + if (state === "offline" || state === "degraded") return "destructive" + return "outline" +} -function getServerState(server: ServerInfo) { - return normalizeServerHealthState(server.state) +function formatUptime(seconds: number | undefined, t: Translate) { + if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown") + const totalMinutes = Math.floor(seconds / 60) + const days = Math.floor(totalMinutes / 1440) + const hours = Math.floor((totalMinutes % 1440) / 60) + const minutes = totalMinutes % 60 + const parts = [ + days ? `${days} ${t("Days")}` : "", + hours ? `${hours} ${t("Hours")}` : "", + !days && minutes ? `${minutes} ${t("Minutes")}` : "", + ].filter(Boolean) + return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}` } -function getServerStateLabel(state: ServerHealthState, t: (key: string) => string) { - if (state === "online") return t("Online") - if (state === "offline") return t("Offline") - if (state === "degraded") return t("Degraded") - return t("Unknown") +function formatBytes(value: number | undefined, t: Translate) { + return value === undefined || !Number.isFinite(value) || value < 0 ? t("Unknown") : niceBytes(String(value)) } -function getServerStateTone(state: ServerHealthState) { - if (state === "online") return "bg-primary" - if (state === "offline") return "bg-destructive" - if (state === "degraded") return "bg-amber-500" - return "bg-muted-foreground" +function getDriveUsagePercent(drive: NonNullable[number]) { + if (!Number.isFinite(drive.totalspace) || !drive.totalspace || drive.totalspace <= 0) return undefined + if (!Number.isFinite(drive.usedspace) || drive.usedspace === undefined || drive.usedspace < 0) return undefined + const used = drive.usedspace + return Math.min(100, Math.max(0, (used / drive.totalspace) * 100)) } -function getServerStateButtonClass(state: ServerHealthState, active: boolean) { - if (!active) return "shadow-none" - if (state === "offline") return "border-destructive/40 text-destructive shadow-none" - if (state === "degraded") return "border-amber-500/50 text-amber-700 shadow-none dark:text-amber-300" - if (state === "unknown") return "border-muted-foreground/40 text-muted-foreground shadow-none" - return "shadow-none" +function getDriveState(drive: NonNullable[number]) { + const state = drive.state?.toLowerCase() + if (state === "ok" || state === "online") return "online" as const + if (state === "offline" || state === "faulty" || state === "error") return "offline" as const + return "unknown" as const } +type PerformanceServerSort = "attention" | "endpoint-asc" | "endpoint-desc" | "uptime-desc" | "uptime-asc" +type PerformanceServerFilter = "all" | ServerHealthState + function compareEndpoint(left: ServerInfo, right: ServerInfo, direction: "asc" | "desc") { const normalizedLeft = left.endpoint?.trim() const normalizedRight = right.endpoint?.trim() @@ -82,8 +102,8 @@ function compareEndpoint(left: ServerInfo, right: ServerInfo, direction: "asc" | } function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | "desc") { - const hasLeft = Number.isFinite(left.uptime) - const hasRight = Number.isFinite(right.uptime) + const hasLeft = Number.isFinite(left.uptime) && (left.uptime ?? -1) >= 0 + const hasRight = Number.isFinite(right.uptime) && (right.uptime ?? -1) >= 0 if (!hasLeft && !hasRight) return 0 if (!hasLeft) return 1 @@ -92,31 +112,29 @@ function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | " return direction === "asc" ? left.uptime! - right.uptime! : right.uptime! - left.uptime! } -export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t: (key: string) => string }) { - const [sortBy, setSortBy] = React.useState("default") +const filterOrder: ServerHealthState[] = ["offline", "degraded", "initializing", "unknown", "online"] + +export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; t: Translate }) { + const [sortBy, setSortBy] = React.useState("attention") const [filterBy, setFilterBy] = React.useState("all") - const stateCounts = React.useMemo( - () => - servers.reduce( - (counts, server) => { - counts[getServerState(server)] += 1 - return counts - }, - { online: 0, offline: 0, unknown: 0, degraded: 0 } satisfies Record, - ), - [servers], - ) + const reportedServers = React.useMemo(() => servers ?? [], [servers]) + + const stateCounts = React.useMemo(() => { + const counts: Record = { + online: 0, + offline: 0, + degraded: 0, + initializing: 0, + unknown: 0, + } + for (const server of reportedServers) counts[normalizeServerHealthState(server.state)] += 1 + return counts + }, [reportedServers]) const visibleServers = React.useMemo(() => { - const rows = servers - .map((server, originalIndex) => ({ - server, - originalIndex, - })) - .filter(({ server }) => { - if (filterBy !== "all") return getServerState(server) === filterBy - return true - }) + const rows = reportedServers + .map((server, originalIndex) => ({ server, originalIndex })) + .filter(({ server }) => filterBy === "all" || normalizeServerHealthState(server.state) === filterBy) return rows.sort((left, right) => { switch (sortBy) { @@ -124,166 +142,206 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t return compareEndpoint(left.server, right.server, "asc") || left.originalIndex - right.originalIndex case "endpoint-desc": return compareEndpoint(left.server, right.server, "desc") || left.originalIndex - right.originalIndex - case "status-online": - return ( - Number(getServerState(right.server) === "online") - Number(getServerState(left.server) === "online") || - compareEndpoint(left.server, right.server, "asc") || - left.originalIndex - right.originalIndex - ) - case "status-offline": - return ( - Number(getServerState(right.server) === "offline") - Number(getServerState(left.server) === "offline") || - compareEndpoint(left.server, right.server, "asc") || - left.originalIndex - right.originalIndex - ) - case "status-unknown": - return ( - Number(getServerState(right.server) === "unknown") - Number(getServerState(left.server) === "unknown") || - compareEndpoint(left.server, right.server, "asc") || - left.originalIndex - right.originalIndex - ) - case "status-degraded": - return ( - Number(getServerState(right.server) === "degraded") - Number(getServerState(left.server) === "degraded") || - compareEndpoint(left.server, right.server, "asc") || - left.originalIndex - right.originalIndex - ) case "uptime-desc": return compareUptime(left.server, right.server, "desc") || left.originalIndex - right.originalIndex case "uptime-asc": return compareUptime(left.server, right.server, "asc") || left.originalIndex - right.originalIndex - case "default": + case "attention": default: - return left.originalIndex - right.originalIndex + return ( + getStatePriority(normalizeServerHealthState(left.server.state)) - + getStatePriority(normalizeServerHealthState(right.server.state)) || + compareEndpoint(left.server, right.server, "asc") || + left.originalIndex - right.originalIndex + ) } }) - }, [filterBy, servers, sortBy]) + }, [filterBy, reportedServers, sortBy]) + + const filters: PerformanceServerFilter[] = [ + "all", + ...filterOrder.filter((state) => stateCounts[state] > 0 || filterBy === state), + ] + const sortLabels: Record = { + attention: t("Needs attention first"), + "endpoint-asc": t("Endpoint, ascending"), + "endpoint-desc": t("Endpoint, descending"), + "uptime-desc": t("Uptime, longest first"), + "uptime-asc": t("Uptime, shortest first"), + } return ( - -
- {t("Server List")} - - {t("Inspect individual server health, disk utilization, and network status.")} - -
-
- - {t("Total")}: {visibleServers.length}/{servers.length} - -
- {serverStates.map((state) => ( - - ))} -
-
- {t("Sort by")} - + +
+
+

+ {t("Server List")} +

+ + {t("Inspect individual server health, disk utilization, and network status.")} +
+ {servers !== undefined ? ( + + {t("Total")}: {visibleServers.length}/{reportedServers.length} + + ) : null}
+ + {servers !== undefined && reportedServers.length ? ( +
+
+ {filters.map((filter) => { + const selected = filterBy === filter + const label = filter === "all" ? t("All") : getStateLabel(filter, t) + const count = filter === "all" ? reportedServers.length : stateCounts[filter] + return ( + + ) + })} +
+ +
+ + +
+
+ ) : null}
+ - - {visibleServers.map(({ server, originalIndex }) => ( - - -
-
- - {server.endpoint ?? "--"} - - {getServerStateLabel(getServerState(server), t)} - -
-
- - {t("Disks")}: {countOnlineDrives(server, "ok")} / {server.drives?.length ?? 0} - - - {t("Network")}: {countOnlineNetworks(server, "online")} /{" "} - {Object.keys(server.network ?? {}).length} - - - {t("Uptime")}:{" "} - {server.uptime != null ? dayjs().subtract(server.uptime, "second").fromNow() : "--"} - -
-
-
- -

- {t("Version")}: {server.version ?? "--"} -

- -
- {(server.drives || []).map((drive) => ( - - - - {drive.drive_path ?? drive.path ?? "--"} - - - {niceBytes(String(drive.usedspace ?? 0))} / {niceBytes(String(drive.totalspace ?? 0))} - - - - -
-

- {t("Used")}:{" "} - - {niceBytes(String(drive.usedspace ?? 0))} - -

-

- {t("Available")}:{" "} - - {niceBytes(String(drive.availspace ?? 0))} - -

-
-
-
- ))} -
-
-
-
- ))} -
+ {servers === undefined || reportedServers.length === 0 ? ( +
+

{t("No server information was reported.")}

+
+ ) : visibleServers.length ? ( + + {visibleServers.map(({ server, originalIndex }) => { + const state = normalizeServerHealthState(server.state) + return ( + + +
+
+ {getStateLabel(state, t)} + + {server.endpoint ?? t("Unknown")} + +
+
+ + {t("Disks")}: {countOnlineDrives(server)} / {server.drives?.length ?? 0} + + + {t("Network")}: {countOnlineNetworks(server)} / {Object.keys(server.network ?? {}).length} + + + {t("Uptime")}: {formatUptime(server.uptime, t)} + +
+
+
+ +

+ {t("Version")}: {server.version ?? t("Unknown")} +

+ {server.drives?.length ? ( +
+ {server.drives.map((drive, driveIndex) => { + const path = drive.drive_path ?? drive.path ?? t("Unknown") + const driveState = getDriveState(drive) + const usedPercent = getDriveUsagePercent(drive) + return ( + + +
+

{path}

+ {getStateLabel(driveState, t)} +
+ + {formatBytes(drive.usedspace, t)} / {formatBytes(drive.totalspace, t)} + +
+ + +
+
+
{t("Used")}
+
+ {formatBytes(drive.usedspace, t)} +
+
+
+
{t("Available")}
+
+ {formatBytes(drive.availspace, t)} +
+
+
+
+
+ ) + })} +
+ ) : ( +

+ {t("No drive information was reported for this server.")} +

+ )} +
+
+ ) + })} +
+ ) : ( +
+

{t("No servers match the current filter")}

+ +
+ )}
) diff --git a/app/(dashboard)/_components/performance-summary-cards.tsx b/app/(dashboard)/_components/performance-summary-cards.tsx index a7174fcb..1c9c2194 100644 --- a/app/(dashboard)/_components/performance-summary-cards.tsx +++ b/app/(dashboard)/_components/performance-summary-cards.tsx @@ -13,35 +13,44 @@ export interface SummaryMetric { href?: string } -export function PerformanceSummaryCards({ metrics }: { metrics: SummaryMetric[] }) { +export function PerformanceSummaryCards({ metrics, title }: { metrics: SummaryMetric[]; title: string }) { return ( -
- {metrics.map((metric) => { - const cardContent = ( - - - {metric.label} - - - -
-

{metric.display}

- {metric.caption ?

{metric.caption}

: null} -
-
-
- ) - return metric.href ? ( - - {cardContent} - - ) : ( - {cardContent} - ) - })} -
+
+

+ {title} +

+
+ {metrics.map((metric) => { + const cardContent = ( + + + {metric.label} + + + +
+

{metric.display}

+ {metric.caption ?

{metric.caption}

: null} +
+
+
+ ) + return metric.href ? ( + + {cardContent} + + ) : ( + {cardContent} + ) + })} +
+
) } diff --git a/app/(dashboard)/_components/performance-usage-card.tsx b/app/(dashboard)/_components/performance-usage-card.tsx index b5561f7a..2f7ce407 100644 --- a/app/(dashboard)/_components/performance-usage-card.tsx +++ b/app/(dashboard)/_components/performance-usage-card.tsx @@ -1,7 +1,7 @@ "use client" import { RiDatabase2Line } from "@remixicon/react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" import { niceBytes } from "@/lib/functions" @@ -11,52 +11,76 @@ export interface UsageStat { } export function PerformanceUsageCard({ - lastUpdatedLabel, + totalCapacity, totalUsedCapacity, usedPercent, usageStats, t, }: { - lastUpdatedLabel: string - totalUsedCapacity: number - usedPercent: number + totalCapacity?: number + totalUsedCapacity?: number + usedPercent?: number usageStats: UsageStat[] t: (key: string) => string }) { + const availableCapacity = + totalCapacity !== undefined && totalUsedCapacity !== undefined + ? Math.max(totalCapacity - totalUsedCapacity, 0) + : undefined + const formatCapacity = (value?: number) => (value === undefined ? t("Unknown") : niceBytes(String(value))) + return ( - + -
- {t("Usage Report")} - - {t("Last Scan Activity")}: {lastUpdatedLabel} - -
- {t("Monitor overall storage usage and recent scanner activity at a glance.")} +

+ {t("Storage Usage and Scanner")} +

+ {t("Review capacity pressure and scanner activity in one place.")}
-
+
-
+

{t("Used Capacity")}

-

{niceBytes(String(totalUsedCapacity))}

+

{formatCapacity(totalUsedCapacity)}

-
- -

{usedPercent.toFixed(0)}%

+
+ +

+ {usedPercent === undefined ? t("Unknown") : `${usedPercent.toFixed(0)}%`} +

-
+
+
+
{t("Used")}
+
{formatCapacity(totalUsedCapacity)}
+
+
+
{t("Available")}
+
{formatCapacity(availableCapacity)}
+
+
+
{t("Total Capacity")}
+
{formatCapacity(totalCapacity)}
+
+
+ +
{usageStats.map((item) => ( -
-

{item.label}

-

{item.value}

+
+
{item.label}
+
{item.value}
))} -
+
) diff --git a/app/(dashboard)/audit-target/page.tsx b/app/(dashboard)/audit-target/page.tsx index b32fcdea..6b20586e 100644 --- a/app/(dashboard)/audit-target/page.tsx +++ b/app/(dashboard)/audit-target/page.tsx @@ -191,25 +191,30 @@ export default function AuditTargetPage() { -
- +
+ +
+ +
- - - +
} >

{t("Audit Targets")}

diff --git a/app/(dashboard)/buckets/page.tsx b/app/(dashboard)/buckets/page.tsx index 699610f1..13c05f30 100644 --- a/app/(dashboard)/buckets/page.tsx +++ b/app/(dashboard)/buckets/page.tsx @@ -1,10 +1,14 @@ "use client" +import Link from "next/link" import { useSearchParams } from "next/navigation" import { useTranslation } from "react-i18next" +import { RiArrowLeftLine } from "@remixicon/react" +import { Button } from "@/components/ui/button" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { BucketInfo } from "@/components/buckets/info" +import { BucketList } from "@/components/buckets/list" export default function BucketSettingsPage() { const { t } = useTranslation() @@ -12,15 +16,37 @@ export default function BucketSettingsPage() { const bucketName = searchParams.get("bucket") ?? "" + if (!bucketName) { + return ( + + {t("Bucket Settings")}} + emptyDescription={t("Create a bucket to start storing objects.")} + getBucketHref={(name) => `/buckets?bucket=${encodeURIComponent(name)}`} + /> + + ) + } + return ( - -

- {t("Bucket")}: {bucketName || "…"} -

+ + {t("Bucket")}: {bucketName} +

+ } + actions={ + + } + > +

{t("Bucket Settings")}

- {bucketName ? : null} +
) } diff --git a/app/(dashboard)/events-target/page.tsx b/app/(dashboard)/events-target/page.tsx index 14665073..767be379 100644 --- a/app/(dashboard)/events-target/page.tsx +++ b/app/(dashboard)/events-target/page.tsx @@ -191,25 +191,30 @@ export default function EventsTargetPage() { -
- +
+ +
+ +
- - - +
} >

{t("Event Destinations")}

diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx index bf3d3c92..caeed2d3 100644 --- a/app/(dashboard)/events/page.tsx +++ b/app/(dashboard)/events/page.tsx @@ -30,20 +30,31 @@ export default function EventsPage() { return ( - }> - - {t("Buckets")} - - } - > -

- {t("Events")}: {bucketName} -

-
- - + ( + + {t("Bucket")}: {bucketName} +

+ } + actions={ + <> + + {actions} + + } + > +

{t("Events")}

+
+ )} + />
) } diff --git a/app/(dashboard)/import-export/page.tsx b/app/(dashboard)/import-export/page.tsx index c16ac57b..88580eed 100644 --- a/app/(dashboard)/import-export/page.tsx +++ b/app/(dashboard)/import-export/page.tsx @@ -118,7 +118,7 @@ export default function ImportExportPage() { } return ( - +

{t("Import/Export")}

@@ -146,9 +146,9 @@ export default function ImportExportPage() { -
+
{exportHighlights.map((item) => ( -
+
{t(item.label)}
@@ -163,11 +163,17 @@ export default function ImportExportPage() { - +
{t("Download complete IAM configuration as ZIP file")}
- @@ -189,38 +195,46 @@ export default function ImportExportPage() { {t("Only ZIP files are supported, and file size should not exceed 10MB")}

- {uploadError &&

{uploadError}

} + {uploadError && ( +

+ {uploadError} +

+ )} {selectedFile && ( - - -
-

{selectedFile.name}

-

{formatSize(selectedFile.size)}

-
- -
-
+
+
+

{selectedFile.name}

+

{formatSize(selectedFile.size)}

+
+ +
)}
- -
+ +
{selectedFile ? t("Ready to import: {filename}", { filename: selectedFile.name, }) : t("Please select a ZIP file to import")}
- diff --git a/app/(dashboard)/lifecycle/page.tsx b/app/(dashboard)/lifecycle/page.tsx index 5be20eae..5d1f46d0 100644 --- a/app/(dashboard)/lifecycle/page.tsx +++ b/app/(dashboard)/lifecycle/page.tsx @@ -30,20 +30,31 @@ export default function LifecyclePage() { return ( - }> - - {t("Buckets")} - - } - > -

- {t("Lifecycle")}: {bucketName} -

-
- - + ( + + {t("Bucket")}: {bucketName} +

+ } + actions={ + <> + + {actions} + + } + > +

{t("Lifecycle")}

+
+ )} + />
) } diff --git a/app/(dashboard)/oidc/page.tsx b/app/(dashboard)/oidc/page.tsx index 43ed3015..41fe4a07 100644 --- a/app/(dashboard)/oidc/page.tsx +++ b/app/(dashboard)/oidc/page.tsx @@ -439,7 +439,7 @@ export default function OidcPage() { ) : null} -
+
1 } -function UsageMeter({ value, tone = "primary" }: { value: number; tone?: "primary" | "destructive" | "muted" }) { +function UsageMeter({ + value, + label, + tone = "primary", +}: { + value: number + label: string + tone?: "primary" | "destructive" | "muted" +}) { return (
{Math.round(value)}%
@@ -159,6 +169,7 @@ function UsageMeter({ value, tone = "primary" }: { value: number; tone?: "primar export default function PoolDecommissionPage() { const { t } = useTranslation() + const dialog = useDialog() const message = useMessage() const { getPoolsOverview, @@ -169,10 +180,11 @@ export default function PoolDecommissionPage() { clearDecommission, } = usePoolOperations() const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) const [submitting, setSubmitting] = useState(false) + const [dataReady, setDataReady] = useState(false) const [error, setError] = useState(null) const [selectedPoolId, setSelectedPoolId] = useState("") - const [confirmingPoolId, setConfirmingPoolId] = useState("") const [overview, setOverview] = useState({ pools: [] as PoolSummary[], totalCapacity: 0, @@ -184,9 +196,15 @@ export default function PoolDecommissionPage() { const [statuses, setStatuses] = useState>({}) const [rebalanceState, setRebalanceState] = useState("idle") const pollRef = useRef(null) + const requestVersionRef = useRef(0) + const mutationRef = useRef(false) + const mountedRef = useRef(true) + const shouldPollRef = useRef(false) + const interactionLockedRef = useRef(true) + const poolRowsRef = useRef([]) const clearPoll = () => { - if (pollRef.current) { + if (pollRef.current !== null) { window.clearTimeout(pollRef.current) pollRef.current = null } @@ -194,35 +212,53 @@ export default function PoolDecommissionPage() { const loadData = useCallback( async (showSpinner = true) => { + const requestId = ++requestVersionRef.current if (showSpinner) setLoading(true) + else setRefreshing(true) setError(null) try { - const [nextOverview, rebalanceStatus] = await Promise.all([ + const [nextOverview, rebalanceStatus, decommissionStatuses] = await Promise.all([ getPoolsOverview(), - getRebalanceStatus().catch(() => null), + getRebalanceStatus(), + getDecommissionStatuses(), ]) + if (!mountedRef.current || requestId !== requestVersionRef.current) return false const nextRebalanceState = deriveRebalanceDisplayState(rebalanceStatus, nextOverview.supportState) - const decommissionStatuses = await getDecommissionStatuses().catch(() => []) const statusEntries = decommissionStatuses.map((status) => [status.poolId, status]) setOverview(nextOverview) setStatuses(Object.fromEntries(statusEntries) as Record) setRebalanceState(nextRebalanceState) - if (!selectedPoolId && nextOverview.pools[0]?.id) { - setSelectedPoolId(nextOverview.pools[0].id) - } + setSelectedPoolId((current) => + current && nextOverview.pools.some((pool) => pool.id === current) + ? current + : (nextOverview.pools[0]?.id ?? ""), + ) + setDataReady(true) + return true } catch (loadError) { + if (!mountedRef.current || requestId !== requestVersionRef.current) return false setError((loadError as Error).message || t("Load Failed")) + setDataReady(false) + return false } finally { - if (showSpinner) setLoading(false) + if (mountedRef.current && requestId === requestVersionRef.current) { + if (showSpinner) setLoading(false) + else setRefreshing(false) + } } }, - [getDecommissionStatuses, getPoolsOverview, getRebalanceStatus, selectedPoolId, t], + [getDecommissionStatuses, getPoolsOverview, getRebalanceStatus, t], ) useEffect(() => { + mountedRef.current = true void loadData() - return clearPoll + return () => { + mountedRef.current = false + requestVersionRef.current += 1 + clearPoll() + } }, [loadData]) const poolRows = useMemo( @@ -232,15 +268,10 @@ export default function PoolDecommissionPage() { return { pool, status: rowStatus, - displayState: getPoolDisplayState( - rowStatus, - overview.supportState, - rebalanceState, - confirmingPoolId === pool.id, - ), + displayState: getPoolDisplayState(rowStatus, overview.supportState, rebalanceState, false), } }), - [confirmingPoolId, overview.pools, overview.supportState, rebalanceState, statuses], + [overview.pools, overview.supportState, rebalanceState, statuses], ) const activeTask = poolRows.find((row) => isTaskLocked(row)) @@ -251,67 +282,218 @@ export default function PoolDecommissionPage() { (selectedRow && hasDecommissionProgress(selectedRow.displayState) && selectedRow.status ? selectedRow : undefined) const selectionLocked = isTaskLocked(activeTask) const activePoolCount = poolRows.filter((row) => isActivePool(row.pool)).length - const selectedPoolName = selectedRow?.pool.name ?? "--" const trackedProgress = trackedTask ? getProgressPercent(trackedTask.status, trackedTask.pool) : 0 + const interactionLocked = loading || refreshing || submitting || Boolean(error) || !dataReady + + useEffect(() => { + shouldPollRef.current = dataReady && poolRows.some((row) => shouldPoll(row.displayState)) + poolRowsRef.current = poolRows + }, [dataReady, poolRows]) + + useEffect(() => { + interactionLockedRef.current = interactionLocked + }, [interactionLocked]) useEffect(() => { clearPoll() - if (!poolRows.some((row) => shouldPoll(row.displayState))) return - pollRef.current = window.setTimeout(() => { - void loadData(false) - }, POLL_MS) - return clearPoll - }, [loadData, poolRows]) + if (!shouldPollRef.current) return + let cancelled = false + + const scheduleNextPoll = async () => { + const succeeded = await loadData(false) + if (!cancelled && succeeded && shouldPollRef.current) { + pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS) + } + } + + pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS) + return () => { + cancelled = true + clearPoll() + } + }, [dataReady, loadData, poolRows]) const handleStart = async (poolId: string) => { + if (mutationRef.current) return + const currentRows = poolRowsRef.current + const currentRow = currentRows.find((row) => row.pool.id === poolId) + if ( + interactionLockedRef.current || + !currentRow || + !canStartDecommission( + currentRow, + isTaskLocked(currentRows.find((row) => isTaskLocked(row))), + currentRows.filter((row) => isActivePool(row.pool)).length, + ) + ) + return + mutationRef.current = true + requestVersionRef.current += 1 setSubmitting(true) + setError(null) try { - await startDecommission(poolId) - setConfirmingPoolId("") + const nextStatus = await startDecommission(poolId) + if (!mountedRef.current) return + setStatuses((current) => ({ ...current, [poolId]: nextStatus })) setSelectedPoolId(poolId) message.success(t("Pool decommission started")) await loadData(false) } catch (startError) { message.error((startError as Error).message || t("Failed to start decommission")) } finally { - setSubmitting(false) + mutationRef.current = false + if (mountedRef.current) setSubmitting(false) } } const handleCancel = async (poolId: string) => { + if (mutationRef.current) return + const currentRow = poolRowsRef.current.find((row) => row.pool.id === poolId) + if (interactionLockedRef.current || currentRow?.displayState !== "running") return + mutationRef.current = true + requestVersionRef.current += 1 setSubmitting(true) + setError(null) try { - await cancelDecommission(poolId) + const nextStatus = await cancelDecommission(poolId) + if (!mountedRef.current) return + setStatuses((current) => ({ ...current, [poolId]: nextStatus })) setSelectedPoolId(poolId) message.success(t("Pool decommission cancel requested")) await loadData(false) } catch (cancelError) { message.error((cancelError as Error).message || t("Failed to cancel decommission")) } finally { - setSubmitting(false) + mutationRef.current = false + if (mountedRef.current) setSubmitting(false) } } const handleClear = async (poolId: string) => { + if (mutationRef.current) return + const currentRow = poolRowsRef.current.find((row) => row.pool.id === poolId) + if (interactionLockedRef.current || !currentRow || !canClearDecommission(currentRow)) return + mutationRef.current = true + requestVersionRef.current += 1 setSubmitting(true) + setError(null) try { - await clearDecommission(poolId) - if (selectedPoolId === poolId) setConfirmingPoolId("") + const nextStatus = await clearDecommission(poolId) + if (!mountedRef.current) return + setStatuses((current) => ({ ...current, [poolId]: nextStatus })) message.success(t("Decommission cleared")) await loadData(false) } catch (clearError) { message.error((clearError as Error).message || t("Failed to clear decommission")) } finally { - setSubmitting(false) + mutationRef.current = false + if (mountedRef.current) setSubmitting(false) } } + const confirmStart = (row: PoolRow) => { + dialog.warning({ + title: t("Review before decommission"), + content: `${t("This action retires the selected pool and should be used only after verifying rebalance has completed.")} ${t("Selected Pool")}: ${row.pool.name}`, + positiveText: t("Start Decommission"), + negativeText: t("Cancel"), + onPositiveClick: () => handleStart(row.pool.id), + }) + } + + const confirmCancel = (row: PoolRow) => { + dialog.warning({ + title: t("Pool Decommission"), + content: `${t("Selected Pool")}: ${row.pool.name}`, + positiveText: t("Cancel"), + negativeText: t("Close"), + onPositiveClick: () => handleCancel(row.pool.id), + }) + } + + const confirmClear = (row: PoolRow) => { + dialog.error({ + title: t("Clear Decommission"), + content: `${t("Selected Pool")}: ${row.pool.name}`, + positiveText: t("Clear Decommission"), + negativeText: t("Cancel"), + onPositiveClick: () => handleClear(row.pool.id), + }) + } + + const renderPoolAction = (row: PoolRow, fullWidth = false) => { + const className = fullWidth ? "min-h-10 w-full" : undefined + if (row.displayState === "ready") { + return ( + + ) + } + + if (row.displayState === "running") { + return ( + + ) + } + + if (canClearDecommission(row)) { + return ( + + ) + } + + return -- + } + + const initialLoading = loading && !overview.pools.length && !error + return ( void loadData()} disabled={loading || submitting}> - + } @@ -320,12 +502,32 @@ export default function PoolDecommissionPage() {
+ {error ? ( + + {t("Load Failed")} + + {error} + + + + ) : null} + + {initialLoading ? ( +
+ + {t("Loading…")} +
+ ) : null} + {trackedTask ? ( - - -
+ + +
{t("Current Pool Decommission Status")} -

{trackedTask.pool.name}

+

{trackedTask.pool.name}

@@ -333,8 +535,9 @@ export default function PoolDecommissionPage() { @@ -342,7 +545,11 @@ export default function PoolDecommissionPage() {
- + {Math.round(trackedProgress)}% @@ -382,31 +589,31 @@ export default function PoolDecommissionPage() {

{t("Current Object")}

-

{getCurrentObject(trackedTask.status)}

+

{getCurrentObject(trackedTask.status)}

{t("State")}

-

+

{trackedTask.status?.stage || trackedTask.status?.status || "--"}

{t("Waiting Reason")}

-

{trackedTask.status?.waitingReason || "--"}

+

{trackedTask.status?.waitingReason || "--"}

) : null} - {overview.supportState === "unsupported" ? ( + {dataReady && overview.supportState === "unsupported" ? ( {t("Single pool decommission is not supported")} {t("Decommission requires more than one pool in the cluster.")} ) : null} - {isRebalanceBlockingDecommission(rebalanceState) ? ( + {dataReady && isRebalanceBlockingDecommission(rebalanceState) ? ( {t("Rebalance must complete before decommission")} @@ -414,16 +621,9 @@ export default function PoolDecommissionPage() { ) : null} - {error ? ( - - {t("Load Failed")} - {error} - - ) : null} - -
- - + {overview.pools.length ? ( + +
{t("Pool Decommission")}

@@ -433,223 +633,202 @@ export default function PoolDecommissionPage() {

- {selectionLocked ? t("Running") : t("Ready")} + {!dataReady || error ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")}
- {loading ? ( + {loading && !overview.pools.length ? (
) : ( <> - - - - {t("ID")} - {t("Pool")} - {t("Status")} - {t("Total Capacity")} - {t("Used Capacity")} - {t("Available")} - {t("Usage")} - {t("Progress")} - {t("Bytes Moved")} - {t("Actions")} - - - - {poolRows.length === 0 ? ( + {selectedRow ? ( +
+
+

+ {selectionLocked ? t("Selection Locked") : t("Selected Pool")} +

+

{selectedRow.pool.name}

+
+
+

{t("Total Capacity")}

+

{formatBytesValue(selectedRow.pool.total)}

+
+
+

{t("Used Capacity")}

+

{formatBytesValue(selectedRow.pool.used)}

+
+
+

{t("Available")}

+

+ {formatBytesValue(selectedRow.pool.available)} +

+
+
+

{t("Updated At")}

+

{formatDateTime(selectedRow.pool.lastUpdate)}

+
+
+ ) : null} + +
+ {poolRows.map((row) => { + const showProgress = Boolean(row.status) && hasDecommissionProgress(row.displayState) + const progressPercent = showProgress ? getProgressPercent(row.status, row.pool) : 0 + const isSelected = selectedRow?.pool.id === row.pool.id + + return ( +
{ + if (!selectionLocked) setSelectedPoolId(row.pool.id) + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget || selectionLocked) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + setSelectedPoolId(row.pool.id) + }} + > +
+

{row.pool.name}

+ + {formatPoolStatusLabel(row, t)} + +
+
+
+

{t("Used Capacity")}

+

{formatBytesValue(row.pool.used)}

+
+
+

{t("Available")}

+

{formatBytesValue(row.pool.available)}

+
+
+
+
+ {showProgress ? t("Progress") : t("Usage")} + + {Math.round(showProgress ? progressPercent : row.pool.usagePercent)}% + +
+ +
+ {renderPoolAction(row, true)} +
+ ) + })} +
+ +
+
+ {t("Pool Decommission")} + - - {t("No Data")} - + {t("Pool")} + {t("Status")} + {t("Total Capacity")} + {t("Usage")} + {t("Progress")} + + {t("Actions")} + - ) : ( - poolRows.map((row) => { - const { pool, status: rowStatus, displayState: rowState } = row - const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState) - const progressPercent = showProgress ? getProgressPercent(rowStatus, pool) : 0 - const isSelected = selectedRow?.pool.id === pool.id - const canStart = canStartDecommission(row, selectionLocked, activePoolCount) && !submitting - const canConfirm = rowState === "confirming" && !submitting - const canCancel = rowState === "running" && !submitting - const canClear = canClearDecommission(row) && !submitting - - return ( - { - if (selectionLocked) return - setSelectedPoolId(pool.id) - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - if (selectionLocked) return - setSelectedPoolId(pool.id) - }} - > - {pool.id} - {pool.name} - - - {formatPoolStatusLabel(row, t)} - - - {formatBytesValue(pool.total)} - {formatBytesValue(pool.used)} - {formatBytesValue(pool.available)} - - - - - {showProgress ? : "--"} - - {showProgress ? formatBytesValue(rowStatus?.bytes) : "--"} - -
- {rowState === "confirming" ? ( - <> - - - + + + {poolRows.length === 0 ? ( + + + {t("No Data")} + + + ) : ( + poolRows.map((row) => { + const { pool, status: rowStatus, displayState: rowState } = row + const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState) + const progressPercent = showProgress ? getProgressPercent(rowStatus, pool) : 0 + const isSelected = selectedRow?.pool.id === pool.id + return ( + { + if (selectionLocked) return + setSelectedPoolId(pool.id) + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + if (selectionLocked) return + setSelectedPoolId(pool.id) + }} + > + + {pool.name} + + + + {formatPoolStatusLabel(row, t)} + + + +

{formatBytesValue(pool.total)}

+

+ {t("Available")}: {formatBytesValue(pool.available)} +

+
+ + + + + {showProgress ? ( + ) : ( - <> - - - - + "--" )} -
-
-
- ) - }) - )} - -
- - {confirmingPoolId ? ( - - {t("Review before decommission")} - - {t( - "This action retires the selected pool and should be used only after verifying rebalance has completed.", - )}{" "} - {t("Selected Pool")}:{" "} - {overview.pools.find((pool) => pool.id === confirmingPoolId)?.name ?? "--"} - - - ) : null} + + +
{renderPoolAction(row)}
+
+ + ) + }) + )} + + +
)} - -
- - - {t("Selected Pool")} - - -
-

{selectionLocked ? t("Selection Locked") : t("Pool")}

-

{selectedPoolName}

-
-
-
-

{t("Total Capacity")}

-

{formatBytesValue(selectedRow?.pool.total)}

-
-
-

{t("Used Capacity")}

-

{formatBytesValue(selectedRow?.pool.used)}

-
-
-

{t("Available")}

-

{formatBytesValue(selectedRow?.pool.available)}

-
-
-

{t("Updated At")}

-

{formatDateTime(selectedRow?.pool.lastUpdate)}

-
-
-
-
- {t("Usage")} - {Math.round(selectedRow?.pool.usagePercent ?? 0)}% -
- -
-
-
-
-
+ ) : null}
) diff --git a/app/(dashboard)/rebalance/page.tsx b/app/(dashboard)/rebalance/page.tsx index 2d757cea..55177b18 100644 --- a/app/(dashboard)/rebalance/page.tsx +++ b/app/(dashboard)/rebalance/page.tsx @@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" import { Spinner } from "@/components/ui/spinner" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { usePoolOperations } from "@/hooks/use-pool-operations" import type { PoolSummary, PoolsOverview, RebalanceDisplayState, RebalanceStatus } from "@/lib/pool-operations" import { useDialog } from "@/lib/feedback/dialog" @@ -63,7 +63,9 @@ export default function RebalancePage() { const message = useMessage() const { getRebalanceViewModel, startRebalance, stopRebalance } = usePoolOperations() const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) const [submitting, setSubmitting] = useState(false) + const [dataReady, setDataReady] = useState(false) const [error, setError] = useState(null) const [overview, setOverview] = useState(() => ({ pools: [] as PoolSummary[], @@ -76,6 +78,11 @@ export default function RebalancePage() { const [status, setStatus] = useState(null) const [displayState, setDisplayState] = useState("idle") const pollRef = useRef(null) + const requestVersionRef = useRef(0) + const mutationRef = useRef(false) + const mountedRef = useRef(true) + const displayStateRef = useRef("idle") + const interactionLockedRef = useRef(true) const clearPoll = () => { if (pollRef.current) { @@ -86,55 +93,103 @@ export default function RebalancePage() { const loadData = useCallback( async (showSpinner = true) => { + const requestId = ++requestVersionRef.current if (showSpinner) setLoading(true) + else setRefreshing(true) setError(null) try { const view = await getRebalanceViewModel() + if (!mountedRef.current || requestId !== requestVersionRef.current) return false setOverview(view.overview) setStatus(view.status) setDisplayState(view.displayState) + displayStateRef.current = view.displayState + setDataReady(true) + return true } catch (loadError) { + if (!mountedRef.current || requestId !== requestVersionRef.current) return false setError((loadError as Error).message || t("Load Failed")) + setDataReady(false) + return false } finally { - if (showSpinner) setLoading(false) + if (mountedRef.current && requestId === requestVersionRef.current) { + if (showSpinner) setLoading(false) + else setRefreshing(false) + } } }, [getRebalanceViewModel, t], ) useEffect(() => { + mountedRef.current = true void loadData() - return clearPoll + return () => { + mountedRef.current = false + requestVersionRef.current += 1 + clearPoll() + } }, [loadData]) + useEffect(() => { + displayStateRef.current = displayState + }, [displayState]) + useEffect(() => { clearPoll() - if (!shouldPoll(displayState)) return - pollRef.current = window.setTimeout(() => { - void loadData(false) - }, POLL_MS) - return clearPoll - }, [displayState, loadData]) + if (!dataReady || !shouldPoll(displayState)) return + let cancelled = false + + const scheduleNextPoll = async () => { + const succeeded = await loadData(false) + if (!cancelled && succeeded && shouldPoll(displayStateRef.current)) { + pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS) + } + } + + pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS) + return () => { + cancelled = true + clearPoll() + } + }, [dataReady, displayState, loadData]) + const interactionLocked = loading || refreshing || submitting || Boolean(error) || !dataReady const canStart = + !interactionLocked && overview.supportState === "supported" && overview.pools.filter((pool) => pool.status.trim().toLowerCase() === "active").length > 1 && !overview.pools.some((pool) => ["decommissioning", "queued", "running"].includes(pool.decommissionStatus.trim().toLowerCase()), ) && ["idle", "completed", "failed", "stopped"].includes(displayState) - const canStop = ["starting", "running"].includes(displayState) + const canStop = !interactionLocked && ["starting", "running"].includes(displayState) + const showStop = ["starting", "running", "stopping"].includes(displayState) + + useEffect(() => { + interactionLockedRef.current = interactionLocked + }, [interactionLocked]) const handleStart = async () => { + if (mutationRef.current) return + if (interactionLockedRef.current) return + mutationRef.current = true + requestVersionRef.current += 1 setSubmitting(true) + setError(null) try { - await startRebalance() + const nextStatus = await startRebalance() + if (!mountedRef.current) return + setStatus(nextStatus) + setDisplayState("starting") + displayStateRef.current = "starting" message.success(t("Rebalance started")) await loadData(false) } catch (startError) { message.error((startError as Error).message || t("Failed to start rebalance")) } finally { - setSubmitting(false) + mutationRef.current = false + if (mountedRef.current) setSubmitting(false) } } @@ -142,24 +197,35 @@ export default function RebalancePage() { dialog.warning({ title: t("Stop rebalance"), content: t("Are you sure you want to stop the current rebalance operation?"), - positiveText: t("Confirm"), + positiveText: t("Stop rebalance"), negativeText: t("Cancel"), onPositiveClick: async () => { + if (mutationRef.current) return + if (interactionLockedRef.current || !["starting", "running"].includes(displayStateRef.current)) return + mutationRef.current = true + requestVersionRef.current += 1 setSubmitting(true) + setError(null) try { - await stopRebalance() + const nextStatus = await stopRebalance() + if (!mountedRef.current) return + setStatus(nextStatus) + setDisplayState("stopping") + displayStateRef.current = "stopping" message.success(t("Rebalance stop requested")) await loadData(false) } catch (stopError) { message.error((stopError as Error).message || t("Failed to stop rebalance")) } finally { - setSubmitting(false) + mutationRef.current = false + if (mountedRef.current) setSubmitting(false) } }, }) } const statusLabel = useMemo(() => { + if (!dataReady || error) return t("Unknown") switch (displayState) { case "unsupported": return t("Unsupported") @@ -180,7 +246,7 @@ export default function RebalancePage() { default: return t("Unknown") } - }, [displayState, t]) + }, [dataReady, displayState, error, t]) const pools = useMemo(() => { const statusPools = new Map((status?.pools ?? []).map((pool) => [pool.id, pool])) @@ -195,28 +261,40 @@ export default function RebalancePage() { } }) }, [overview.pools, status?.pools]) + const initialLoading = loading && !overview.pools.length && !error return ( - - - + {showStop ? ( + + ) : ( + + )} } > @@ -224,115 +302,189 @@ export default function RebalancePage() {
- - - {overview.supportState === "unsupported" ? ( - - {t("Single pool rebalance is not supported")} - {t("Rebalance requires more than one pool in the cluster.")} - - ) : null} - {error ? ( {t("Load Failed")} - {error} + + {error} + + ) : null} - - -
- {t("Current Rebalance Status")} -

- {t("Track overall rebalance progress and pool-by-pool movement.")} -

-
- - {statusLabel} - -
- - {loading ? ( -
- -
- ) : ( - <> -
-
-

{t("Progress")}

-

{Math.round(status?.progressPercent ?? 0)}%

-
-
-

{t("Bytes Moved")}

-

{formatBytesValue(status?.totals?.bytes)}

-
-
-

{t("Elapsed")}

-

{formatDuration(status?.totals?.elapsed)}

-
-
-

{t("ETA")}

-

{formatDuration(status?.totals?.eta)}

-
+ {initialLoading ? ( +
+ + {t("Loading…")} +
+ ) : overview.pools.length ? ( + <> + + + {dataReady && overview.supportState === "unsupported" ? ( + + {t("Single pool rebalance is not supported")} + {t("Rebalance requires more than one pool in the cluster.")} + + ) : null} + + + +
+ {t("Current Rebalance Status")} +

+ {t("Track overall rebalance progress and pool-by-pool movement.")} +

+ + {statusLabel} + +
+ + {loading && !overview.pools.length ? ( +
+ +
+ ) : ( + <> +
+
+

{t("Progress")}

+

{Math.round(status?.progressPercent ?? 0)}%

+
+
+

{t("Bytes Moved")}

+

{formatBytesValue(status?.totals?.bytes)}

+
+
+

{t("Elapsed")}

+

{formatDuration(status?.totals?.elapsed)}

+
+
+

{t("ETA")}

+

{formatDuration(status?.totals?.eta)}

+
+
- + - - - - {t("Pool")} - {t("Status")} - {t("Used Capacity")} - {t("Bytes Moved")} - {t("Objects")} - {t("Versions")} - {t("Cleanup Warnings")} - - - - {pools.length === 0 ? ( - - - {t("No Data")} - - - ) : ( - pools.map((pool) => ( - + {pools.map((pool) => ( +
- {pool.name} - {pool.rebalanceStatus || "--"} - {formatBytesValue(pool.used)} - {formatBytesValue(pool.progress.bytes)} - {formatNumberValue(pool.progress.objects)} - {formatNumberValue(pool.progress.versions)} - - {pool.cleanupWarnings.count > 0 ? ( - - {formatCleanupWarnings(pool)} - - ) : ( - formatCleanupWarnings(pool) - )} - - - )) - )} - -
- - )} -
-
+
+

{pool.name}

+ + {pool.rebalanceStatus || "--"} + +
+
+
+

{t("Used Capacity")}

+

{formatBytesValue(pool.used)}

+
+
+

{t("Bytes Moved")}

+

{formatBytesValue(pool.progress.bytes)}

+
+
+

{t("Objects")}

+

{formatNumberValue(pool.progress.objects)}

+
+
+

{t("Versions")}

+

{formatNumberValue(pool.progress.versions)}

+
+
+ {pool.cleanupWarnings.count > 0 ? ( +

+ {t("Cleanup Warnings")}: {pool.cleanupWarnings.count} +

+ ) : null} +
+ ))} +
+ +
+ + {t("Current Rebalance Status")} + + + {t("Pool")} + {t("Status")} + {t("Used Capacity")} + {t("Bytes Moved")} + {t("Objects")} + {t("Cleanup Warnings")} + + + + {pools.length === 0 ? ( + + + {t("No Data")} + + + ) : ( + pools.map((pool) => ( + + + {pool.name} + + + + {pool.rebalanceStatus || "--"} + + + {formatBytesValue(pool.used)} + {formatBytesValue(pool.progress.bytes)} + + {formatNumberValue(pool.progress.objects)} /{" "} + {formatNumberValue(pool.progress.versions)} + + + {pool.cleanupWarnings.count > 0 ? ( + + {pool.cleanupWarnings.count} + + ) : ( + formatCleanupWarnings(pool) + )} + + + )) + )} + +
+
+ + )} + +
+ + ) : null}
) diff --git a/app/(dashboard)/replication/page.tsx b/app/(dashboard)/replication/page.tsx index dace0861..88151fd4 100644 --- a/app/(dashboard)/replication/page.tsx +++ b/app/(dashboard)/replication/page.tsx @@ -30,20 +30,31 @@ export default function ReplicationPage() { return ( - }> - - {t("Buckets")} - - } - > -

- {t("Bucket Replication")}: {bucketName} -

-
- - + ( + + {t("Bucket")}: {bucketName} +

+ } + actions={ + <> + + {actions} + + } + > +

{t("Bucket Replication")}

+
+ )} + />
) } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index c10c37f3..e4bbbfa7 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -45,6 +45,7 @@ export default function SettingsPage() { }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [loadError, setLoadError] = useState(null) const applySnapshot = useCallback((nextSnapshot: ModuleSwitchSnapshot) => { setSnapshot(nextSnapshot) @@ -56,10 +57,13 @@ export default function SettingsPage() { const loadSwitches = useCallback(async () => { setLoading(true) + setLoadError(null) try { applySnapshot(await getModuleSwitches()) } catch (error) { - message.error((error as Error).message || t("Failed to load module switches")) + const description = (error as Error).message || t("Failed to load module switches") + setLoadError(description) + message.error(description) } finally { setLoading(false) } @@ -88,7 +92,7 @@ export default function SettingsPage() { } const handleSave = async () => { - if (!snapshot) return + if (!snapshot || loadError) return if (!canUpdateModuleSwitches) { message.warning(t("You do not have permission to update module switches.")) return @@ -154,7 +158,11 @@ export default function SettingsPage() { {t("Sync")} {canUpdateModuleSwitches ? ( - @@ -165,7 +173,7 @@ export default function SettingsPage() {

{t("Settings")}

-
+
{hasEnvManagedSwitch ? ( {t("Environment variables are controlling some switches")} @@ -182,11 +190,24 @@ export default function SettingsPage() { ) : null} + {loadError ? ( + + {t("Failed to load module switches")} + + {loadError} + + + + ) : null} +
{switchItems.map((item) => { const isEnvManaged = isEnvManagedSource(item.source) return ( - +
@@ -203,12 +224,12 @@ export default function SettingsPage() { ) : null}
- + updateSwitch(item.name, checked)} - disabled={!canUpdateModuleSwitches || isEnvManaged || loading || saving} + disabled={!canUpdateModuleSwitches || isEnvManaged || Boolean(loadError) || loading || saving} /> diff --git a/app/(dashboard)/sse/page.tsx b/app/(dashboard)/sse/page.tsx index 840b8368..72fd15f2 100644 --- a/app/(dashboard)/sse/page.tsx +++ b/app/(dashboard)/sse/page.tsx @@ -2,11 +2,11 @@ import * as React from "react" import { useTranslation } from "react-i18next" -import { RiAddLine, RiArrowLeftSLine, RiArrowRightSLine, RiRefreshLine } from "@remixicon/react" +import { RiAddLine, RiArrowLeftSLine, RiArrowRightSLine, RiCloseLine, RiRefreshLine } from "@remixicon/react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, @@ -16,7 +16,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer" import { DropdownMenu, DropdownMenuContent, @@ -30,7 +37,7 @@ import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Spinner } from "@/components/ui/spinner" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { useSSE } from "@/hooks/use-sse" import { useMessage } from "@/lib/feedback/message" import { formatDateTime } from "@/lib/functions" @@ -48,6 +55,13 @@ import { const KEY_LIST_LIMIT = 20 const DEFAULT_PENDING_DELETE_DAYS = 7 +const ADVANCED_CONFIG_FIELDS = new Set([ + "timeoutSeconds", + "retryAttempts", + "enableCache", + "maxCachedKeys", + "cacheTtlSeconds", +]) type ConfigFormState = { backendType: "local" | "vault-kv2" | "vault-transit" @@ -73,8 +87,13 @@ type KeyActionState = { key: KmsKeyInfo } | null +type PendingNavigation = { + destination?: string + historyDelta?: number +} | null + const INITIAL_FORM_STATE: ConfigFormState = { - backendType: "local", + backendType: "vault-transit", keyDir: "", filePermissions: "384", defaultKeyId: "", @@ -128,10 +147,6 @@ function parseOptionalInteger(value: string) { return Number.isFinite(parsed) ? parsed : undefined } -function isAbsolutePath(value: string) { - return /^(?:[A-Za-z]:[\\/]|\\\\|\/)/.test(value.trim()) -} - function normalizeBackendType(value?: string | null): ConfigFormState["backendType"] { switch (value) { case "Vault": @@ -191,7 +206,13 @@ export default function SSEPage() { } = useSSE() const [status, setStatus] = React.useState(null) + const [statusError, setStatusError] = React.useState(null) + const statusRequestRef = React.useRef(0) const [formState, setFormState] = React.useState(INITIAL_FORM_STATE) + const [baselineFormState, setBaselineFormState] = React.useState(INITIAL_FORM_STATE) + const [configFormError, setConfigFormError] = React.useState(null) + const [configFormErrorField, setConfigFormErrorField] = React.useState(null) + const advancedSettingsRef = React.useRef(null) const [loadingStatus, setLoadingStatus] = React.useState(false) const [refreshingStatus, setRefreshingStatus] = React.useState(false) const [submittingConfig, setSubmittingConfig] = React.useState(false) @@ -201,6 +222,8 @@ export default function SSEPage() { const [keys, setKeys] = React.useState([]) const [loadingKeys, setLoadingKeys] = React.useState(false) + const [keysError, setKeysError] = React.useState(null) + const keysRequestRef = React.useRef(0) const [currentMarker, setCurrentMarker] = React.useState("") const [previousMarkers, setPreviousMarkers] = React.useState([]) const [nextMarker, setNextMarker] = React.useState(null) @@ -214,66 +237,119 @@ export default function SSEPage() { const [selectedKeyId, setSelectedKeyId] = React.useState(null) const [loadingKeyDetails, setLoadingKeyDetails] = React.useState(false) const [keyDetails, setKeyDetails] = React.useState(null) + const [keyDetailsError, setKeyDetailsError] = React.useState(null) + const [keyDetailsReloadVersion, setKeyDetailsReloadVersion] = React.useState(0) + const keyDetailsRequestRef = React.useRef(0) const [pendingKeyAction, setPendingKeyAction] = React.useState(null) const [processingKeyAction, setProcessingKeyAction] = React.useState(false) - - const statusKind = React.useMemo(() => getStatusKind(status), [status]) + const [pendingServiceAction, setPendingServiceAction] = React.useState<"start" | "restart" | "stop" | null>(null) + const [pendingNavigation, setPendingNavigation] = React.useState(null) + const allowNavigationRef = React.useRef(false) + const mutationRef = React.useRef(null) + const [activeMutation, setActiveMutation] = React.useState(null) + + const beginMutation = React.useCallback((name: string) => { + if (mutationRef.current) return false + mutationRef.current = name + setActiveMutation(name) + return true + }, []) + + const endMutation = React.useCallback((name: string) => { + if (mutationRef.current !== name) return + mutationRef.current = null + setActiveMutation(null) + }, []) + + const statusKind = React.useMemo(() => (statusError ? "Error" : getStatusKind(status)), [status, statusError]) const isRunning = statusKind === "Running" - const hasConfiguration = statusKind !== "NotConfigured" + const hasConfiguration = !statusError && statusKind !== "NotConfigured" const hasStoredVaultCredentials = status?.config_summary?.backend_summary?.has_stored_credentials === true const statusBadgeValue = statusKind === "Error" ? "Error" : typeof status?.status === "string" ? status.status : statusKind const loadStatus = React.useCallback( async (syncForm = false) => { + const requestId = ++statusRequestRef.current setLoadingStatus(true) + setStatusError(null) try { const res = await getKMSStatus() + if (requestId !== statusRequestRef.current) return res setStatus(res) + setStatusError(null) if (syncForm) { - setFormState((current) => { - const next = buildFormStateFromStatus(res) - if (current.vaultToken && next.backendType !== "local") { - next.vaultToken = current.vaultToken - } - return next - }) + const nextFormState = buildFormStateFromStatus(res) + setFormState(nextFormState) + setBaselineFormState(nextFormState) } + return res } catch (error) { - setStatus(null) + if (requestId !== statusRequestRef.current) throw error + const description = (error as Error).message || t("Failed to load KMS status") + setStatusError(description) throw error } finally { - setLoadingStatus(false) + if (requestId === statusRequestRef.current) setLoadingStatus(false) } }, - [getKMSStatus], + [getKMSStatus, t], ) const loadKeys = React.useCallback( async (marker = "", notifyOnError = true) => { + const requestId = ++keysRequestRef.current setLoadingKeys(true) + setKeysError(null) try { const response = await getKeyList({ limit: KEY_LIST_LIMIT, marker: marker || undefined, }) + if (requestId !== keysRequestRef.current) return false setKeys(response.keys ?? []) setCurrentMarker(marker) setNextMarker(response.truncated ? (response.next_marker ?? null) : null) + return true } catch (error) { - setKeys([]) - setNextMarker(null) + if (requestId !== keysRequestRef.current) return false + const description = (error as Error).message || t("Failed to fetch KMS keys") + setKeysError(description) if (notifyOnError) { - message.error((error as Error).message || t("Failed to fetch KMS keys")) + message.error(description) } + return false } finally { - setLoadingKeys(false) + if (requestId === keysRequestRef.current) setLoadingKeys(false) } }, [getKeyList, message, t], ) + const reconcileMutationState = React.useCallback( + async (showUncertainResult = false) => { + try { + const latestStatus = await loadStatus(false) + if (getStatusKind(latestStatus) === "Running") { + const keysSynced = await loadKeys(currentMarker, false) + if (!keysSynced) { + message.warning(t("Failed to fetch KMS keys")) + return false + } + } + if (showUncertainResult) { + message.warning(t("The operation result is uncertain. Current status has been refreshed.")) + } + return true + } catch (refreshError) { + message.warning((refreshError as Error).message || t("Failed to load KMS status")) + return false + } + }, + [currentMarker, loadKeys, loadStatus, message, t], + ) + React.useEffect(() => { loadStatus(true).catch((error) => { message.error((error as Error).message || t("Failed to load KMS status")) @@ -293,109 +369,205 @@ export default function SSEPage() { }, [isRunning, loadKeys]) React.useEffect(() => { + if (configFormErrorField && ADVANCED_CONFIG_FIELDS.has(configFormErrorField)) { + advancedSettingsRef.current?.setAttribute("open", "") + } + }, [configFormErrorField]) + + React.useEffect(() => { + const requestId = ++keyDetailsRequestRef.current if (!selectedKeyId) { + setLoadingKeyDetails(false) setKeyDetails(null) + setKeyDetailsError(null) return } setLoadingKeyDetails(true) + setKeyDetailsError(null) getKeyDetails(selectedKeyId) .then((response) => { + if (requestId !== keyDetailsRequestRef.current) return setKeyDetails(response.key_metadata ?? null) }) .catch((error) => { + if (requestId !== keyDetailsRequestRef.current) return setKeyDetails(null) - message.error((error as Error).message || t("Failed to load key details")) + const description = (error as Error).message || t("Failed to load key details") + setKeyDetailsError(description) + message.error(description) }) .finally(() => { - setLoadingKeyDetails(false) + if (requestId === keyDetailsRequestRef.current) setLoadingKeyDetails(false) }) - }, [getKeyDetails, message, selectedKeyId, t]) + }, [getKeyDetails, keyDetailsReloadVersion, message, selectedKeyId, t]) const getKmsStatusText = React.useCallback(() => { + if (statusError) return t("Unavailable") if (statusKind === "Running") { return status?.healthy === false ? t("Running (Unhealthy)") : t("Running") } if (statusKind === "Configured") return t("Configured") if (statusKind === "Error") return t("Error") return t("Not Configured") - }, [status?.healthy, statusKind, t]) + }, [status?.healthy, statusError, statusKind, t]) const getKmsStatusDescription = React.useCallback(() => { + if (statusError) return t("Failed to load KMS status") if (statusKind === "Running") { return status?.healthy ? t("KMS server is running and healthy") : t("KMS server is running but unhealthy") } if (statusKind === "Configured") return t("KMS server is configured but not running") if (statusKind === "Error") return t("KMS server has errors") return t("KMS server is not configured") - }, [status?.healthy, statusKind, t]) + }, [status?.healthy, statusError, statusKind, t]) const updateFormState = (key: K, value: ConfigFormState[K]) => { setFormState((current) => ({ ...current, [key]: value })) + setConfigFormError((current) => (current && configFormErrorField === key ? null : current)) + setConfigFormErrorField((current) => (current === key ? null : current)) } - const buildConfigPayload = React.useCallback( - (overrideDefaultKeyId?: string): { payload?: KmsConfigPayload; error?: string } => { - const defaultKeyId = (overrideDefaultKeyId ?? formState.defaultKeyId).trim() - const timeoutSeconds = parseOptionalInteger(formState.timeoutSeconds) - const retryAttempts = parseOptionalInteger(formState.retryAttempts) - const maxCachedKeys = parseOptionalInteger(formState.maxCachedKeys) - const cacheTtlSeconds = parseOptionalInteger(formState.cacheTtlSeconds) - - if (formState.backendType === "local") { - if (!isAbsolutePath(formState.keyDir)) { - return { error: t("Please enter an absolute local key directory path") } - } + const isConfigDirty = React.useMemo( + () => JSON.stringify(formState) !== JSON.stringify(baselineFormState), + [baselineFormState, formState], + ) + + const resetFormToCurrentStatus = () => { + const nextFormState = buildFormStateFromStatus(status) + setFormState(nextFormState) + setBaselineFormState(nextFormState) + setConfigFormError(null) + setConfigFormErrorField(null) + } + const discardConfigChangesAndNavigate = () => { + const navigation = pendingNavigation + if (!navigation || mutationInFlight) { + if (mutationInFlight) + message.warning(t("An operation is still in progress. Wait for it to finish before leaving this page.")) + return + } + allowNavigationRef.current = true + setPendingNavigation(null) + if (navigation.historyDelta) { + window.history.go(navigation.historyDelta) + } else if (navigation.destination) { + window.location.assign(navigation.destination) + } + } + + React.useEffect(() => { + if (!isConfigDirty) return + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (allowNavigationRef.current) return + event.preventDefault() + event.returnValue = "" + } + const handleDocumentNavigation = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + const link = (event.target as Element | null)?.closest("a[href]") + if (!(link instanceof HTMLAnchorElement) || link.target || link.hasAttribute("download")) return + + const destination = new URL(link.href) + if ( + destination.origin !== window.location.origin || + (destination.pathname === window.location.pathname && destination.search === window.location.search) + ) { + return + } + + event.preventDefault() + event.stopImmediatePropagation() + setPendingNavigation({ destination: destination.href }) + } + const previousHistoryState = window.history.state + const guardState = { ...(previousHistoryState ?? {}), __kmsDirtyGuard: true } + window.history.pushState(guardState, "", window.location.href) + const handlePopState = () => { + if (allowNavigationRef.current) return + window.history.go(1) + setPendingNavigation({ historyDelta: -2 }) + } + + window.addEventListener("beforeunload", handleBeforeUnload) + window.addEventListener("popstate", handlePopState) + document.addEventListener("click", handleDocumentNavigation, true) + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload) + window.removeEventListener("popstate", handlePopState) + document.removeEventListener("click", handleDocumentNavigation, true) + if (window.history.state?.__kmsDirtyGuard) { + window.history.replaceState(previousHistoryState, "", window.location.href) + } + } + }, [isConfigDirty]) + + const buildConfigPayload = React.useCallback( + ( + overrideDefaultKeyId?: string, + sourceFormState: ConfigFormState = formState, + sourceHasStoredVaultCredentials = hasStoredVaultCredentials, + ): { payload?: KmsConfigPayload; error?: string; field?: string } => { + const values = sourceFormState + const defaultKeyId = (overrideDefaultKeyId ?? values.defaultKeyId).trim() + const timeoutSeconds = parseOptionalInteger(values.timeoutSeconds) + const retryAttempts = parseOptionalInteger(values.retryAttempts) + const maxCachedKeys = parseOptionalInteger(values.maxCachedKeys) + const cacheTtlSeconds = parseOptionalInteger(values.cacheTtlSeconds) + + if (values.backendType === "local") { return { - payload: { - backend_type: "Local", - key_dir: formState.keyDir.trim(), - file_permissions: parseOptionalInteger(formState.filePermissions) ?? 384, - default_key_id: defaultKeyId || undefined, - timeout_seconds: timeoutSeconds ?? 30, - retry_attempts: retryAttempts ?? 3, - enable_cache: formState.enableCache, - max_cached_keys: formState.enableCache ? (maxCachedKeys ?? 1000) : undefined, - cache_ttl_seconds: formState.enableCache ? (cacheTtlSeconds ?? 3600) : undefined, - }, + error: t( + "Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.", + ), } } - if (!formState.address.trim()) { - return { error: t("Please enter Vault server address") } + if (!values.address.trim()) { + return { error: t("Please enter Vault server address"), field: "vaultAddress" } } - if (!formState.vaultToken.trim() && !hasStoredVaultCredentials) { - return { error: t("Please enter Vault token") } + if (!values.vaultToken.trim() && !sourceHasStoredVaultCredentials) { + return { error: t("Please enter Vault token"), field: "vaultToken" } } - if (!formState.mountPath.trim()) { - return { error: t("Please enter Vault transit mount path") } + if (!values.mountPath.trim()) { + return { error: t("Please enter Vault transit mount path"), field: "transitMountPath" } } return { payload: { - backend_type: formState.backendType === "vault-kv2" ? "VaultKV2" : "VaultTransit", - address: formState.address.trim(), + backend_type: values.backendType === "vault-kv2" ? "VaultKV2" : "VaultTransit", + address: values.address.trim(), auth_method: { Token: { - token: formState.vaultToken.trim(), + token: values.vaultToken.trim(), }, }, - namespace: formState.namespace.trim() || null, - mount_path: formState.mountPath.trim(), - ...(formState.backendType === "vault-kv2" + namespace: values.namespace.trim() || null, + mount_path: values.mountPath.trim(), + ...(values.backendType === "vault-kv2" ? { - kv_mount: formState.kvMount.trim() || null, - key_path_prefix: formState.keyPathPrefix.trim() || null, + kv_mount: values.kvMount.trim() || null, + key_path_prefix: values.keyPathPrefix.trim() || null, } : {}), - skip_tls_verify: formState.skipTlsVerify, + skip_tls_verify: values.skipTlsVerify, default_key_id: defaultKeyId || undefined, timeout_seconds: timeoutSeconds ?? 30, retry_attempts: retryAttempts ?? 3, - enable_cache: formState.enableCache, - max_cached_keys: formState.enableCache ? (maxCachedKeys ?? 1000) : undefined, - cache_ttl_seconds: formState.enableCache ? (cacheTtlSeconds ?? 3600) : undefined, + enable_cache: values.enableCache, + max_cached_keys: values.enableCache ? (maxCachedKeys ?? 1000) : undefined, + cache_ttl_seconds: values.enableCache ? (cacheTtlSeconds ?? 3600) : undefined, }, } }, @@ -403,16 +575,34 @@ export default function SSEPage() { ) const submitConfiguration = React.useCallback( - async (overrideDefaultKeyId?: string, source: "manual" | "defaultKey" = "manual") => { - const { payload, error } = buildConfigPayload(overrideDefaultKeyId) + async ( + overrideDefaultKeyId?: string, + source: "manual" | "defaultKey" = "manual", + sourceFormState?: ConfigFormState, + ) => { + if (statusError || loadingStatus) { + if (source === "manual") message.error(t("Failed to load KMS status")) + return false + } + const nestedCreateMutation = source === "defaultKey" && mutationRef.current === "create-key" + const ownsMutation = nestedCreateMutation || beginMutation("kms-config") + if (!ownsMutation) return false + + const { payload, error, field } = buildConfigPayload(overrideDefaultKeyId, sourceFormState) if (!payload) { + setConfigFormError(error || t("Failed to save KMS configuration")) + setConfigFormErrorField(field ?? null) if (source === "manual") { message.error(error || t("Failed to save KMS configuration")) } + if (source === "manual" && field) document.getElementById(field)?.focus() + if (!nestedCreateMutation) endMutation("kms-config") return false } setSubmittingConfig(true) + setConfigFormError(null) + setConfigFormErrorField(null) try { const response = statusKind === "NotConfigured" ? await configureKMS(payload) : await reconfigureKMS(payload) @@ -426,23 +616,53 @@ export default function SSEPage() { } else { message.success(t("Default SSE key updated successfully")) } - await loadStatus(true) + try { + await loadStatus(true) + } catch (refreshError) { + message.warning((refreshError as Error).message || t("Failed to load KMS status")) + } return true } + try { + await loadStatus(false) + } catch (refreshError) { + message.warning((refreshError as Error).message || t("Failed to load KMS status")) + } message.error(response.message || t("Failed to save KMS configuration")) return false } catch (error) { + try { + await loadStatus(false) + } catch (refreshError) { + message.warning((refreshError as Error).message || t("Failed to load KMS status")) + } message.error((error as Error).message || t("Failed to save KMS configuration")) return false } finally { + setFormState((current) => ({ ...current, vaultToken: "" })) setSubmittingConfig(false) + if (!nestedCreateMutation) endMutation("kms-config") } }, - [buildConfigPayload, configureKMS, loadStatus, message, reconfigureKMS, statusKind, t], + [ + beginMutation, + buildConfigPayload, + configureKMS, + endMutation, + loadStatus, + loadingStatus, + message, + mutationRef, + reconfigureKMS, + statusError, + statusKind, + t, + ], ) const handleRefresh = async () => { + if (activeMutation || loadingStatus) return setRefreshingStatus(true) try { await loadStatus(false) @@ -455,6 +675,7 @@ export default function SSEPage() { } const handleClearCache = async () => { + if (statusError || loadingStatus || !beginMutation("clear-cache")) return setClearingCache(true) try { const result = await clearCache() @@ -467,49 +688,88 @@ export default function SSEPage() { message.error((error as Error).message || t("Failed to clear cache")) } finally { setClearingCache(false) + endMutation("clear-cache") } } const handleStartKMS = async () => { + if (statusError || loadingStatus || !beginMutation("start-kms")) return setStartingKMS(true) try { const force = isRunning const res = await startKMS(force ? { force: true } : {}) if (res?.success) { message.success(force ? t("KMS service restarted successfully") : t("KMS service started successfully")) - await loadStatus(false) + try { + await loadStatus(false) + } catch (refreshError) { + message.warning((refreshError as Error).message || t("Failed to load KMS status")) + } } else { message.error( (force ? t("Failed to restart KMS service") : t("Failed to start KMS service")) + ": " + (res?.message || t("Unknown")), ) + await reconcileMutationState() } } catch (error) { message.error((error as Error).message || t("Failed to start KMS service")) + await reconcileMutationState(true) } finally { setStartingKMS(false) + endMutation("start-kms") } } const handleStopKMS = async () => { + if (statusError || loadingStatus || !beginMutation("stop-kms")) return setStoppingKMS(true) try { const res = await stopKMS() if (res?.success) { message.success(t("KMS service stopped successfully")) - await loadStatus(false) + try { + await loadStatus(false) + } catch (refreshError) { + message.warning((refreshError as Error).message || t("Failed to load KMS status")) + } } else { message.error(t("Failed to stop KMS service") + ": " + (res?.message || t("Unknown"))) + await reconcileMutationState() } } catch (error) { message.error((error as Error).message || t("Failed to stop KMS service")) + await reconcileMutationState(true) } finally { setStoppingKMS(false) + endMutation("stop-kms") + } + } + + const requestStartKMS = () => { + if (statusError || loadingStatus || mutationLocked) return + setPendingServiceAction(isRunning ? "restart" : "start") + } + + const requestStopKMS = () => { + if (statusError || loadingStatus || mutationLocked) return + setPendingServiceAction("stop") + } + + const confirmServiceAction = async () => { + const action = pendingServiceAction + if (!action) return + setPendingServiceAction(null) + if (action === "stop") { + await handleStopKMS() + } else { + await handleStartKMS() } } const handleCreateKey = async () => { + if (statusError || keysError || loadingKeys || loadingStatus || !beginMutation("create-key")) return setCreatingKey(true) try { const tags: Record = { created_by: "console" } @@ -525,6 +785,7 @@ export default function SSEPage() { if (response.success === false) { message.error(response.message || t("Failed to create KMS key")) + await reconcileMutationState() return } @@ -535,7 +796,7 @@ export default function SSEPage() { setCreateKeySetAsDefault(false) if (createKeySetAsDefault && createdKeyId) { - const updated = await submitConfiguration(createdKeyId, "defaultKey") + const updated = await submitConfiguration(createdKeyId, "defaultKey", buildFormStateFromStatus(status)) if (!updated) { message.warning( t("Key created but could not update the default SSE key. Save the configuration form and try again."), @@ -544,26 +805,46 @@ export default function SSEPage() { } if (isRunning) { - await loadKeys(currentMarker, false) + const keysSynced = await loadKeys(currentMarker, false) + if (!keysSynced) message.warning(t("Failed to fetch KMS keys")) } } catch (error) { message.error((error as Error).message || t("Failed to create KMS key")) + setCreateKeyOpen(false) + setCreateKeyName("") + setCreateKeyDescription("") + setCreateKeySetAsDefault(false) + await reconcileMutationState(true) } finally { setCreatingKey(false) + endMutation("create-key") } } const handleConfirmKeyAction = async () => { if (!pendingKeyAction) return + if (statusError || loadingStatus || !beginMutation("key-action")) return setProcessingKeyAction(true) try { + if (pendingKeyAction.type !== "cancelDeletion") { + const latestStatus = await loadStatus(false) + if ( + !latestStatus?.config_summary || + latestStatus.config_summary.default_key_id === pendingKeyAction.key.key_id + ) { + message.error(t("Cannot delete the default SSE key. Choose another default key first.")) + return + } + } if (pendingKeyAction.type === "scheduleDelete") { const response = await deleteKey(pendingKeyAction.key.key_id, { pending_window_in_days: DEFAULT_PENDING_DELETE_DAYS, }) if (response.success === false) { message.error(response.message || t("Failed to schedule key deletion")) + setPendingKeyAction(null) + await reconcileMutationState() return } message.success(t("Key deletion scheduled successfully")) @@ -573,6 +854,8 @@ export default function SSEPage() { const response = await forceDeleteKey(pendingKeyAction.key.key_id) if (response.success === false) { message.error(response.message || t("Failed to force delete key")) + setPendingKeyAction(null) + await reconcileMutationState() return } message.success(t("Key deleted immediately")) @@ -582,17 +865,23 @@ export default function SSEPage() { const response = await cancelKeyDeletion(pendingKeyAction.key.key_id) if (response.success === false) { message.error(response.message || t("Failed to cancel key deletion")) + setPendingKeyAction(null) + await reconcileMutationState() return } message.success(t("Key deletion canceled successfully")) } setPendingKeyAction(null) - await loadKeys(currentMarker, false) + const keysSynced = await loadKeys(currentMarker, false) + if (!keysSynced) message.warning(t("Failed to fetch KMS keys")) } catch (error) { message.error((error as Error).message || t("Failed to update key status")) + setPendingKeyAction(null) + await reconcileMutationState(true) } finally { setProcessingKeyAction(false) + endMutation("key-action") } } @@ -609,6 +898,17 @@ export default function SSEPage() { : pendingKeyAction?.type === "cancelDeletion" ? t("This restores the key from the pending deletion state.") : t("This schedules the key for deletion using the default pending window.") + const serviceActionDescription = + pendingServiceAction === "stop" + ? t("KMS will be stopped. SSE and key management will be unavailable until you start KMS again.") + : t("Changing KMS service state may briefly interrupt SSE requests.") + const isPendingDefaultKey = pendingKeyAction?.key.key_id === status?.config_summary?.default_key_id + + const mutationLocked = Boolean(activeMutation || statusError || loadingStatus) + const localKmsReadOnly = formState.backendType === "local" + const formDisabled = mutationLocked || loadingStatus || submittingConfig || localKmsReadOnly + const mutationInFlight = Boolean(activeMutation || submittingConfig || creatingKey || processingKeyAction) + const canSetCreatedKeyAsDefault = status?.backend_type !== "Local" && !isConfigDirty return ( <> @@ -624,48 +924,66 @@ export default function SSEPage() {
+ {statusError ? ( + + {t("Failed to load KMS status")} + {statusError} + + + ) : null}
- {t("KMS Status Overview")} +

{t("KMS Status Overview")}

{loadingStatus ? t("Loading…") : getKmsStatusText()}
{getKmsStatusDescription()} - {status?.backend_type && ( + {!statusError && status?.backend_type && ( {t("Backend")}: {status.backend_type} )}
-
-
-

{t("Backend Type")}

-

{status?.backend_type ?? t("Not configured")}

-
-
-

{t("Status")}

-

- {loadingStatus ? t("Loading…") : getKmsStatusText()} -

-
-
-

{t("Health")}

-

- {status?.healthy == null ? t("Unknown") : status.healthy ? t("Healthy") : t("Unhealthy")} -

-
-
-

{t("Default SSE Key")}

-

- {status?.config_summary?.default_key_id || t("Not set")} -

+ {!statusError ? ( +
+
+

{t("Backend Type")}

+

{status?.backend_type ?? t("Not configured")}

+
+
+

{t("Status")}

+

+ {loadingStatus ? t("Loading…") : getKmsStatusText()} +

+
+
+

{t("Health")}

+

+ {status?.healthy == null ? t("Unknown") : status.healthy ? t("Healthy") : t("Unhealthy")} +

+
+
+

{t("Default SSE Key")}

+

+ {status?.config_summary?.default_key_id || t("Not set")} +

+
-
+ ) : null} - {statusKind === "Error" && ( + {!statusError && statusKind === "Error" ? ( {t("KMS service has errors")} @@ -674,7 +992,7 @@ export default function SSEPage() { : t("KMS server status unknown")} - )} + ) : null} {statusKind === "Configured" && ( @@ -684,13 +1002,23 @@ export default function SSEPage() { )}
- {hasConfiguration && ( - @@ -706,13 +1034,16 @@ export default function SSEPage() { } /> - + {clearingCache ? t("Clearing cache…") : t("Clear Cache")} {stoppingKMS ? t("Stopping KMS…") : t("Stop KMS")} @@ -726,10 +1057,26 @@ export default function SSEPage() { - {t("KMS Configuration")} +

{t("KMS Configuration")}

{t("Configure the KMS backend, cache policy, and default SSE key.")}
+ {configFormError ? ( + + {t("Please fix the form errors before saving")} + {configFormError} + + ) : null} + {localKmsReadOnly ? ( + + {t("Local filesystem")} + + {t( + "Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.", + )} + + + ) : null}
{ @@ -737,310 +1084,373 @@ export default function SSEPage() { void submitConfiguration() }} > - - - {t("KMS Backend")} - - - - {t("Choose the backend used by SSE/KMS.")} - - - - {t("Default SSE Key")} - - updateFormState("defaultKeyId", event.target.value)} - autoComplete="off" - placeholder={t("Enter the default SSE key ID")} - spellCheck={false} - /> - - - {t("This key is used as the platform default SSE key for SSE-KMS and SSE-S3.")} - - - - - {formState.backendType === "local" ? ( +
+ {t("Backend and default key")} - {t("Local Key Directory")} + {t("KMS Backend")} - updateFormState("keyDir", event.target.value)} - autoComplete="off" - placeholder={t("Enter an absolute path such as D:/data/kms-keys")} - spellCheck={false} - /> + - {t("The directory must be an absolute path on the server.")} + {t("Choose the backend used by SSE/KMS.")} - {t("File Permissions")} + {t("Default SSE Key")} updateFormState("defaultKeyId", event.target.value)} autoComplete="off" - value={formState.filePermissions} - onChange={(event) => updateFormState("filePermissions", event.target.value)} - placeholder="384" + placeholder={t("Enter the default SSE key ID")} + spellCheck={false} + disabled={formDisabled} /> - {t("Use decimal values such as 384 for 0o600.")} + + {t("This key is used as the platform default SSE key for SSE-KMS and SSE-S3.")} + - ) : ( -
- - {t("Vault token authentication only")} - - {t("Vault AppRole is not supported in the first version of this console.")} - - +
+
+ + {formState.backendType === "local" ? t("Local filesystem") : t("Vault connection")} + + {formState.backendType === "local" ? ( - {t("Vault Server Address")} + {t("Local Key Directory")} updateFormState("address", event.target.value)} - autoComplete="off" - placeholder="http://127.0.0.1:8200" - spellCheck={false} - /> - - - - - {t("Vault Token")} - - updateFormState("vaultToken", event.target.value)} - placeholder={ - hasStoredVaultCredentials - ? t("Stored token is hidden. Enter a new token only to replace it.") - : t("Enter your Vault authentication token") - } + id="keyDir" + name="keyDir" + value={formState.keyDir} + onChange={(event) => updateFormState("keyDir", event.target.value)} autoComplete="off" + placeholder={t("Enter an absolute path such as D:/data/kms-keys")} spellCheck={false} + disabled={formDisabled} + required + aria-required="true" + aria-invalid={configFormErrorField === "keyDir"} + aria-describedby={configFormErrorField === "keyDir" ? "kms-config-error" : undefined} /> - {hasStoredVaultCredentials - ? t("Leave blank to keep the stored Vault token.") - : t("Required: Vault authentication token")} + {t("The directory must be an absolute path on the server.")} - - - {t("Vault Namespace")} + {t("File Permissions")} updateFormState("namespace", event.target.value)} + id="filePermissions" + name="filePermissions" + type="number" + inputMode="numeric" + min={0} autoComplete="off" - placeholder={t("Optional Vault namespace")} - spellCheck={false} + value={formState.filePermissions} + onChange={(event) => updateFormState("filePermissions", event.target.value)} + placeholder="384" + disabled={formDisabled} /> + {t("Use decimal values such as 384 for 0o600.")} + + ) : ( +
+ + {t("Vault token authentication only")} + + {t("Vault AppRole is not supported in the first version of this console.")} + + + + + + {t("Vault Server Address")} + + updateFormState("address", event.target.value)} + autoComplete="off" + placeholder="http://127.0.0.1:8200" + spellCheck={false} + disabled={formDisabled} + required + aria-required="true" + aria-invalid={configFormErrorField === "vaultAddress"} + aria-describedby={ + configFormErrorField === "vaultAddress" ? "kms-config-error" : undefined + } + /> + + + + + {t("Vault Token")} + + updateFormState("vaultToken", event.target.value)} + placeholder={ + hasStoredVaultCredentials + ? t("Stored token is hidden. Enter a new token only to replace it.") + : t("Enter your Vault authentication token") + } + autoComplete="off" + spellCheck={false} + disabled={formDisabled} + required={!hasStoredVaultCredentials} + aria-required={!hasStoredVaultCredentials ? "true" : "false"} + aria-invalid={configFormErrorField === "vaultToken"} + aria-describedby={configFormErrorField === "vaultToken" ? "kms-config-error" : undefined} + /> + + + {hasStoredVaultCredentials + ? t("Leave blank to keep the stored Vault token.") + : t("Required: Vault authentication token")} + + + + + + + {t("Vault Namespace")} + + updateFormState("namespace", event.target.value)} + autoComplete="off" + placeholder={t("Optional Vault namespace")} + disabled={formDisabled} + spellCheck={false} + /> + + + + + {t("Transit Mount Path")} + + updateFormState("mountPath", event.target.value)} + autoComplete="off" + placeholder="transit" + disabled={formDisabled} + required + aria-required="true" + aria-invalid={configFormErrorField === "transitMountPath"} + aria-describedby={ + configFormErrorField === "transitMountPath" ? "kms-config-error" : undefined + } + spellCheck={false} + /> + + + + {formState.backendType === "vault-kv2" && ( + <> + + {t("KV Mount")} + + updateFormState("kvMount", event.target.value)} + autoComplete="off" + placeholder="secret" + disabled={formDisabled} + spellCheck={false} + /> + + + + + {t("Key Path Prefix")} + + updateFormState("keyPathPrefix", event.target.value)} + autoComplete="off" + placeholder="rustfs/kms/keys" + disabled={formDisabled} + spellCheck={false} + /> + + + + )} + + +
+ updateFormState("skipTlsVerify", checked === true)} + id="skipTlsVerify" + disabled={formDisabled} + /> + +
+
+ )} +
- - {t("Transit Mount Path")} - - updateFormState("mountPath", event.target.value)} - autoComplete="off" - placeholder="transit" - spellCheck={false} - /> - - +
+ + {t("Advanced Settings")} + + {t("Reliability")} · {t("KMS Cache")} + + + +
+
+ {t("Reliability")} + + + {t("Timeout (seconds)")} + + updateFormState("timeoutSeconds", event.target.value)} + disabled={formDisabled} + /> + + + + + {t("Retry Attempts")} + + updateFormState("retryAttempts", event.target.value)} + disabled={formDisabled} + /> + + + +
+ +
+ {t("KMS Cache")} +
+ updateFormState("enableCache", checked === true)} + id="enableCache" + disabled={formDisabled} + /> + +
- {formState.backendType === "vault-kv2" && ( - <> + {formState.enableCache && ( + - {t("KV Mount")} + {t("Max Cached Keys")} updateFormState("kvMount", event.target.value)} + id="maxCachedKeys" + name="maxCachedKeys" + type="number" + inputMode="numeric" + min={1} autoComplete="off" - placeholder="secret" - spellCheck={false} + value={formState.maxCachedKeys} + onChange={(event) => updateFormState("maxCachedKeys", event.target.value)} + disabled={formDisabled} /> - {t("Key Path Prefix")} + {t("Cache TTL (seconds)")} updateFormState("keyPathPrefix", event.target.value)} + id="cacheTtlSeconds" + name="cacheTtlSeconds" + type="number" + inputMode="numeric" + min={1} autoComplete="off" - placeholder="rustfs/kms/keys" - spellCheck={false} + value={formState.cacheTtlSeconds} + onChange={(event) => updateFormState("cacheTtlSeconds", event.target.value)} + disabled={formDisabled} /> - + )} - - -
- updateFormState("skipTlsVerify", checked === true)} - id="skipTlsVerify" - /> - -
+
- )} - - - - {t("Timeout (seconds)")} - - updateFormState("timeoutSeconds", event.target.value)} - /> - - - - - {t("Retry Attempts")} - - updateFormState("retryAttempts", event.target.value)} - /> - - - - -
-
- updateFormState("enableCache", checked === true)} - id="enableCache" - /> - -
- - {formState.enableCache && ( - - - {t("Max Cached Keys")} - - updateFormState("maxCachedKeys", event.target.value)} - /> - - - - - {t("Cache TTL (seconds)")} - - updateFormState("cacheTtlSeconds", event.target.value)} - /> - - - - )} -
+
- @@ -1054,7 +1464,7 @@ export default function SSEPage() {
- {t("KMS Keys Management")} +

{t("KMS Keys Management")}

{t("Create, rotate, and inspect the keys managed by your KMS backend.")} @@ -1063,13 +1473,17 @@ export default function SSEPage() { - @@ -1084,87 +1498,203 @@ export default function SSEPage() { + {keysError ? ( + + {t("Failed to fetch KMS keys")} + {keysError} + + + ) : null} + {loadingKeys ? (
- ) : keys.length === 0 ? ( + ) : keys.length === 0 && !keysError ? (
{t("No KMS keys found")}
- ) : ( - - - - {t("Name")} - {t("KMS Key ID")} - {t("Status")} - {t("Algorithm")} - {t("Usage")} - {t("Created")} - {t("Actions")} - - - + ) : keys.length > 0 && !keysError ? ( + <> +
{keys.map((key) => { + const isDefaultKey = key.key_id === status?.config_summary?.default_key_id const isPendingDeletion = (key.status ?? "").toLowerCase().includes("pendingdeletion") return ( - - {getKeyDisplayName(key)} - - {key.key_id} - - +
+
+
+

{getKeyDisplayName(key)}

+

{key.key_id}

+
{key.status || "-"} - - {key.algorithm || "-"} - {key.usage || "-"} - {formatTimestamp(key.created_at)} - -
- - - {t("Actions")} - - } - /> - - setSelectedKeyId(key.key_id)}> - {t("View Details")} - - {!isPendingDeletion && ( - setPendingKeyAction({ type: "scheduleDelete", key })} - > - {t("Schedule Deletion")} - - )} - {isPendingDeletion && ( - setPendingKeyAction({ type: "cancelDeletion", key })} - > - {t("Cancel Deletion")} - - )} - - setPendingKeyAction({ type: "forceDelete", key })} - className="text-destructive focus:text-destructive" - > - {t("Delete Immediately")} - - - +
+ {isDefaultKey ? ( +

{t("Default SSE Key")}

+ ) : null} +
+
+
{t("Algorithm")}
+
{key.algorithm || "-"}
- - +
+
{t("Created")}
+
{formatTimestamp(key.created_at)}
+
+
+
+ + {isPendingDeletion ? ( + + ) : ( + + )} + +
+
) })} - -
- )} +
+
+ + {t("KMS Keys Management")} + + + {t("Name")} + {t("KMS Key ID")} + {t("Status")} + {t("Algorithm")} + {t("Usage")} + {t("Created")} + + {t("Actions")} + + + + + {keys.map((key) => { + const isPendingDeletion = (key.status ?? "").toLowerCase().includes("pendingdeletion") + const isDefaultKey = key.key_id === status?.config_summary?.default_key_id + return ( + + {getKeyDisplayName(key)} + + {key.key_id} + + + {key.status || "-"} + + {key.algorithm || "-"} + {key.usage || "-"} + {formatTimestamp(key.created_at)} + +
+ + + {t("Actions")} + + } + /> + + setSelectedKeyId(key.key_id)}> + {t("View Details")} + + {!isPendingDeletion && ( + setPendingKeyAction({ type: "scheduleDelete", key })} + disabled={ + isDefaultKey || + Boolean(activeMutation) || + loadingStatus || + Boolean(keysError) + } + > + {t("Schedule Deletion")} + + )} + {isPendingDeletion && ( + setPendingKeyAction({ type: "cancelDeletion", key })} + disabled={Boolean(activeMutation) || loadingStatus || Boolean(keysError)} + > + {t("Cancel Deletion")} + + )} + + setPendingKeyAction({ type: "forceDelete", key })} + disabled={ + isDefaultKey || + Boolean(activeMutation) || + loadingStatus || + Boolean(keysError) + } + className="text-destructive focus:text-destructive" + > + {t("Delete Immediately")} + + + +
+
+
+ ) + })} +
+
+
+ + ) : null}

@@ -1174,7 +1704,13 @@ export default function SSEPage() { - @@ -1283,73 +1842,102 @@ export default function SSEPage() { !open && setSelectedKeyId(null)} direction="right"> - - + + {t("KMS Key Details")} - {selectedKeyId || ""} + {selectedKeyId || ""} + + + -

+
{loadingKeyDetails ? (
+ ) : keyDetailsError ? ( + + {t("Failed to load key details")} + {keyDetailsError} + + ) : !keyDetails ? (
{t("No key details available")}
) : ( <> -
-
-

{t("Name")}

-

{getKeyDisplayName(keyDetails)}

+
+
+
{t("Name")}
+
{getKeyDisplayName(keyDetails)}
-
-

{t("State")}

-
+
+
{t("State")}
+
{keyDetails.key_state || "-"} -
+
-
-

{t("KMS Key ID")}

-

{keyDetails.key_id}

+
+
{t("KMS Key ID")}
+
{keyDetails.key_id}
-
-

{t("Usage")}

-

{keyDetails.key_usage || "-"}

+
+
{t("Usage")}
+
{keyDetails.key_usage || "-"}
-
-

{t("Creation Date")}

-

{formatTimestamp(keyDetails.creation_date)}

+
+
{t("Creation Date")}
+
{formatTimestamp(keyDetails.creation_date)}
-
-

{t("Deletion Date")}

-

{formatTimestamp(keyDetails.deletion_date)}

+
+
{t("Deletion Date")}
+
{formatTimestamp(keyDetails.deletion_date)}
-
-

{t("Origin")}

-

{keyDetails.origin || "-"}

+
+
{t("Origin")}
+
{keyDetails.origin || "-"}
-
-

{t("Key Manager")}

-

{keyDetails.key_manager || "-"}

+
+
{t("Key Manager")}
+
{keyDetails.key_manager || "-"}
-
+
{keyDetails.description && ( -
+

{t("Description")}

-

{keyDetails.description}

+

{keyDetails.description}

)} -
+

{t("Tags")}

{Object.entries(keyDetails.tags ?? {}).length > 0 ? ( Object.entries(keyDetails.tags ?? {}).map(([key, value]) => ( - + {key}: {value} )) @@ -1364,17 +1952,104 @@ export default function SSEPage() { - !open && setPendingKeyAction(null)}> + { + if (!open && !startingKMS && !stoppingKMS) setPendingServiceAction(null) + }} + > + + + + {pendingServiceAction === "stop" + ? t("Stop KMS") + : pendingServiceAction === "restart" + ? t("Restart KMS") + : t("Start KMS")} + + {serviceActionDescription} + + + {t("Cancel")} + + {startingKMS || stoppingKMS ? : null} + {pendingServiceAction === "stop" ? t("Stop KMS") : t("Confirm")} + + + + + + !open && setPendingNavigation(null)}> + + + {t("Discard Changes")} + + {t("You have unsaved KMS changes. Do you want to discard them?")} + {mutationInFlight ? ( + + {t("An operation is still in progress. Wait for it to finish before leaving this page.")} + + ) : null} + + + + {t("Cancel")} + + {t("Discard")} + + + + + + { + if (!open && !processingKeyAction) setPendingKeyAction(null) + }} + > {keyActionTitle} - {keyActionDescription} + + {keyActionDescription} + + {t("KMS Key ID")}: {pendingKeyAction?.key.key_id} + + {isPendingDefaultKey && pendingKeyAction?.type !== "cancelDeletion" ? ( + + {t("Cannot delete the default SSE key. Choose another default key first.")} + + ) : null} + {t("Cancel")} - + {processingKeyAction ? : null} - {pendingKeyAction?.type === "cancelDeletion" ? t("Restore Key") : t("Confirm")} + {pendingKeyAction?.type === "cancelDeletion" + ? t("Restore Key") + : pendingKeyAction?.type === "forceDelete" + ? t("Delete Key Immediately") + : t("Schedule Key Deletion")} diff --git a/app/(dashboard)/status/page.tsx b/app/(dashboard)/status/page.tsx index fb0ba9c7..fda00c40 100644 --- a/app/(dashboard)/status/page.tsx +++ b/app/(dashboard)/status/page.tsx @@ -4,139 +4,139 @@ import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" -import { usePerformanceData } from "@/hooks/use-performance-data" -import { niceBytes } from "@/lib/functions" -import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data" +import { usePerformanceData, type PerformanceDataSource } from "@/hooks/use-performance-data" +import { usePermissions } from "@/hooks/use-permissions" +import { formatRelativeTime, summarizeServerStates } from "@/lib/performance-data" +import { cn } from "@/lib/utils" import { RiArchiveDrawerFill, RiArchiveLine, - RiHardDrive2Line, RiListSettingsFill, RiRefreshLine, RiSecurePaymentFill, RiStackLine, } from "@remixicon/react" import dayjs from "dayjs" -import relativeTime from "dayjs/plugin/relativeTime" import { useMemo } from "react" import { useTranslation } from "react-i18next" -import { usePermissions } from "@/hooks/use-permissions" -import { PerformanceSummaryCards } from "../_components/performance-summary-cards" -import { PerformanceUsageCard } from "../_components/performance-usage-card" -import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card" import { PerformanceBackendCard } from "../_components/performance-backend-card" +import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card" import { PerformanceServerList } from "../_components/performance-server-list" +import { PerformanceSummaryCards } from "../_components/performance-summary-cards" +import { PerformanceUsageCard } from "../_components/performance-usage-card" -dayjs.extend(relativeTime) +function formatDuration(seconds: number | undefined, t: (key: string) => string) { + if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown") + const totalMinutes = Math.floor(seconds / 60) + const days = Math.floor(totalMinutes / 1440) + const hours = Math.floor((totalMinutes % 1440) / 60) + const minutes = totalMinutes % 60 + const parts = [ + days ? `${days} ${t("Days")}` : "", + hours ? `${hours} ${t("Hours")}` : "", + !days && minutes ? `${minutes} ${t("Minutes")}` : "", + ].filter(Boolean) + return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}` +} export default function PerformancePage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { canAccessPath } = usePermissions() - const { systemInfo, metricsInfo, datausageinfo, storageinfo, loading, error, refetch } = usePerformanceData() + const { + systemInfo, + metricsInfo, + datausageinfo, + storageinfo, + loading, + refreshing, + hasLoaded, + error, + sourceErrors, + lastUpdatedAt, + metricsUpdatedAt, + refetch, + } = usePerformanceData() const browserHref = canAccessPath("/browser") ? "/browser" : undefined - const numberFormatter = useMemo(() => new Intl.NumberFormat(), []) - - const storageBackend = useMemo( - () => storageinfo?.backend ?? (storageinfo as { Backend?: typeof storageinfo.backend })?.Backend, - [storageinfo], - ) + const numberFormatter = useMemo(() => new Intl.NumberFormat(i18n.resolvedLanguage), [i18n.resolvedLanguage]) + const storageBackend = storageinfo.backend + const totalCapacity = datausageinfo.total_capacity + const totalUsedCapacity = datausageinfo.total_used_capacity const usedPercent = useMemo(() => { - const total = Number(datausageinfo.total_capacity || 0) - if (!total) return 0 - const used = Number(datausageinfo.total_used_capacity || 0) - return Math.min(100, Math.max(0, (used / total) * 100)) - }, [datausageinfo]) - - const lastUpdatedLabel = useMemo(() => { - const last = metricsInfo?.aggregated?.scanner?.current_started - const time = dayjs(last) - return time.isValid() ? time.fromNow() : "--" - }, [metricsInfo]) + if (totalCapacity === undefined || totalUsedCapacity === undefined) return undefined + if (totalCapacity === 0) return totalUsedCapacity === 0 ? 0 : undefined + return Math.min(100, Math.max(0, (totalUsedCapacity / totalCapacity) * 100)) + }, [totalCapacity, totalUsedCapacity]) const summaryMetrics = useMemo( () => [ { label: t("Buckets"), - display: numberFormatter.format(systemInfo?.buckets?.count ?? 0), + display: + systemInfo.buckets?.count === undefined ? t("Unknown") : numberFormatter.format(systemInfo.buckets.count), icon: RiArchiveLine, caption: null as string | null, href: browserHref, }, { label: t("Objects"), - display: numberFormatter.format(systemInfo?.objects?.count ?? 0), + display: + systemInfo.objects?.count === undefined ? t("Unknown") : numberFormatter.format(systemInfo.objects.count), icon: RiStackLine, caption: null as string | null, href: browserHref, }, - { - label: t("Total Capacity"), - display: datausageinfo.total_capacity ? niceBytes(String(datausageinfo.total_capacity)) : "--", - icon: RiHardDrive2Line, - caption: datausageinfo.total_used_capacity - ? `${t("Used")}: ${niceBytes(String(datausageinfo.total_used_capacity))}` - : null, - href: undefined as string | undefined, - }, ], - [systemInfo, datausageinfo, numberFormatter, t, browserHref], + [browserHref, numberFormatter, systemInfo.buckets, systemInfo.objects, t], ) - const fromLastStartTime = useMemo(() => { - const times = metricsInfo?.aggregated?.scanner?.cycle_complete_times || [] - if (!times.length) return "--" - const start = dayjs(times[times.length - 1]) - return dayjs().from(start) - }, [metricsInfo]) - - const fromLastScanTime = useMemo(() => { - const start = dayjs(metricsInfo?.aggregated?.scanner?.current_started) - if (!start.isValid()) return "--" - return dayjs().from(start) - }, [metricsInfo]) - - const lastScanTime = useMemo(() => { - const currentStart = dayjs(metricsInfo?.aggregated?.scanner?.current_started) - const cycleTimes = metricsInfo?.aggregated?.scanner?.cycle_complete_times || [] - if (!currentStart.isValid()) return "--" - const lastComplete = dayjs(cycleTimes[cycleTimes.length - 1]) - return lastComplete.isValid() && currentStart.isBefore(lastComplete) - ? lastComplete.from(currentStart) - : dayjs().from(currentStart) - }, [metricsInfo]) + const scannerStartedAt = metricsInfo.aggregated?.scanner?.current_started + const scannerCycle = metricsInfo.aggregated?.scanner?.current_cycle + const scannerCompleteTimes = metricsInfo.aggregated?.scanner?.cycle_complete_times ?? [] + const scannerCompletedAt = scannerCompleteTimes.at(-1) + const scannerActive = (scannerCycle ?? 0) > 0 + const scannerStatus = scannerActive ? t("Scanning") : scannerCompletedAt ? t("Idle") : t("Never run") + const scannerDuration = useMemo(() => { + if (!scannerStartedAt) return undefined + const started = dayjs(scannerStartedAt) + if (!started.isValid()) return undefined + if (scannerActive) return Math.max(0, dayjs(metricsUpdatedAt ?? undefined).diff(started, "second")) + if (!scannerCompletedAt) return undefined + const completed = dayjs(scannerCompletedAt) + return completed.isValid() && completed.isAfter(started) + ? Math.max(0, completed.diff(started, "second")) + : undefined + }, [metricsUpdatedAt, scannerActive, scannerCompletedAt, scannerStartedAt]) const usageStats = useMemo( () => [ - { label: t("Last Normal Operation"), value: fromLastStartTime }, - { label: t("Last Scan Activity"), value: fromLastScanTime }, - { label: t("Uptime"), value: lastScanTime }, + { + label: t("Last Completed Scan"), + value: scannerCompletedAt + ? (formatRelativeTime(scannerCompletedAt, i18n.resolvedLanguage, metricsUpdatedAt?.getTime()) ?? t("Unknown")) + : t("Unknown"), + }, + { + label: t("Scanner Status"), + value: scannerStatus, + }, + { label: t("Scan Duration"), value: formatDuration(scannerDuration, t) }, ], - [t, fromLastStartTime, fromLastScanTime, lastScanTime], + [i18n.resolvedLanguage, metricsUpdatedAt, scannerCompletedAt, scannerDuration, scannerStatus, t], ) - const serverCounts = useMemo(() => { - const counts: Record = { - online: 0, - offline: 0, - unknown: 0, - degraded: 0, - } - - for (const server of systemInfo?.servers ?? []) { - counts[normalizeServerHealthState(server.state)] += 1 - } - - return counts - }, [systemInfo]) + const serverSummary = useMemo( + () => (systemInfo.servers ? summarizeServerStates(systemInfo.servers) : undefined), + [systemInfo.servers], + ) const backendInfo = useMemo( () => [ { icon: RiArchiveDrawerFill, title: t("Backend Type"), - value: systemInfo?.backend?.backendType, + value: systemInfo.backend?.backendType ?? storageBackend?.BackendType, }, { icon: RiSecurePaymentFill, @@ -149,17 +149,43 @@ export default function PerformancePage() { value: storageBackend?.RRSCParity, }, ], - [systemInfo, storageBackend, t], + [storageBackend, systemInfo.backend?.backendType, t], ) - if (loading && !Object.keys(systemInfo).length && !Object.keys(datausageinfo).length) { + const unavailableSources = Object.keys(sourceErrors) as PerformanceDataSource[] + const sourceLabels: Record = { + system: t("Cluster information"), + usage: t("Storage Usage Statistics"), + storage: t("Storage Configuration"), + metrics: t("Scanner metrics"), + } + + const refreshAction = ( +
+ {lastUpdatedAt ? ( + + {t("Last refreshed")}: {formatRelativeTime(lastUpdatedAt, i18n.resolvedLanguage) ?? t("Unknown")} + + ) : null} + +
+ ) + + if (loading && !hasLoaded) { return (

{t("Running Status")}

-
- +
+ + {t("Loading…")}
) @@ -168,18 +194,21 @@ export default function PerformancePage() { if (error) { return ( - - - {t("Sync")} - - } - > +

{t("Running Status")}

-
-

{error}

+
+

{t(error)}

+

+ {t("Check your connection, then refresh the status page.")} +

+
) @@ -187,42 +216,62 @@ export default function PerformancePage() { return ( - - - {t("Sync")} - - } - > +

{t("Running Status")}

-
- +
+ + {refreshing + ? t("Refreshing…") + : !unavailableSources.length && lastUpdatedAt + ? t("Status refreshed successfully") + : ""} + - + {unavailableSources.length ? ( +
+

{t("Some status data is unavailable.")}

+

+ {t("Previously loaded values may be stale.")} {t("Unavailable")}:{" "} + {unavailableSources.map((source) => sourceLabels[source]).join(", ")}. {t("Refresh to try again.")} +

+
+ ) : null} - +
+
+ +
- +
+ +
- +
+ +
+
+ + + +
) diff --git a/components/access-keys/edit-item.tsx b/components/access-keys/edit-item.tsx index 42cda218..7fec505f 100644 --- a/components/access-keys/edit-item.tsx +++ b/components/access-keys/edit-item.tsx @@ -3,11 +3,12 @@ import * as React from "react" import { useTranslation } from "react-i18next" import dayjs from "dayjs" +import { RiRefreshLine } from "@remixicon/react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" -import { Field, FieldContent, FieldLabel } from "@/components/ui/field" +import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field" import { Switch } from "@/components/ui/switch" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DateTimePicker } from "@/components/datetime-picker" @@ -41,58 +42,95 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces const [description, setDescription] = React.useState("") const [status, setStatus] = React.useState<"on" | "off">("on") const [submitting, setSubmitting] = React.useState(false) + const [loading, setLoading] = React.useState(false) + const [loadError, setLoadError] = React.useState("") + const [policyError, setPolicyError] = React.useState("") + const [loadVersion, setLoadVersion] = React.useState(0) const [initialName, setInitialName] = React.useState("") const [initialDescription, setInitialDescription] = React.useState("") const minExpiry = React.useMemo(() => dayjs().toISOString(), []) React.useEffect(() => { - if (open && row?.accessKey) { - getServiceAccount(row.accessKey) - .then( - (res: { - policy?: unknown - expiration?: string - name?: string - description?: string - accountStatus?: string - }) => { - setAccesskey(row.accessKey) - let policyValue: string - if (typeof res.policy === "string") { - try { - policyValue = JSON.stringify(JSON.parse(res.policy), null, 2) - } catch { - policyValue = res.policy - } - } else { - policyValue = JSON.stringify(res.policy ?? {}, null, 2) + if (!open || !row?.accessKey) return + + let cancelled = false + const currentAccessKey = row.accessKey + + setAccesskey(currentAccessKey) + setPolicy("") + setExpiry(null) + setName("") + setDescription("") + setStatus("on") + setInitialName("") + setInitialDescription("") + setPolicyError("") + setLoadError("") + setLoading(true) + + getServiceAccount(currentAccessKey) + .then( + (res: { + policy?: unknown + expiration?: string + name?: string + description?: string + accountStatus?: string + }) => { + if (cancelled) return + let policyValue: string + if (typeof res.policy === "string") { + try { + policyValue = JSON.stringify(JSON.parse(res.policy), null, 2) + } catch { + policyValue = res.policy } - setPolicy(policyValue) - setExpiry( - res.expiration && res.expiration !== "9999-01-01T00:00:00Z" ? dayjs(res.expiration).toISOString() : null, - ) - const initialNameValue = res.name ?? "" - const initialDescriptionValue = res.description ?? "" - setName(initialNameValue) - setDescription(initialDescriptionValue) - setInitialName(initialNameValue) - setInitialDescription(initialDescriptionValue) - setStatus((res.accountStatus as "on" | "off") ?? "on") - }, - ) - .catch(() => { - message.error(t("Failed to get data")) - }) + } else { + policyValue = JSON.stringify(res.policy ?? {}, null, 2) + } + setPolicy(policyValue) + setExpiry( + res.expiration && res.expiration !== "9999-01-01T00:00:00Z" ? dayjs(res.expiration).toISOString() : null, + ) + const initialNameValue = res.name ?? "" + const initialDescriptionValue = res.description ?? "" + setName(initialNameValue) + setDescription(initialDescriptionValue) + setInitialName(initialNameValue) + setInitialDescription(initialDescriptionValue) + setStatus((res.accountStatus as "on" | "off") ?? "on") + }, + ) + .catch((error) => { + if (cancelled) return + const reason = (error as Error)?.message || t("Failed to get data") + setLoadError(reason) + message.error(reason) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true } - }, [open, row?.accessKey, getServiceAccount, message, t]) + }, [open, row?.accessKey, getServiceAccount, loadVersion, message, t]) const closeModal = () => { onOpenChange(false) } + const handleOpenChange = (nextOpen: boolean) => { + if (!submitting || nextOpen) { + onOpenChange(nextOpen) + } + } + const submitForm = async () => { - if (!accesskey) return + if (!accesskey || loading || loadError) return + if (submitting) return + setPolicyError("") // Validate policy JSON format before submitting if (policy) { @@ -100,15 +138,15 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces const parsed = JSON.parse(policy) // Basic structure validation if (typeof parsed !== "object" || parsed === null) { - message.error(t("Policy must be a valid JSON object")) + setPolicyError(t("Policy must be a valid JSON object")) return } if (parsed.Statement !== undefined && !Array.isArray(parsed.Statement)) { - message.error(t("Policy Statement must be an array")) + setPolicyError(t("Policy Statement must be an array")) return } } catch { - message.error(t("Policy format is invalid. Please check the JSON syntax.")) + setPolicyError(t("Policy format is invalid. Please check the JSON syntax.")) return } } @@ -141,101 +179,162 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces } return ( - - - + + + {t("Edit Key")} -
-
- - {t("Access Key")} - - - - - - - {t("Expiry")} - - - - - - - {t("Name")} - - setName(e.target.value)} - autoComplete="off" - spellCheck={false} - /> - - - - - {t("Description")} - -