diff --git a/app/(auth)/config/page.tsx b/app/(auth)/config/page.tsx index ea24a2bc..094a0141 100644 --- a/app/(auth)/config/page.tsx +++ b/app/(auth)/config/page.tsx @@ -102,13 +102,13 @@ function ConfigPageContent() { } return ( -
-
-
+
+
+
-
-
+
+

diff --git a/app/(dashboard)/403/page.tsx b/app/(dashboard)/403/page.tsx index 76983d97..9af73188 100644 --- a/app/(dashboard)/403/page.tsx +++ b/app/(dashboard)/403/page.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation" import { useTranslation } from "react-i18next" -import { Empty, EmptyContent, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from "@/components/ui/empty" +import { RiArrowLeftLine } from "@remixicon/react" import { Button } from "@/components/ui/button" import { useAuth } from "@/contexts/auth-context" import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route" @@ -26,36 +26,22 @@ export default function ForbiddenPage() { } return ( -
- - - - - - - - - - - - {t("Access Denied")} - - {t( - "You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.", - )} - - - - - -
+
+ +
+

{t("Access Denied")}

+

+ {t( + "You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.", + )} +

+ +
+
) } diff --git a/app/(dashboard)/_components/performance-backend-card.tsx b/app/(dashboard)/_components/performance-backend-card.tsx index 1f0ba951..67b14a19 100644 --- a/app/(dashboard)/_components/performance-backend-card.tsx +++ b/app/(dashboard)/_components/performance-backend-card.tsx @@ -1,7 +1,5 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" - export interface BackendInfoItem { icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }> title: string @@ -10,15 +8,15 @@ export interface BackendInfoItem { export function PerformanceBackendCard({ items, t }: { items: BackendInfoItem[]; t: (key: string) => string }) { return ( - - +
+

{t("Storage Configuration")}

- {t("Parity and backend settings reported by the cluster.")} - - -
+

{t("Parity and backend settings reported by the cluster.")}

