From 2babe4c46ae48d6c0ae5be7cce255546290bf202 Mon Sep 17 00:00:00 2001 From: overtrue Date: Sat, 17 Oct 2026 23:38:19 +0800 Subject: [PATCH 1/5] feat: redesign Console pages around their workflows Give resources, operations, settings, and identity pages distinct layouts while preserving their existing controls and safeguards. Keep section navigation usable with unsaved drafts and keyboard input. --- app/(auth)/config/page.tsx | 10 +- app/(dashboard)/403/page.tsx | 50 +- .../_components/performance-backend-card.tsx | 18 +- .../performance-infrastructure-card.tsx | 19 +- .../performance-status-summary.tsx | 3 +- .../_components/performance-summary-cards.tsx | 58 +- .../_components/performance-usage-card.tsx | 38 +- app/(dashboard)/access-keys/page.tsx | 35 +- app/(dashboard)/account/page.tsx | 105 +- app/(dashboard)/account/security/page.tsx | 167 +- app/(dashboard)/audit-target/page.tsx | 17 +- app/(dashboard)/browser/content.tsx | 31 +- app/(dashboard)/browser/page.tsx | 25 +- app/(dashboard)/events-target/page.tsx | 48 +- app/(dashboard)/events/page.tsx | 24 +- app/(dashboard)/import-export/page.tsx | 38 +- app/(dashboard)/layout.tsx | 12 +- app/(dashboard)/lifecycle/page.tsx | 24 +- app/(dashboard)/oidc/page.tsx | 18 +- app/(dashboard)/on-demand-migration/page.tsx | 34 +- app/(dashboard)/policies/page.tsx | 17 +- app/(dashboard)/pool-decommission/page.tsx | 10 +- app/(dashboard)/rebalance/page.tsx | 55 +- app/(dashboard)/replication/page.tsx | 29 +- app/(dashboard)/settings/page.tsx | 26 +- app/(dashboard)/site-replication/page.tsx | 353 ++-- app/(dashboard)/sse/page.tsx | 1753 +++++++++-------- app/(dashboard)/status/page.tsx | 27 +- app/(dashboard)/table-catalog/page.tsx | 20 +- app/(dashboard)/tiers/page.tsx | 14 +- app/(dashboard)/user-groups/page.tsx | 41 +- app/(dashboard)/users/page.tsx | 59 +- components/app-loading-shell.tsx | 10 +- components/app-sidebar.tsx | 28 +- components/app-top-nav.tsx | 2 +- components/auth/heroes/hero-static.tsx | 56 +- components/auth/login-form.tsx | 12 +- components/buckets/info.tsx | 15 +- components/buckets/lifecycle-tab.tsx | 60 +- components/buckets/list.tsx | 17 +- components/buckets/replication-tab.tsx | 16 +- components/buckets/scoped-header.tsx | 41 + components/buckets/settings-layout.tsx | 6 +- components/buckets/usage-overview.tsx | 81 + components/data-table/data-table.tsx | 11 +- components/events/columns.tsx | 12 +- components/license/article.tsx | 73 +- components/object/list.tsx | 106 +- components/oidc/form.tsx | 11 +- components/oidc/provider-list.tsx | 10 +- components/on-demand-migration/management.tsx | 468 ++--- components/page-header.tsx | 45 +- components/page.tsx | 2 +- components/pools/overview.tsx | 30 +- components/section-navigation.tsx | 80 + components/site-replication/new-form.tsx | 2 +- config/navs.ts | 71 +- docs/ui-review/register.md | 47 +- i18n/locales/ar-MA.json | 5 +- i18n/locales/de-DE.json | 5 +- i18n/locales/en-US.json | 5 +- i18n/locales/es-ES.json | 5 +- i18n/locales/fr-FR.json | 5 +- i18n/locales/id-ID.json | 5 +- i18n/locales/it-IT.json | 5 +- i18n/locales/ja-JP.json | 5 +- i18n/locales/ko-KR.json | 5 +- i18n/locales/pt-BR.json | 5 +- i18n/locales/ru-RU.json | 5 +- i18n/locales/tr-TR.json | 5 +- i18n/locales/vi-VN.json | 5 +- i18n/locales/zh-CN.json | 5 +- lib/account-bucket-usage.js | 18 + lib/license-sections.js | 12 + styles/console.css | 153 ++ tests/lib/account-bucket-usage.test.js | 26 +- tests/lib/browser-layout.test.ts | 4 +- tests/lib/data-table-state.test.ts | 13 + tests/lib/license-sections.test.js | 19 + tests/lib/pool-overview-source.test.js | 3 +- tests/lib/running-status-safety.test.js | 2 +- tests/lib/section-navigation.test.ts | 120 ++ tests/lib/settings-layout-source.test.js | 26 +- tests/lib/sse-kms-p1-safety.test.js | 2 +- tests/lib/ui-layout-source.test.js | 17 +- 85 files changed, 2887 insertions(+), 2088 deletions(-) create mode 100644 components/buckets/scoped-header.tsx create mode 100644 components/buckets/usage-overview.tsx create mode 100644 components/section-navigation.tsx create mode 100644 lib/license-sections.js create mode 100644 tests/lib/license-sections.test.js create mode 100644 tests/lib/section-navigation.test.ts 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} +
diff --git a/app/(dashboard)/status/page.tsx b/app/(dashboard)/status/page.tsx index 780c657e..01b00a22 100644 --- a/app/(dashboard)/status/page.tsx +++ b/app/(dashboard)/status/page.tsx @@ -278,8 +278,8 @@ export default function PerformancePage() { t={t} /> -
-
+
+
+
- -
+
- -
- -
+ +
- - +
- - + { + if (!updatingTagsRef.current) setShowTagView(nextOpen) + }} + disablePointerDismissal + > + {t("Set Tags")} + {resolvedObjectKey} -
+
{tags.map((tag) => ( confirmDeleteTag(tag.Key)} + disabled={isUpdatingTags} > @@ -714,7 +767,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie value={tagFormValue.Key} onChange={(e) => setTagFormValue((v) => ({ ...v, Key: e.target.value }))} placeholder={t("Tag Key Placeholder")} - disabled={!canEditObjectTags} + disabled={!canEditObjectTags || isUpdatingTags} /> @@ -732,33 +785,42 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie })) } placeholder={t("Tag Value Placeholder")} - disabled={!canEditObjectTags} + disabled={!canEditObjectTags || isUpdatingTags} />
-
- -
-
+ + + + +
- - + { + if (!updatingRetentionRef.current) setShowRetentionView(nextOpen) + }} + disablePointerDismissal + > +
{t("Retention")} + {resolvedObjectKey} -
+ {t("Retention Mode")} @@ -766,6 +828,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie value={retentionMode} onValueChange={(v) => setRetentionMode(v as "COMPLIANCE" | "GOVERNANCE")} className="grid gap-2 sm:grid-cols-2" + disabled={!canEditRetention || isUpdatingRetention} > {[ { label: t("COMPLIANCE"), value: "COMPLIANCE" }, @@ -800,34 +863,34 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie /> -
- - - -
-
+ + + + + +
diff --git a/components/object/list.tsx b/components/object/list.tsx index 4456b2f5..d879df79 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -27,17 +27,18 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, -} from "@/components/ui/alert-dialog" +} from "@/components/dialog" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, + DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" +} from "@/components/dialog" import { Input } from "@/components/ui/input" import { DropdownMenu, @@ -618,8 +619,7 @@ export function ObjectList({ ref={(node) => setRowActionTrigger(row.id, node)} type="button" variant="ghost" - size="icon" - className="size-11 lg:size-8" + size="icon-sm" aria-label={`${t("Actions")}: ${displayKey(key)}`} > @@ -950,7 +950,6 @@ export function ObjectList({ type="button" variant="outline" size="icon" - className="size-11 lg:size-8" aria-label={t("More actions")} > @@ -1146,24 +1145,17 @@ export function ObjectList({ - - {t("Cancel")} - - } - /> + + {t("Cancel")} + - {t("Confirm")} - - } - /> + variant={"destructive"} + className={cn("w-full sm:w-auto", DANGER_BUTTON_CLASS)} + onClick={handleConfirmDelete} + disabled={bucketVersioningState === "unknown"} + > + {t("Delete")} + @@ -1192,28 +1184,21 @@ export function ObjectList({ - - {t("Cancel")} - - } - /> + + {t("Cancel")} + - {t("Force Cleanup Folder")} - + variant={"destructive"} + className={cn("min-h-11 w-full sm:min-h-8 sm:w-auto", DANGER_BUTTON_CLASS)} + onClick={handleForceCleanup} + disabled={ + !forceCleanupTarget || + forceCleanupTarget.bucket !== bucket || + !canCapability("objects.forceCleanup", forceCleanupTarget) } - /> + > + {t("Force Cleanup Folder")} + @@ -1235,7 +1220,7 @@ export function ObjectList({ {t("Rename Object")} {renameSourceKey} -
+ {getRenameValidationMessage(renameValidation)}

) : null} -
+
- + +
-
+
{formatBytes(item.file.size)}
-
- @@ -448,12 +451,19 @@ export function ObjectUploadPicker({ } return ( - - + { + if (!isAdding) onShowChange(nextOpen) + }} + disablePointerDismissal + > + {t("Upload File")} + {bucketName} -
+
-

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

{t("Current Prefix")}:
-
- - -
- - {t("Overwrite Warning")} - - {isMemoryWarning && ( @@ -534,7 +517,7 @@ export function ObjectUploadPicker({ )}
-
+
+
+ + +
{items.length > 0 && (

@@ -562,7 +565,7 @@ export function ObjectUploadPicker({

{items.length === 0 ? ( -
+

{t("No Selection")}

@@ -597,8 +600,15 @@ export function ObjectUploadPicker({

)}
- -
+ + + + {t("Overwrite Warning")} + +
+
-
+
) diff --git a/components/object/versions.tsx b/components/object/versions.tsx index 790be99b..fcf58974 100644 --- a/components/object/versions.tsx +++ b/components/object/versions.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next" import { RiFileCopyLine, RiEyeLine, RiDownloadCloud2Line, RiDeleteBin5Line, RiLoopLeftLine } from "@remixicon/react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Dialog, DialogBody, DialogDescription, DialogContent, DialogHeader, DialogTitle } from "@/components/dialog" import { Spinner } from "@/components/ui/spinner" import { DataTable } from "@/components/data-table/data-table" import { useDataTable } from "@/hooks/use-data-table" @@ -291,20 +291,24 @@ export function ObjectVersions({ return ( !open && onClose()} disablePointerDismissal> - + {t("Object Versions")} + {objectKey} -
- - {t("Versions")}: {versionStats.count} - - - {t("Total")} {t("Size")}:{" "} - {formatBytes(versionStats.totalSize)} - -
- + + {" "} +
+ + {t("Versions")}: {versionStats.count} + + + {t("Total")} {t("Size")}:{" "} + {formatBytes(versionStats.totalSize)} + +
+ +
) diff --git a/components/on-demand-migration/backfill-dialog.tsx b/components/on-demand-migration/backfill-dialog.tsx index 222b3865..f03900d1 100644 --- a/components/on-demand-migration/backfill-dialog.tsx +++ b/components/on-demand-migration/backfill-dialog.tsx @@ -8,12 +8,13 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Dialog, + DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" +} from "@/components/dialog" import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -131,14 +132,14 @@ export function OnDemandMigrationBackfillDialog({ className="grid max-h-[calc(100dvh-2rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-xl" showCloseButton={!submitting} > - + {active ? t("Backfill in progress") : t("Start background backfill")} {t("Bucket")}: {bucketName} -
+ {operationError ? ( @@ -282,9 +283,9 @@ export function OnDemandMigrationBackfillDialog({ )} -
+ - + -
- -
- - {isConfigured ? t("Expand replication group") : t("First-time setup")} - - {siteCountLabel} -
- {title} - - {t( - "The current site is detected automatically by the server. Add one or more remote sites to join the replication group.", - )} - -
- -
-
-
- - {t("Topology")} -
-

{t("Current site + remote peers")}

-

- {t("Build a site replication group without manually entering the local deployment identity.")} -

-
- -
-
- - {t("Handshake")} + + +
+ {title} + {siteCountLabel} +
+ + {t( + "The current site is detected automatically by the server. Add one or more remote sites to join the replication group.", + )} + +
+ + {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}
-

{t("Admin-to-admin join flow")}

-

- {t( - "Each remote site uses its administrator credentials to complete the initial replication handshake.", - )} -

-
-
- - {t("Credentials")} -
-

{t("Service account is automatic")}

-

- {t("RustFS creates the internal site replication service account after the first successful join.")} -

-
+ {sites.length > 1 ? ( + + ) : null}
-
-
-
- +
+
+ + updateSite(index, "endpoint", event.target.value)} + autoComplete="off" + placeholder="https://remote-rustfs.example.com" + spellCheck={false} + disabled={saving} + /> + {errors[index]?.endpoint ? ( +

{errors[index].endpoint}

+ ) : (

- {t("Matches the backend and MinIO site replication option for expiry lifecycle rules.")} + {t("Use the remote RustFS API endpoint, including protocol and port if needed.")}

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

{errors[index].accessKey}

+ ) : ( +

+ {t("Administrator access key for the remote site.")} +

+ )}
- -
-

- {t("When enabled, lifecycle expiry rule metadata is propagated to every peer site in the group.")} -

-

{t("You can still adjust this setting later from the site replication overview page.")}

-
-
-
- - -
-
-
-

- {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} + +