+
+
+
{items.map((item) => (
))}
- - +
+
) } diff --git a/app/(dashboard)/_components/performance-infrastructure-card.tsx b/app/(dashboard)/_components/performance-infrastructure-card.tsx index a1c988f6..82c25b53 100644 --- a/app/(dashboard)/_components/performance-infrastructure-card.tsx +++ b/app/(dashboard)/_components/performance-infrastructure-card.tsx @@ -1,7 +1,6 @@ "use client" import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" import type { RunningStatusTopology, StatusDiagnostic } from "@/lib/performance-data" export type InfrastructureHealthState = "healthy" | "attention" | "unknown" @@ -52,9 +51,9 @@ export function getInfrastructureHealthState({ function HealthMetric({ label, value, unavailableLabel }: { label: string; value?: number; unavailableLabel: string }) { return ( -
+
{label}
-
{value ?? unavailableLabel}
+
{value ?? unavailableLabel}
) } @@ -112,14 +111,14 @@ export function PerformanceInfrastructureCard({ : t("All reported servers and disks are online.") return ( - - +
+

{t("Cluster Health")}

- {description} +

{description}

- - +
+

@@ -185,7 +184,7 @@ export function PerformanceInfrastructureCard({ ) : null}

- - +
+
) } diff --git a/app/(dashboard)/_components/performance-status-summary.tsx b/app/(dashboard)/_components/performance-status-summary.tsx index 6c621d39..8e6c80bb 100644 --- a/app/(dashboard)/_components/performance-status-summary.tsx +++ b/app/(dashboard)/_components/performance-status-summary.tsx @@ -57,7 +57,8 @@ export function PerformanceStatusSummary({ return (
diff --git a/app/(dashboard)/_components/performance-summary-cards.tsx b/app/(dashboard)/_components/performance-summary-cards.tsx index 1c9c2194..04952fbf 100644 --- a/app/(dashboard)/_components/performance-summary-cards.tsx +++ b/app/(dashboard)/_components/performance-summary-cards.tsx @@ -2,8 +2,6 @@ import * as React from "react" import Link from "next/link" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { cn } from "@/lib/utils" export interface SummaryMetric { label: string @@ -15,42 +13,36 @@ export interface SummaryMetric { export function PerformanceSummaryCards({ metrics, title }: { metrics: SummaryMetric[]; title: string }) { return ( -
-

+
+

{title}

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

{metric.display}

- {metric.caption ?

{metric.caption}

: null} -
-
-
- ) - return metric.href ? ( - - {cardContent} - - ) : ( - {cardContent} + return ( +
+
+ + {metric.label} +
+
+ {metric.href ? ( + + {metric.display} + + ) : ( + metric.display + )} +
+ {metric.caption ?
{metric.caption}
: null} +
) })} -
+
) } diff --git a/app/(dashboard)/_components/performance-usage-card.tsx b/app/(dashboard)/_components/performance-usage-card.tsx index 668719bb..6b641492 100644 --- a/app/(dashboard)/_components/performance-usage-card.tsx +++ b/app/(dashboard)/_components/performance-usage-card.tsx @@ -1,9 +1,7 @@ "use client" -import { RiDatabase2Line } from "@remixicon/react" import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { Progress } from "@/components/ui/progress" import { niceBytes } from "@/lib/functions" import type { StatusDiagnostic } from "@/lib/performance-data" @@ -33,7 +31,7 @@ export function PerformanceUsageCard({ const freshnessNeedsAttention = usageFreshness?.state === "degraded" || usageFreshness?.state === "stale" return ( - +

{t("Storage Usage and Scanner")} @@ -43,25 +41,41 @@ export function PerformanceUsageCard({
-

{t("Usable Capacity Used")}

-

{formatCapacity(totalUsedCapacity)}

+

+ {formatCapacity(totalUsedCapacity)} +

- +
+ {Array.from({ length: 32 }, (_, index) => ( +

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

-
+
{t("Used")}
{formatCapacity(totalUsedCapacity)}
@@ -89,7 +103,7 @@ export function PerformanceUsageCard({
) : null} -
+
{usageStats.map((item) => (
{item.label}
diff --git a/app/(dashboard)/access-keys/page.tsx b/app/(dashboard)/access-keys/page.tsx index fe24f10e..898c7ccc 100644 --- a/app/(dashboard)/access-keys/page.tsx +++ b/app/(dashboard)/access-keys/page.tsx @@ -217,23 +217,11 @@ export default function AccessKeysPage() { return ( - - {selectedKeys.length > 0 && canBulkDeleteAccessKeys && ( - - )} {canCreateAccessKey ? ( - @@ -246,6 +234,25 @@ export default function AccessKeysPage() {
+ +
+ {selectedKeys.length > 0 && canBulkDeleteAccessKeys && ( + + )} +
+ + } keepPreviousData caption={t("Access Keys")} errorTitle={loadError ? t("Failed to get data") : undefined} diff --git a/app/(dashboard)/account/page.tsx b/app/(dashboard)/account/page.tsx index d96c5494..ebac6f56 100644 --- a/app/(dashboard)/account/page.tsx +++ b/app/(dashboard)/account/page.tsx @@ -101,57 +101,60 @@ export default function AccountPage() {
) : info ? ( <> - {/* Passive metadata as a definition list, not a grid of cards: these - are facts to read, not objects to select. */} -
-
{t("Username")}
-
{resolveAccountDisplayName(info)}
- -
{t("Role")}
-
- {info.is_admin ? t("Administrator") : t("User")} - {identityTypeLabel(info.identity_type)} -
- -
{t("Status")}
-
{info.status === "enabled" ? t("Enabled") : t("Disabled")}
- -
{t("Two-factor authentication")}
-
{info.mfa.enabled ? t("On") : t("Off")}
- - {info.policies.length > 0 && ( - <> -
{t("Policies")}
-
- {info.policies.map((policy) => ( - - {policy} - - ))} -
- - )} - - {info.member_of.length > 0 && ( - <> -
{t("Groups")}
-
- {info.member_of.map((group) => ( - - {group} - - ))} -
- - )} - - {info.session_access_key && ( - <> -
{t("Session access key")}
-
{info.session_access_key}
- - )} -
+
+
+

{t("Username")}

+

+ {resolveAccountDisplayName(info)} +

+
+
+
{t("Role")}
+
+ {info.is_admin ? t("Administrator") : t("User")} + {identityTypeLabel(info.identity_type)} +
+ +
{t("Status")}
+
{info.status === "enabled" ? t("Enabled") : t("Disabled")}
+ +
{t("Two-factor authentication")}
+
{info.mfa.enabled ? t("On") : t("Off")}
+ + {info.policies.length > 0 && ( + <> +
{t("Policies")}
+
+ {info.policies.map((policy) => ( + + {policy} + + ))} +
+ + )} + + {info.member_of.length > 0 && ( + <> +
{t("Groups")}
+
+ {info.member_of.map((group) => ( + + {group} + + ))} +
+ + )} + + {info.session_access_key && ( + <> +
{t("Session access key")}
+
{info.session_access_key}
+ + )} +
+
{/* Root cannot be edited here, and saying so beats a disabled control with no explanation. */} diff --git a/app/(dashboard)/account/security/page.tsx b/app/(dashboard)/account/security/page.tsx index eed0b513..3fc750c5 100644 --- a/app/(dashboard)/account/security/page.tsx +++ b/app/(dashboard)/account/security/page.tsx @@ -101,9 +101,12 @@ export default function AccountSecurityPage() {
) : info ? ( -
+
{/* Peer sections separated by one divider system, no nested frames. */} -
+

{t("Password")} @@ -114,22 +117,27 @@ export default function AccountSecurityPage() { : t("This identity's password is managed outside the console.")}

- {canManagePassword ? ( - - ) : ( - - - {info.credentials_source === "env" - ? t( - "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.", - ) - : t("This identity's password is managed outside the console.")} - - - )} +
+ {canManagePassword ? ( + + ) : ( + + + {info.credentials_source === "env" + ? t( + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.", + ) + : t("This identity's password is managed outside the console.")} + + + )} +
-
+

@@ -139,74 +147,75 @@ export default function AccountSecurityPage() {

{mfa?.enabled ? t("On") : t("Off")}
+
+ {mfa?.enabled ? ( + <> +
+ {mfa.activated_at && ( + <> +
{t("Enabled on")}
+
{formatDateTime(mfa.activated_at)}
+ + )} + {mfa.last_verified_at && ( + <> +
{t("Last used")}
+
{formatDateTime(mfa.last_verified_at)}
+ + )} +
{t("Recovery codes remaining")}
+
{mfa.recovery_codes_remaining}
+
- {mfa?.enabled ? ( - <> -
- {mfa.activated_at && ( - <> -
{t("Enabled on")}
-
{formatDateTime(mfa.activated_at)}
- + {mfa.recovery_codes_remaining === 0 && ( + + + {t( + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.", + )} + + )} - {mfa.last_verified_at && ( - <> -
{t("Last used")}
-
{formatDateTime(mfa.last_verified_at)}
- + {recoveryCodesRunningLow(mfa.recovery_codes_remaining) && ( + + + {t("You are running low on recovery codes. Generate a new set to be safe.")} + + )} -
{t("Recovery codes remaining")}
-
{mfa.recovery_codes_remaining}
-
- - {mfa.recovery_codes_remaining === 0 && ( - - - {t( - "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.", - )} - - - )} - {recoveryCodesRunningLow(mfa.recovery_codes_remaining) && ( - - - {t("You are running low on recovery codes. Generate a new set to be safe.")} - - - )} -
- - - -
- {mfa.pending && ( -

- {t("A new authenticator is waiting to be confirmed. Your current one keeps working until then.")} -

- )} - - ) : ( - <> - {/* An unavailable feature explains itself instead of showing a +
+ + + +
+ {mfa.pending && ( +

+ {t("A new authenticator is waiting to be confirmed. Your current one keeps working until then.")} +

+ )} + + ) : ( + <> + {/* An unavailable feature explains itself instead of showing a dead button. */} - {!canManageMfa && mfa?.enrollment_blocked_reason && ( - - {mfa.enrollment_blocked_reason} - - )} - - - )} + {!canManageMfa && mfa?.enrollment_blocked_reason && ( + + {mfa.enrollment_blocked_reason} + + )} + + + )} +
) : null} diff --git a/app/(dashboard)/audit-target/page.tsx b/app/(dashboard)/audit-target/page.tsx index 16dfdc6b..9c8822ec 100644 --- a/app/(dashboard)/audit-target/page.tsx +++ b/app/(dashboard)/audit-target/page.tsx @@ -192,15 +192,9 @@ export default function AuditTargetPage() { return ( -
@@ -319,7 +313,22 @@ function BrowserBucketsPage() {

{t("Buckets")}

+ bucket.Name)} + usage={bucketUsage} + unavailable={pending || usageLoading || loadError} + /> + + } keepPreviousData table={table} isLoading={pending} diff --git a/app/(dashboard)/events-target/page.tsx b/app/(dashboard)/events-target/page.tsx index af511bf8..d34e14f5 100644 --- a/app/(dashboard)/events-target/page.tsx +++ b/app/(dashboard)/events-target/page.tsx @@ -152,7 +152,7 @@ export default function EventsTargetPage() { dialog.error({ title: t("Warning"), - content: t("Are you sure you want to delete this destination?"), + content: `${row.account_id}: ${t("Are you sure you want to delete this destination?")}`, positiveText: t("Confirm"), negativeText: t("Cancel"), onPositiveClick: () => deleteItem(row), @@ -195,7 +195,7 @@ export default function EventsTargetPage() { return ( - -
-
+ <> + + + } >

{t("Event Destinations")}

@@ -375,6 +362,15 @@ export default function EventsTargetPage() { {data.length > 0 || !loadFailed ? ( + } table={table} isLoading={loading && data.length === 0} emptyTitle={t("No Destinations")} diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx index caeed2d3..935650fd 100644 --- a/app/(dashboard)/events/page.tsx +++ b/app/(dashboard)/events/page.tsx @@ -1,12 +1,9 @@ "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 { BucketScopedHeader } from "@/components/buckets/scoped-header" import { BucketList } from "@/components/buckets/list" import { BucketEventsTab } from "@/components/buckets/events-tab" import { buildModuleBucketPath } from "@/lib/module-bucket-route" @@ -35,24 +32,7 @@ export default function EventsPage() { bucketName={bucketName} hideTitle renderHeader={(actions) => ( - - {t("Bucket")}: {bucketName} -

- } - actions={ - <> - - {actions} - - } - > -

{t("Events")}

-
+ )} /> diff --git a/app/(dashboard)/import-export/page.tsx b/app/(dashboard)/import-export/page.tsx index 88580eed..8e4e1696 100644 --- a/app/(dashboard)/import-export/page.tsx +++ b/app/(dashboard)/import-export/page.tsx @@ -18,7 +18,8 @@ import { PageHeader } from "@/components/page-header" import { UploadZone } from "@/components/upload-zone" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" import { useImportExport } from "@/hooks/use-import-export" import { useMessage } from "@/lib/feedback/message" @@ -53,14 +54,13 @@ export default function ImportExportPage() { const exportHighlights = useMemo( () => [ - { label: "Users", icon: RiUserLine, iconClass: "text-chart-1" }, - { label: "User Groups", icon: RiGroupLine, iconClass: "text-chart-2" }, + { label: "Users", icon: RiUserLine }, + { label: "User Groups", icon: RiGroupLine }, { label: "IAM Policies", icon: RiShieldCheckLine, - iconClass: "text-chart-3", }, - { label: "AK/SK", icon: RiKeyLine, iconClass: "text-chart-4" }, + { label: "AK/SK", icon: RiKeyLine }, ], [], ) @@ -118,27 +118,33 @@ export default function ImportExportPage() { } return ( - +

{t("Import/Export")}

- setActiveTab(v as "export" | "import")} - className="flex flex-col gap-6" + className="grid items-start gap-8 lg:grid-cols-[13rem_minmax(0,1fr)]" > - + {tabs.map((tab) => ( - + {tab.label} ))} - + {t("IAM Configuration Export")} @@ -146,10 +152,10 @@ export default function ImportExportPage() { -
+
{exportHighlights.map((item) => ( -
- +
+ {t(item.label)}
))} @@ -182,7 +188,7 @@ export default function ImportExportPage() { - + {t("IAM Configuration Import")} {t("Import IAM configurations from a previously exported ZIP file.")} @@ -241,7 +247,7 @@ export default function ImportExportPage() { - +
) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index d9b88d46..e412353e 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -14,12 +14,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod - + - -
+ +
-
+
{children}
diff --git a/app/(dashboard)/lifecycle/page.tsx b/app/(dashboard)/lifecycle/page.tsx index 5d1f46d0..cea74516 100644 --- a/app/(dashboard)/lifecycle/page.tsx +++ b/app/(dashboard)/lifecycle/page.tsx @@ -1,12 +1,9 @@ "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 { BucketScopedHeader } from "@/components/buckets/scoped-header" import { BucketList } from "@/components/buckets/list" import { BucketLifecycleTab } from "@/components/buckets/lifecycle-tab" import { buildModuleBucketPath } from "@/lib/module-bucket-route" @@ -35,24 +32,7 @@ export default function LifecyclePage() { bucketName={bucketName} hideTitle renderHeader={(actions) => ( - - {t("Bucket")}: {bucketName} -

- } - actions={ - <> - - {actions} - - } - > -

{t("Lifecycle")}

-
+ )} /> diff --git a/app/(dashboard)/oidc/page.tsx b/app/(dashboard)/oidc/page.tsx index a590a88d..4f188690 100644 --- a/app/(dashboard)/oidc/page.tsx +++ b/app/(dashboard)/oidc/page.tsx @@ -433,19 +433,17 @@ export default function OidcPage() { return ( - + + {t("Environment-managed providers are read-only")} {t("Changes will take effect after RustFS restarts")} + + } + >

{t("OIDC Providers")}

- - {t("Environment Managed")} - -

{t("Environment-managed providers are read-only")}

-

{t("Changes will take effect after RustFS restarts")}

-
-
- {!canUpdateOidcProviders ? ( {t("Read-only OIDC settings")} @@ -476,7 +474,7 @@ export default function OidcPage() { {t("Loading…")}
) : loadError && providers.length === 0 ? null : ( -
+
( - - {t("Bucket")}: {bucketName} -

- } - actions={ - <> - - {actions} - - } - > -

{t("On-demand migration")}

-
+ )} /> diff --git a/app/(dashboard)/policies/page.tsx b/app/(dashboard)/policies/page.tsx index 4c1d2640..50fe4c13 100644 --- a/app/(dashboard)/policies/page.tsx +++ b/app/(dashboard)/policies/page.tsx @@ -144,15 +144,9 @@ export default function PoliciesPage() { return ( - {canCreatePolicy ? ( - {showStop ? ( - - ) : ( - - )} } > @@ -322,7 +303,33 @@ export default function RebalancePage() {
) : overview.pools.length ? ( <> - + + {showStop ? ( + + ) : ( + + )} + + } + /> {dataReady && overview.supportState === "unsupported" ? ( @@ -331,7 +338,7 @@ export default function RebalancePage() { ) : null} - +
{t("Current Rebalance Status")} @@ -357,7 +364,9 @@ export default function RebalancePage() {

{t("Progress")}

-

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

+

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

{t("Bytes Moved")}

@@ -375,7 +384,7 @@ export default function RebalancePage() { diff --git a/app/(dashboard)/replication/page.tsx b/app/(dashboard)/replication/page.tsx index 88151fd4..43779cf9 100644 --- a/app/(dashboard)/replication/page.tsx +++ b/app/(dashboard)/replication/page.tsx @@ -1,12 +1,9 @@ "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 { BucketScopedHeader } from "@/components/buckets/scoped-header" import { BucketList } from "@/components/buckets/list" import { BucketReplicationTab } from "@/components/buckets/replication-tab" import { buildModuleBucketPath } from "@/lib/module-bucket-route" @@ -35,24 +32,12 @@ export default function ReplicationPage() { bucketName={bucketName} hideTitle renderHeader={(actions) => ( - - {t("Bucket")}: {bucketName} -

- } - actions={ - <> - - {actions} - - } - > -

{t("Bucket Replication")}

-
+ )} /> diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index e4bbbfa7..64da0529 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -157,16 +157,6 @@ export default function SettingsPage() { {t("Sync")} - {canUpdateModuleSwitches ? ( - - ) : null} } > @@ -203,11 +193,11 @@ export default function SettingsPage() { ) : null} -
+
{switchItems.map((item) => { const isEnvManaged = isEnvManagedSource(item.source) return ( - +
@@ -236,6 +226,18 @@ export default function SettingsPage() { ) })}
+
+ {canUpdateModuleSwitches ? ( + + ) : null} +
) diff --git a/app/(dashboard)/site-replication/page.tsx b/app/(dashboard)/site-replication/page.tsx index e449759f..77ce88e5 100644 --- a/app/(dashboard)/site-replication/page.tsx +++ b/app/(dashboard)/site-replication/page.tsx @@ -21,7 +21,7 @@ import { SiteReplicationEditForm } from "@/components/site-replication/edit-form import { SiteReplicationNewForm } from "@/components/site-replication/new-form" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { DropdownMenu, DropdownMenuContent, @@ -447,6 +447,9 @@ export default function SiteReplicationPage() { getRowId: (row) => row.deploymentId || row.endpoint, }) + const showSetup = info?.enabled === false && peers.length === 0 + const remotePeers = peers.filter((peer) => !localDeploymentId || peer.deploymentId !== localDeploymentId) + if (featureUnavailable && !loading) { return ( @@ -526,13 +529,8 @@ export default function SiteReplicationPage() { - {canManageSites ? ( - @@ -581,153 +579,206 @@ export default function SiteReplicationPage() {
) : null} -
- - - {t("Current Site")} - - {t("The server detects the local site identity from the active RustFS deployment.")} - - - - - - {localPeer?.endpoint || localMetric?.endpoint || "--"} - - } - /> - {localDeploymentId || "--"}} - /> - {info?.enabled && !localDeploymentId ? ( -

- {localSite.ambiguousName - ? t("Multiple peers share the local site name, so the current site cannot be identified.") - : t("The current site could not be identified because the server did not report its deployment ID.")} -

+ {showSetup ? ( +
+
+ {t("Not configured")} +

+ {t("Add a remote site to initialize site replication for this deployment.")} +

+

+ {t( + "The current site is detected automatically by the server. Add one or more remote sites to join the replication group.", + )} +

+ {canManageSites ? ( + ) : null} - - - - - - {t("Replication Summary")} - - {t("Overview of replicated configuration and object replication activity.")} - - - - {t("Enabled")} : {t("Not configured")} - } - /> - - - - - - - - - {t("Replicated Scope")} - - {statusUnavailable - ? t("This server does not provide the detailed site replication status endpoint yet.") - : t( - "Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.", +
+
+
+
{t("Current Site")}
+
{info?.name || t("Unknown")}
+
+
+
{t("Deployment ID")}
+
{localDeploymentId || t("Unknown")}
+
+
+
+ ) : ( + <> +
+
+
+

{t("Current Site")}

+ {info?.enabled ? {t("Enabled")} : {t("Not configured")}} +
+
+
+

{info?.name || t("Unknown")}

+

+ {localPeer?.endpoint || localMetric?.endpoint || "--"} +

+
+ +
+ {remotePeers.length ? ( + remotePeers.slice(0, 3).map((peer) => ( +
+

{peer.name || t("Unknown")}

+

{peer.endpoint}

+
+ )) + ) : ( +

+ {t("Add a remote site to initialize site replication for this deployment.")} +

)} - - - - - - - - - - - - - - - {t("Replication Controls")} - - {t("Service account and global lifecycle expiry replication settings for the site replication group.")} - - {info?.serviceAccountAccessKey ? ( - - - - ) : null} - - - {info.serviceAccountAccessKey} - ) : ( - t("Created automatically after the first remote site is added") - ) - } - /> - {t("Enabled")} : {t("Disabled")} - } + {remotePeers.length > 3 ? ( + + {t("Peer Sites")} ({peers.length}) + + ) : null} +
+
+
+ + {localDeploymentId || "--"} + + } + /> + {info?.enabled && !localDeploymentId ? ( +

+ {localSite.ambiguousName + ? t("Multiple peers share the local site name, so the current site cannot be identified.") + : t( + "The current site could not be identified because the server did not report its deployment ID.", + )} +

+ ) : null} +
+
+
+
+

{t("Replication Summary")}

+

+ {t("Overview of replicated configuration and object replication activity.")} +

+
+
+ {t("Enabled")} + ) : ( + {t("Not configured")} + ) + } + /> + + + +
+
+
+ +
+

+ {t("Peer Sites")} +

+ - {canManageSites ? ( -
- +
+ +
+
+
+

{t("Replicated Scope")}

+

+ {statusUnavailable + ? t("This server does not provide the detailed site replication status endpoint yet.") + : t( + "Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.", + )} +

- ) : null} - - -
- - - - {t("Peer Sites")} - - {t( - "Peer membership and editable replication controls, aligned with the RustFS backend site replication API and MinIO-style site management flow.", - )} - - - - - - +
+ + + + + + +
+

+
+
+

{t("Replication Controls")}

+

+ {t( + "Service account and global lifecycle expiry replication settings for the site replication group.", + )} +

+ {info?.serviceAccountAccessKey ? : null} +
+
+ {info.serviceAccountAccessKey} + ) : ( + t("Created automatically after the first remote site is added") + ) + } + /> + {t("Enabled")} : {t("Disabled")} + } + /> + {canManageSites ? ( +
+ +
+ ) : null} +
+
+
+ + )} {t("Server-Side Encryption (SSE) Configuration")}

-
- {statusError ? ( - - {t("Failed to load KMS status")} - {statusError} - - - ) : null} - - -
-

{t("KMS Status Overview")}

- - {loadingStatus ? t("Loading…") : getKmsStatusText()} - - {capabilities?.production_supported === false ? ( - - {t("Development / testing backend — not supported for production")} - - ) : null} -
- {getKmsStatusDescription()} - {!statusError && status?.backend_type && ( - - {t("Backend")}: {status.backend_type} - - )} -
- - {!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} - - {!statusError && statusKind === "Error" ? ( - - {t("KMS service has errors")} - - {typeof status?.status === "object" && status?.status && "Error" in status.status - ? status.status.Error - : t("KMS server status unknown")} - - - ) : null} - - {statusKind === "Configured" && ( - - {t("KMS configured but not started")} - {t("Start KMS to make SSE and key management available.")} - - )} - -
+
+ +
+ {statusError ? ( + + {t("Failed to load KMS status")} + {statusError} + + ) : null} + + +
+

{t("KMS Status Overview")}

+ + {loadingStatus ? t("Loading…") : getKmsStatusText()} + + {capabilities?.production_supported === false ? ( + + {t("Development / testing backend — not supported for production")} + + ) : null} +
+ {getKmsStatusDescription()} + {!statusError && status?.backend_type && ( + + {t("Backend")}: {status.backend_type} + + )} +
+ + {!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} + + {!statusError && statusKind === "Error" ? ( + + {t("KMS service has errors")} + + {typeof status?.status === "object" && status?.status && "Error" in status.status + ? status.status.Error + : t("KMS server status unknown")} + + + ) : null} + + {statusKind === "Configured" && ( + + {t("KMS configured but not started")} + {t("Start KMS to make SSE and key management available.")} + + )} - {hasConfiguration && ( +
- )} - {hasConfiguration && ( - - - {t("Advanced Actions")} - - } - /> - - - {clearingCache ? t("Clearing cache…") : t("Clear Cache")} - - - + {startingKMS ? : null} + {isRunning ? t("Restart KMS") : t("Start KMS")} + + )} + + {hasConfiguration && ( + + + {t("Advanced Actions")} + + } + /> + + + {clearingCache ? t("Clearing cache…") : t("Clear Cache")} + + + + {stoppingKMS ? t("Stopping KMS…") : t("Stop KMS")} + + + + )} +
+
+
+ + {isRunning && ( + + +
+
+

{t("KMS Keys Management")}

+ + {t("Create a new KMS key and optionally make it the default SSE key.")} + +
+
+
- - - - - -

{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} - {configBaselineConflict ? ( - - {t("KMS configuration changed")} - {configBaselineConflict} - - ) : null} - {formState.backendType === "local" ? ( - - {t("Warning")} - - {localKmsConfigured - ? t( - "Local KMS is intended for evaluation only. The key directory and file permissions are locked after configuration; changing or rotating the local master key is not supported.", - ) - : t( - "Local KMS is intended for evaluation only. Keys created without a server-managed master key are stored using plaintext development mode. Do not use this backend for production data.", + {loadingKeys ? ( + + ) : ( + )} - - - ) : null} - {unsupportedKmsReadOnly ? ( - - {t("Unsupported KMS backend")} - - {t( - "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", - )} - - - ) : null} -
{ - event.preventDefault() - void submitConfiguration() - }} - > -
- {t("Backend and default key")} - - - {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} - disabled={formDisabled} - /> - - - {t("This key is used as the platform default SSE key for SSE-KMS and SSE-S3.")} - - - -
- -
- - {formState.backendType === "local" - ? t("Local filesystem") - : formState.backendType === "unsupported" - ? t("Unsupported KMS backend") - : formState.backendType === "static" - ? t("Static key configuration") - : t("Vault connection")} - - {formState.backendType === "unsupported" ? ( + {t("Refresh")} + + +
+
+ + + + {t("Two-stage key deletion")} + + {t("Keys in PendingDeletion can still be restored or deleted immediately.")} + + + + {keysError ? ( - {t("Unsupported KMS backend")} - - {t("Console cannot safely edit a newer or unknown KMS backend type.")} - + {t("Failed to fetch KMS keys")} + {keysError} + - ) : formState.backendType === "static" ? ( + ) : null} + + {loadingKeys ? ( +
+ +
+ ) : keys.length === 0 && !keysError ? ( +
+ {t("No KMS keys found")} +
+ ) : 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}

+
+ {key.status || "-"} +
+ {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) || + staticKmsReadOnly + } + > + {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) || + staticKmsReadOnly + } + className="text-destructive focus:text-destructive" + > + {t("Delete Immediately")} + + + +
+
+
+ ) + })} +
+
+
+ + ) : null} + +
+

+ {t("Showing up to {count} keys per page", { count: KEY_LIST_LIMIT })} +

+
+ + +
+
+
+ + )} + + + +

{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} + {configBaselineConflict ? ( + + {t("KMS configuration changed")} + {configBaselineConflict} + + ) : null} + {formState.backendType === "local" ? ( + + {t("Warning")} + + {localKmsConfigured + ? t( + "Local KMS is intended for evaluation only. The key directory and file permissions are locked after configuration; changing or rotating the local master key is not supported.", + ) + : t( + "Local KMS is intended for evaluation only. Keys created without a server-managed master key are stored using plaintext development mode. Do not use this backend for production data.", + )} + + + ) : null} + {unsupportedKmsReadOnly ? ( + + {t("Unsupported KMS backend")} + + {t( + "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", + )} + + + ) : null} + { + event.preventDefault() + void submitConfiguration() + }} + > +
+ {t("Backend and default key")} - {t("Secret Key")} + {t("KMS Backend")} - updateFormState("secretKey", event.target.value)} - autoComplete="off" - placeholder={t("Base64-encoded 32-byte AES-256 key")} - spellCheck={false} - disabled={formDisabled} - required - aria-required="true" - aria-invalid={configFormErrorField === "secretKey"} - aria-describedby={configFormErrorField === "secretKey" ? "kms-config-error" : undefined} - /> + - - {t("The base64-encoded 32-byte AES-256 key used to derive data encryption keys.")} - + {t("Choose the backend used by SSE/KMS.")} - - ) : formState.backendType === "local" ? ( - + - {t("Local Key Directory")} + {t("Default SSE Key")} updateFormState("keyDir", event.target.value)} + id="defaultKeyId" + name="defaultKeyId" + value={formState.defaultKeyId} + onChange={(event) => updateFormState("defaultKeyId", event.target.value)} autoComplete="off" - placeholder={t("Enter an absolute path such as D:/data/kms-keys")} + placeholder={t("Enter the default SSE key ID")} spellCheck={false} - disabled={formDisabled || localKmsConfigured} - required - aria-required="true" - aria-invalid={configFormErrorField === "keyDir"} - aria-describedby={configFormErrorField === "keyDir" ? "kms-config-error" : undefined} + disabled={formDisabled} /> - {t("The directory must be an absolute path on the server.")} + {t("This key is used as the platform default SSE key for SSE-KMS and SSE-S3.")} - - - {t("File Permissions")} - - updateFormState("filePermissions", event.target.value)} - placeholder="384" - disabled={formDisabled || localKmsConfigured} - /> - - {t("Use decimal values such as 384 for 0o600.")} - - ) : ( -
- - {t("Vault token authentication only")} +
+ +
+ + {formState.backendType === "local" + ? t("Local filesystem") + : formState.backendType === "unsupported" + ? t("Unsupported KMS backend") + : formState.backendType === "static" + ? t("Static key configuration") + : t("Vault connection")} + + {formState.backendType === "unsupported" ? ( + + {t("Unsupported KMS backend")} - {t("Vault AppRole is not supported in the first version of this console.")} + {t("Console cannot safely edit a newer or unknown KMS backend type.")} - + ) : formState.backendType === "static" ? ( - {t("Vault Server Address")} + {t("Secret Key")} updateFormState("address", event.target.value)} + id="secretKey" + name="secretKey" + type="password" + value={formState.secretKey} + onChange={(event) => updateFormState("secretKey", event.target.value)} autoComplete="off" - placeholder="http://127.0.0.1:8200" + placeholder={t("Base64-encoded 32-byte AES-256 key")} 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} + aria-invalid={configFormErrorField === "secretKey"} + aria-describedby={configFormErrorField === "secretKey" ? "kms-config-error" : undefined} /> - {hasStoredVaultCredentials - ? t("Leave blank to keep the stored Vault token.") - : t("Required: Vault authentication token")} + {t("The base64-encoded 32-byte AES-256 key used to derive data encryption keys.")} - + ) : formState.backendType === "local" ? ( - {t("Vault Namespace")} + {t("Local Key Directory")} updateFormState("namespace", event.target.value)} + id="keyDir" + name="keyDir" + value={formState.keyDir} + onChange={(event) => updateFormState("keyDir", event.target.value)} autoComplete="off" - placeholder={t("Optional Vault namespace")} - disabled={formDisabled} + placeholder={t("Enter an absolute path such as D:/data/kms-keys")} spellCheck={false} + disabled={formDisabled || localKmsConfigured} + required + aria-required="true" + aria-invalid={configFormErrorField === "keyDir"} + aria-describedby={configFormErrorField === "keyDir" ? "kms-config-error" : undefined} /> + + {t("The directory must be an absolute path on the server.")} + - {t("Transit Mount Path")} + {t("File Permissions")} updateFormState("mountPath", event.target.value)} + id="filePermissions" + name="filePermissions" + type="number" + inputMode="numeric" + min={0} autoComplete="off" - placeholder="transit" - disabled={formDisabled} - required - aria-required="true" - aria-invalid={configFormErrorField === "transitMountPath"} - aria-describedby={ - configFormErrorField === "transitMountPath" ? "kms-config-error" : undefined - } - spellCheck={false} + value={formState.filePermissions} + onChange={(event) => updateFormState("filePermissions", event.target.value)} + placeholder="384" + disabled={formDisabled || localKmsConfigured} /> + {t("Use decimal values such as 384 for 0o600.")} - - {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("Advanced TLS")} - - {t("Custom CA")} · {t("mTLS client identity")} - - - - {hasStoredCustomCa || hasStoredClientIdentity ? ( - - - {[ - hasStoredCustomCa ? t("Custom CA: configured") : null, - hasStoredClientIdentity ? t("mTLS client identity: configured") : null, - ] - .filter(Boolean) - .join(" · ")} - - - {t( - "Stored TLS paths are never displayed. Re-enter the paths to keep them; saving with blank fields removes the stored TLS settings.", - )} - - - ) : null} + ) : ( +
+ + {t("Vault token authentication only")} + + {t("Vault AppRole is not supported in the first version of this console.")} + + - {t("CA Certificate Path")} + {t("Vault Server Address")} updateFormState("caCertPath", event.target.value)} + id="vaultAddress" + name="vaultAddress" + type="url" + value={formState.address} + onChange={(event) => updateFormState("address", event.target.value)} autoComplete="off" - placeholder="/etc/rustfs/certs/vault-ca.pem" + placeholder="http://127.0.0.1:8200" spellCheck={false} disabled={formDisabled} - aria-invalid={configFormErrorField === "caCertPath"} + required + aria-required="true" + aria-invalid={configFormErrorField === "vaultAddress"} aria-describedby={ - configFormErrorField === "caCertPath" ? "kms-config-error" : undefined + configFormErrorField === "vaultAddress" ? "kms-config-error" : undefined } /> - {t("PEM CA bundle trusted for the Vault connection.")} - {t("Client Certificate Path")} + {t("Vault Token")} updateFormState("clientCertPath", event.target.value)} + id="vaultToken" + name="vaultToken" + type="password" + value={formState.vaultToken} + onChange={(event) => 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" - placeholder="/etc/rustfs/certs/vault-client.pem" spellCheck={false} disabled={formDisabled} - aria-invalid={configFormErrorField === "clientCertPath"} + required={!hasStoredVaultCredentials} + aria-required={!hasStoredVaultCredentials ? "true" : "false"} + aria-invalid={configFormErrorField === "vaultToken"} aria-describedby={ - configFormErrorField === "clientCertPath" ? "kms-config-error" : undefined + configFormErrorField === "vaultToken" ? "kms-config-error" : undefined } /> - {t("PEM client certificate presented to Vault for mTLS.")} + {hasStoredVaultCredentials + ? t("Leave blank to keep the stored Vault token.") + : t("Required: Vault authentication token")} + + - {t("Client Key Path")} + {t("Vault Namespace")} updateFormState("clientKeyPath", event.target.value)} + id="vaultNamespace" + name="vaultNamespace" + value={formState.namespace} + onChange={(event) => updateFormState("namespace", event.target.value)} autoComplete="off" - placeholder="/etc/rustfs/certs/vault-client.key" + placeholder={t("Optional Vault namespace")} + disabled={formDisabled} spellCheck={false} + /> + + + + + {t("Transit Mount Path")} + + updateFormState("mountPath", event.target.value)} + autoComplete="off" + placeholder="transit" disabled={formDisabled} - aria-invalid={configFormErrorField === "clientKeyPath"} + required + aria-required="true" + aria-invalid={configFormErrorField === "transitMountPath"} aria-describedby={ - configFormErrorField === "clientKeyPath" ? "kms-config-error" : undefined + configFormErrorField === "transitMountPath" ? "kms-config-error" : undefined } + spellCheck={false} /> - {t("PEM private key matching the client certificate.")} - -

- {t( - "Paths refer to PEM files on the RustFS server node. In a multi-node cluster the same path must exist on every node.", + + {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} + /> + + + )} -

-
-
- )} - - -
- - {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} - /> - +
+ updateFormState("skipTlsVerify", checked === true)} + id="skipTlsVerify" + disabled={formDisabled} + /> + +
+ +
+ + {t("Advanced TLS")} + + {t("Custom CA")} · {t("mTLS client identity")} + + + + {hasStoredCustomCa || hasStoredClientIdentity ? ( + + + {[ + hasStoredCustomCa ? t("Custom CA: configured") : null, + hasStoredClientIdentity ? t("mTLS client identity: configured") : null, + ] + .filter(Boolean) + .join(" · ")} + + + {t( + "Stored TLS paths are never displayed. Re-enter the paths to keep them; saving with blank fields removes the stored TLS settings.", + )} + + + ) : null} + + + + {t("CA Certificate Path")} + + updateFormState("caCertPath", event.target.value)} + autoComplete="off" + placeholder="/etc/rustfs/certs/vault-ca.pem" + spellCheck={false} + disabled={formDisabled} + aria-invalid={configFormErrorField === "caCertPath"} + aria-describedby={ + configFormErrorField === "caCertPath" ? "kms-config-error" : undefined + } + /> + + + {t("PEM CA bundle trusted for the Vault connection.")} + + + + + {t("Client Certificate Path")} + + updateFormState("clientCertPath", event.target.value)} + autoComplete="off" + placeholder="/etc/rustfs/certs/vault-client.pem" + spellCheck={false} + disabled={formDisabled} + aria-invalid={configFormErrorField === "clientCertPath"} + aria-describedby={ + configFormErrorField === "clientCertPath" ? "kms-config-error" : undefined + } + /> + + + {t("PEM client certificate presented to Vault for mTLS.")} + + + + + {t("Client Key Path")} + + updateFormState("clientKeyPath", event.target.value)} + autoComplete="off" + placeholder="/etc/rustfs/certs/vault-client.key" + spellCheck={false} + disabled={formDisabled} + aria-invalid={configFormErrorField === "clientKeyPath"} + aria-describedby={ + configFormErrorField === "clientKeyPath" ? "kms-config-error" : undefined + } + /> + + + {t("PEM private key matching the client certificate.")} + + + +

+ {t( + "Paths refer to PEM files on the RustFS server node. In a multi-node cluster the same path must exist on every node.", + )} +

+
+ )} +
- {formState.enableCache && ( - +
+ + {t("Advanced Settings")} + + {t("Reliability")} · {t("KMS Cache")} + + + +
+
+ {t("Reliability")} + - {t("Max Cached Keys")} + {t("Timeout (seconds)")} updateFormState("maxCachedKeys", event.target.value)} + value={formState.timeoutSeconds} + onChange={(event) => updateFormState("timeoutSeconds", event.target.value)} disabled={formDisabled} /> - {t("Cache TTL (seconds)")} + {t("Retry Attempts")} updateFormState("cacheTtlSeconds", event.target.value)} + value={formState.retryAttempts} + onChange={(event) => updateFormState("retryAttempts", event.target.value)} disabled={formDisabled} /> - )} -
-
-
+ + +
+ {t("KMS Cache")} +
+ updateFormState("enableCache", checked === true)} + id="enableCache" + disabled={formDisabled} + /> + +
-
- - -
- - - - - {isRunning && ( - - -
-
-

{t("KMS Keys Management")}

- - {t("Create a new KMS key and optionally make it the default SSE key.")} - -
-
- - -
-
-
- - - {t("Two-stage key deletion")} - - {t("Keys in PendingDeletion can still be restored or deleted immediately.")} - - - - {keysError ? ( - - {t("Failed to fetch KMS keys")} - {keysError} - - - ) : null} + {formState.enableCache && ( + + + {t("Max Cached Keys")} + + updateFormState("maxCachedKeys", event.target.value)} + disabled={formDisabled} + /> + + - {loadingKeys ? ( -
- -
- ) : keys.length === 0 && !keysError ? ( -
- {t("No KMS keys found")} -
- ) : 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}

-
- {key.status || "-"} -
- {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) || - staticKmsReadOnly - } - > - {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) || - staticKmsReadOnly - } - className="text-destructive focus:text-destructive" - > - {t("Delete Immediately")} - - - -
-
-
- ) - })} -
-
+ + {t("Cache TTL (seconds)")} + + updateFormState("cacheTtlSeconds", event.target.value)} + disabled={formDisabled} + /> + + + + )} +
- - ) : null} +
-
-

- {t("Showing up to {count} keys per page", { count: KEY_LIST_LIMIT })} -

-
- -
-
+ - )} - {isRunning && capabilities ? : null} + {isRunning && capabilities ? ( +
+ +
+ ) : null} +
@@ -2047,17 +2077,14 @@ export default function SSEPage() { }} > - + {t("Create New Key")} {t("Create a new KMS key and optionally make it the default SSE key.")} -
+ {t("Key Name")} @@ -2108,9 +2135,9 @@ export default function SSEPage() {
-
+ - + - ) : null} - {selectedInfo?.enabled && activeTab === "tables" ? ( - - ) : selectedInfo?.enabled && activeTab === "views" ? ( - - ) : selectedInfo?.enabled ? null : selectedInfo && - !bucketErrors[selectedBucket] && - !bucketStatusLoading[selectedBucket] ? ( - - ) : null} +
+ + + {selectedInfo.catalogType || "rest"} + + + {t("Catalog URI")} + + {selectedCatalogUri || "--"} + + + +
+ ) : ( +

+ {t("Enable this bucket to create namespaces and tables.")} +

+ )}
-
+ {selectedInfo?.enabled && selectedInfo.disableSupported ? ( + + ) : null} + {bucketErrors[selectedBucket] ? (
@@ -1028,69 +1016,7 @@ export default function TableCatalogPage() { ) : selectedInfo ? (
-
- - - {selectedInfo.catalogType || "rest"} - - - {t("Catalog URI")} - - {selectedCatalogUri || "--"} - - - -
- -
-
-
- - setSelectedNamespace(event.target.value)} - className="w-full sm:max-w-sm" - disabled={namespaceLoading || !namespaces.length} - aria-label={t("Select namespace")} - > - {!namespaces.length ? ( - {t("No namespaces")} - ) : null} - {namespaces.map((item) => { - const label = displayNamespace(item.namespace) - return ( - - {label} - - ) - })} - -
-
- - - {namespaceLoading ? t("Loading…") : `${namespaces.length} ${t("namespaces")}`} - -
-
- +
{namespaceError ? ( @@ -1110,29 +1036,72 @@ export default function TableCatalogPage() { onValueChange={(value) => setActiveTab((value as CatalogTab) || "tables")} className="gap-4" > - - {t("Tables")} - {t("Views")} - {t("Namespaces")} - +
+ + {t("Tables")} + {t("Views")} + {t("Namespaces")} + +
+ {activeTab === "tables" ? ( + + ) : activeTab === "views" ? ( + + ) : ( + + )} +
+
+ {activeTab !== "namespaces" ? ( +
+ + setSelectedNamespace(event.target.value)} + className="w-full sm:w-52" + disabled={namespaceLoading || !namespaces.length} + aria-label={t("Select namespace")} + > + {!namespaces.length ? ( + {t("No namespaces")} + ) : null} + {namespaces.map((item) => { + const label = displayNamespace(item.namespace) + return ( + + {label} + + ) + })} + + + {namespaceLoading ? : null} +
+ ) : null} -
-
-

{selectedNamespace || t("Tables")}

-

- {t("Open a table to inspect metadata, snapshots, and recovery evidence.")} -

-
- -
{tableError ? ( @@ -1148,6 +1117,15 @@ export default function TableCatalogPage() { ) : null} + } isLoading={tableLoading} caption={t("Iceberg tables")} emptyTitle={tableSearch ? t("No matching tables") : t("No tables")} @@ -1160,33 +1138,6 @@ export default function TableCatalogPage() {
-
-
-

{selectedNamespace || t("Views")}

-

- {t("Create and replace SQL views registered in this namespace.")} -

-
-
- - -
-
{viewError ? ( @@ -1202,6 +1153,15 @@ export default function TableCatalogPage() { ) : null} + } isLoading={viewLoading} caption={t("Iceberg views")} emptyTitle={viewSearch ? t("No matching views") : t("No views")} @@ -1214,18 +1174,9 @@ export default function TableCatalogPage() {
-
-
-

{t("Namespaces")}

-

- {t("Namespaces group tables and carry optional catalog properties.")} -

-
- -
+

+ {t("Namespaces group tables and carry optional catalog properties.")} +

) : null} -
+ {renderWorkspace()} + +

{t("Table catalog status")}

-
-
+
+

{t("Catalog endpoint")}

{catalogPrefixLabel}

-
+

{t("Catalog backing")}

{backing}

-
+

{t("Credential mode")}

{credentialMode}

-
+

{t("Catalog contract")}

{tableMetadataCount(config)}

- {renderWorkspace()} - [] = useMemo( () => [ + { + id: "name", + header: () => t("Name"), + accessorFn: (row) => getConfig(row)?.name || "-", + cell: ({ row }) => {getConfig(row.original)?.name || "-"}, + }, { header: () => t("Tier Type"), accessorKey: "type", @@ -117,11 +123,6 @@ export default function TiersPage() { ) }, }, - { - id: "name", - header: () => t("Name"), - accessorFn: (row) => getConfig(row)?.name || "-", - }, { id: "endpoint", header: () => t("Endpoint"), @@ -192,9 +193,10 @@ export default function TiersPage() { return ( - diff --git a/app/(dashboard)/user-groups/page.tsx b/app/(dashboard)/user-groups/page.tsx index 5924fcde..d819ee91 100644 --- a/app/(dashboard)/user-groups/page.tsx +++ b/app/(dashboard)/user-groups/page.tsx @@ -151,25 +151,10 @@ export default function UserGroupsPage() { return ( - - - @@ -181,6 +166,28 @@ export default function UserGroupsPage() {
+ +
+ +
+ + } keepPreviousData caption={t("User Groups")} errorTitle={loadError ? t("Failed to get data") : undefined} diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 0b70a95f..2954c8c1 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -175,7 +175,7 @@ export default function UsersPage() { dialog.error({ title: t("Warning"), content: `${row.accessKey}: ${t("Are you sure you want to delete this user?")}`, - positiveText: t("Confirm"), + positiveText: t("Delete"), negativeText: t("Cancel"), onPositiveClick: () => deleteItem(row), }) @@ -199,7 +199,7 @@ export default function UsersPage() { dialog.error({ title: t("Warning"), content: t("Are you sure you want to delete all selected users?"), - positiveText: t("Confirm"), + positiveText: t("Delete"), negativeText: t("Cancel"), onPositiveClick: async () => { const results = await Promise.allSettled(selectedKeys.map((key) => deleteUser(key))) @@ -226,35 +226,11 @@ export default function UsersPage() { return ( - - - {canCreateUser ? ( - @@ -267,6 +243,37 @@ export default function UsersPage() {
+ +
+ + +
+ + } keepPreviousData caption={t("Users")} errorTitle={loadError ? t("Failed to get data") : undefined} diff --git a/components/access-keys/edit-item.tsx b/components/access-keys/edit-item.tsx index 7fec505f..bc4586c0 100644 --- a/components/access-keys/edit-item.tsx +++ b/components/access-keys/edit-item.tsx @@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" 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 { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/dialog" import { DateTimePicker } from "@/components/datetime-picker" import { useMessage } from "@/lib/feedback/message" import { useAccessKeys } from "@/hooks/use-access-keys" @@ -184,7 +184,7 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-6xl" aria-busy={loading || submitting} > - + {t("Edit Key")} @@ -195,7 +195,7 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces void submitForm() }} > -
+ {loading ? (
) : ( -
+
{t("Access Key")} @@ -306,9 +306,9 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
)} -
+
- + diff --git a/components/account/mfa-disable-dialog.tsx b/components/account/mfa-disable-dialog.tsx index ed30f983..f2304fcb 100644 --- a/components/account/mfa-disable-dialog.tsx +++ b/components/account/mfa-disable-dialog.tsx @@ -4,12 +4,13 @@ import { useRef, useState } from "react" import { useTranslation } from "react-i18next" import { Dialog, + DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" +} from "@/components/dialog" import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -107,7 +108,7 @@ export function MfaDisableDialog({ open, onOpenChange, accessKey, onDisabled }: className="max-h-[min(90dvh,40rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-lg" aria-busy={submitting} > - + {t("Turn off two-factor authentication")} {t("{account} will be protected by its password alone, and the recovery codes will stop working.", { @@ -123,7 +124,7 @@ export function MfaDisableDialog({ open, onOpenChange, accessKey, onDisabled }: void submit() }} > -
+ {submitError && ( {submitError} @@ -180,9 +181,9 @@ export function MfaDisableDialog({ open, onOpenChange, accessKey, onDisabled }: {errors.password} -
+ - + diff --git a/components/account/mfa-recovery-codes-dialog.tsx b/components/account/mfa-recovery-codes-dialog.tsx index dc279ba9..c3dabe37 100644 --- a/components/account/mfa-recovery-codes-dialog.tsx +++ b/components/account/mfa-recovery-codes-dialog.tsx @@ -4,12 +4,13 @@ import { useRef, useState } from "react" import { useTranslation } from "react-i18next" import { Dialog, + DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" +} from "@/components/dialog" import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -91,7 +92,7 @@ export function MfaRecoveryCodesDialog({ open, onOpenChange, onRegenerated }: Mf aria-busy={submitting} showCloseButton={!codes} > - + {codes ? t("Save your recovery codes") : t("Generate new recovery codes")} {codes @@ -100,7 +101,7 @@ export function MfaRecoveryCodesDialog({ open, onOpenChange, onRegenerated }: Mf -
+ {codes ? ( )} -
+ - + {codes ? (

{t("These codes are shown only once.")}

) : ( diff --git a/components/account/mfa-setup-dialog.tsx b/components/account/mfa-setup-dialog.tsx index 1882e4d0..058f9587 100644 --- a/components/account/mfa-setup-dialog.tsx +++ b/components/account/mfa-setup-dialog.tsx @@ -5,12 +5,13 @@ import Image from "next/image" import { useTranslation } from "react-i18next" import { Dialog, + DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" +} from "@/components/dialog" import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { CopyInput } from "@/components/copy-input" @@ -125,11 +126,11 @@ export function MfaSetupDialog({ open, onOpenChange, onCompleted }: MfaSetupDial return ( - + {step === "codes" ? t("Save your recovery codes") : t("Two-factor authentication")} {step === "codes" @@ -138,7 +139,7 @@ export function MfaSetupDialog({ open, onOpenChange, onCompleted }: MfaSetupDial -
+ {step === "loading" && (
@@ -159,7 +160,7 @@ export function MfaSetupDialog({ open, onOpenChange, onCompleted }: MfaSetupDial )} {step === "scan" && enrollment && ( -
+
- - {t("Can't scan? Manual setup key")} - - - - - {t("Time-based, {digits} digits, {period}s period.", { - digits: enrollment.digits, - period: enrollment.period_seconds, - })} - - +
+ + {t("Can't scan? Manual setup key")} + + + + + {t("Time-based, {digits} digits, {period}s period.", { + digits: enrollment.digits, + period: enrollment.period_seconds, + })} + + - - {t("Enter the 6-digit code")} - - { - setCode(value) - setCodeError("") - }} - onComplete={() => void verify()} - disabled={verifying} - autoFocus - // Codes are always LTR even in a right-to-left locale. - dir="ltr" - aria-invalid={Boolean(codeError)} - aria-describedby={codeError ? "mfa-setup-code-error" : undefined} - > - - {Array.from({ length: TOTP_CODE_LENGTH }, (_, index) => ( - - ))} - - - - {codeError} - + + {t("Enter the 6-digit code")} + + { + setCode(value) + setCodeError("") + }} + onComplete={() => void verify()} + disabled={verifying} + autoFocus + // Codes are always LTR even in a right-to-left locale. + dir="ltr" + aria-invalid={Boolean(codeError)} + aria-describedby={codeError ? "mfa-setup-code-error" : undefined} + > + + {Array.from({ length: TOTP_CODE_LENGTH }, (_, index) => ( + + ))} + + + + {codeError} + +
)} @@ -235,9 +238,9 @@ export function MfaSetupDialog({ open, onOpenChange, onCompleted }: MfaSetupDial }} /> )} -
+ - + {step === "codes" ? (

{t("These codes are shown only once.")}

) : ( diff --git a/components/app-loading-shell.tsx b/components/app-loading-shell.tsx index 95f18574..271817ec 100644 --- a/components/app-loading-shell.tsx +++ b/components/app-loading-shell.tsx @@ -2,9 +2,9 @@ export function AppLoadingShell() { return ( -
-
-
+
- -
-
-
-

- {t("Remote Sites")} -

-

- {t( - "Each remote site needs an endpoint and administrator credentials so the cluster can complete the join handshake.", +

+ + updateSite(index, "secretKey", event.target.value)} + autoComplete="off" + placeholder={t("Secret Key")} + spellCheck={false} + disabled={saving} + /> + {errors[index]?.secretKey ? ( +

{errors[index].secretKey}

+ ) : ( +

+ {t("The value is used only for the initial join request.")} +

)} -

-
- -
- - -
-
- -
-
- {sites.map((site, index) => ( -
- - {t("Remote Site")} {index + 1} - -
-
-
- - {t("Remote Site")} {index + 1} - - {!site.name.trim() ? ( - {t("Name inferred from endpoint if left blank")} - ) : null} -
-

{site.name.trim() || t("Unnamed remote site")}

-

- {site.endpoint.trim() || "https://remote-rustfs.example.com"} -

-
- - {sites.length > 1 ? ( - - ) : null} +
+
+ + updateSite(index, "name", event.target.value)} + autoComplete="off" + placeholder={t("Site Name")} + spellCheck={false} + disabled={saving} + /> +
+ {isHttpsSiteReplicationEndpoint(site.endpoint) ? ( +
+
+ +
-
+ {tlsModes[index] === "custom-ca" ? (
- - updateSite(index, "name", event.target.value)} - autoComplete="off" - placeholder={t("Site Name")} - spellCheck={false} - disabled={saving} - /> -
- -
- - updateSite(index, "endpoint", event.target.value)} - autoComplete="off" - placeholder="https://remote-rustfs.example.com" - spellCheck={false} + +