@@ -1174,7 +1704,13 @@ export default function SSEPage() {
{
const previousMarker = previousMarkers[previousMarkers.length - 1] ?? ""
setPreviousMarkers((current) => current.slice(0, -1))
@@ -1187,7 +1723,9 @@ export default function SSEPage() {
{
if (!nextMarker) return
setPreviousMarkers((current) => [...current, currentMarker])
@@ -1207,7 +1745,9 @@ export default function SSEPage() {
{
+ if (!open && creatingKey) return
setCreateKeyOpen(open)
if (!open) {
setCreateKeyName("")
@@ -1216,15 +1756,18 @@ 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")}
@@ -1236,6 +1779,7 @@ export default function SSEPage() {
autoComplete="off"
placeholder={t("Optional display name for the key")}
spellCheck={false}
+ disabled={creatingKey || Boolean(activeMutation)}
/>
@@ -1250,6 +1794,7 @@ export default function SSEPage() {
onChange={(event) => setCreateKeyDescription(event.target.value)}
autoComplete="off"
placeholder={t("Describe the purpose of this key")}
+ disabled={creatingKey || Boolean(activeMutation)}
/>
@@ -1260,21 +1805,35 @@ export default function SSEPage() {
checked={createKeySetAsDefault}
onCheckedChange={(checked) => setCreateKeySetAsDefault(checked === true)}
id="setAsDefaultKey"
+ disabled={creatingKey || Boolean(activeMutation) || !canSetCreatedKeyAsDefault}
/>
{t("Set as default SSE key")}
- {t("This will update the current KMS configuration after key creation.")}
+ {status?.backend_type === "Local"
+ ? t("Set a default key for Local KMS in server configuration.")
+ : isConfigDirty
+ ? t("Save or discard KMS configuration changes before setting a newly created default key.")
+ : t("This will update the current KMS configuration after key creation.")}
-
- setCreateKeyOpen(false)} disabled={creatingKey}>
+
+ setCreateKeyOpen(false)}
+ disabled={creatingKey || Boolean(activeMutation)}
+ >
{t("Cancel")}
-
+
{creatingKey ? : null}
{t("Create Key")}
@@ -1283,73 +1842,102 @@ export default function SSEPage() {
!open && setSelectedKeyId(null)} direction="right">
-
-
+
+
{t("KMS Key Details")}
- {selectedKeyId || ""}
+ {selectedKeyId || ""}
+
+
+
+
+
-
+
{loadingKeyDetails ? (
+ ) : keyDetailsError ? (
+
+ {t("Failed to load key details")}
+ {keyDetailsError}
+ setKeyDetailsReloadVersion((value) => value + 1)}
+ >
+ {t("Refresh")}
+
+
) : !keyDetails ? (
{t("No key details available")}
) : (
<>
-
-
-
{t("Name")}
-
{getKeyDisplayName(keyDetails)}
+
+
+
{t("Name")}
+ {getKeyDisplayName(keyDetails)}
-
-
{t("State")}
-
+
+
{t("State")}
+
{keyDetails.key_state || "-"}
-
+
-
-
{t("KMS Key ID")}
-
{keyDetails.key_id}
+
+
{t("KMS Key ID")}
+ {keyDetails.key_id}
-
-
{t("Usage")}
-
{keyDetails.key_usage || "-"}
+
+
{t("Usage")}
+ {keyDetails.key_usage || "-"}
-
-
{t("Creation Date")}
-
{formatTimestamp(keyDetails.creation_date)}
+
+
{t("Creation Date")}
+ {formatTimestamp(keyDetails.creation_date)}
-
-
{t("Deletion Date")}
-
{formatTimestamp(keyDetails.deletion_date)}
+
+
{t("Deletion Date")}
+ {formatTimestamp(keyDetails.deletion_date)}
-
-
{t("Origin")}
-
{keyDetails.origin || "-"}
+
+
{t("Origin")}
+ {keyDetails.origin || "-"}
-
-
{t("Key Manager")}
-
{keyDetails.key_manager || "-"}
+
+
{t("Key Manager")}
+ {keyDetails.key_manager || "-"}
-
+
{keyDetails.description && (
-
+
{t("Description")}
-
{keyDetails.description}
+
{keyDetails.description}
)}
-
+
{t("Tags")}
{Object.entries(keyDetails.tags ?? {}).length > 0 ? (
Object.entries(keyDetails.tags ?? {}).map(([key, value]) => (
-
+
{key}: {value}
))
@@ -1364,17 +1952,104 @@ export default function SSEPage() {
- !open && setPendingKeyAction(null)}>
+ {
+ if (!open && !startingKMS && !stoppingKMS) setPendingServiceAction(null)
+ }}
+ >
+
+
+
+ {pendingServiceAction === "stop"
+ ? t("Stop KMS")
+ : pendingServiceAction === "restart"
+ ? t("Restart KMS")
+ : t("Start KMS")}
+
+ {serviceActionDescription}
+
+
+ {t("Cancel")}
+
+ {startingKMS || stoppingKMS ? : null}
+ {pendingServiceAction === "stop" ? t("Stop KMS") : t("Confirm")}
+
+
+
+
+
+ !open && setPendingNavigation(null)}>
+
+
+ {t("Discard Changes")}
+
+ {t("You have unsaved KMS changes. Do you want to discard them?")}
+ {mutationInFlight ? (
+
+ {t("An operation is still in progress. Wait for it to finish before leaving this page.")}
+
+ ) : null}
+
+
+
+ {t("Cancel")}
+
+ {t("Discard")}
+
+
+
+
+
+ {
+ if (!open && !processingKeyAction) setPendingKeyAction(null)
+ }}
+ >
{keyActionTitle}
- {keyActionDescription}
+
+ {keyActionDescription}
+
+ {t("KMS Key ID")}: {pendingKeyAction?.key.key_id}
+
+ {isPendingDefaultKey && pendingKeyAction?.type !== "cancelDeletion" ? (
+
+ {t("Cannot delete the default SSE key. Choose another default key first.")}
+
+ ) : null}
+
{t("Cancel")}
-
+
{processingKeyAction ? : null}
- {pendingKeyAction?.type === "cancelDeletion" ? t("Restore Key") : t("Confirm")}
+ {pendingKeyAction?.type === "cancelDeletion"
+ ? t("Restore Key")
+ : pendingKeyAction?.type === "forceDelete"
+ ? t("Delete Key Immediately")
+ : t("Schedule Key Deletion")}
diff --git a/app/(dashboard)/status/page.tsx b/app/(dashboard)/status/page.tsx
index fb0ba9c7..fda00c40 100644
--- a/app/(dashboard)/status/page.tsx
+++ b/app/(dashboard)/status/page.tsx
@@ -4,139 +4,139 @@ import { Page } from "@/components/page"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
-import { usePerformanceData } from "@/hooks/use-performance-data"
-import { niceBytes } from "@/lib/functions"
-import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data"
+import { usePerformanceData, type PerformanceDataSource } from "@/hooks/use-performance-data"
+import { usePermissions } from "@/hooks/use-permissions"
+import { formatRelativeTime, summarizeServerStates } from "@/lib/performance-data"
+import { cn } from "@/lib/utils"
import {
RiArchiveDrawerFill,
RiArchiveLine,
- RiHardDrive2Line,
RiListSettingsFill,
RiRefreshLine,
RiSecurePaymentFill,
RiStackLine,
} from "@remixicon/react"
import dayjs from "dayjs"
-import relativeTime from "dayjs/plugin/relativeTime"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
-import { usePermissions } from "@/hooks/use-permissions"
-import { PerformanceSummaryCards } from "../_components/performance-summary-cards"
-import { PerformanceUsageCard } from "../_components/performance-usage-card"
-import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card"
import { PerformanceBackendCard } from "../_components/performance-backend-card"
+import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card"
import { PerformanceServerList } from "../_components/performance-server-list"
+import { PerformanceSummaryCards } from "../_components/performance-summary-cards"
+import { PerformanceUsageCard } from "../_components/performance-usage-card"
-dayjs.extend(relativeTime)
+function formatDuration(seconds: number | undefined, t: (key: string) => string) {
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown")
+ const totalMinutes = Math.floor(seconds / 60)
+ const days = Math.floor(totalMinutes / 1440)
+ const hours = Math.floor((totalMinutes % 1440) / 60)
+ const minutes = totalMinutes % 60
+ const parts = [
+ days ? `${days} ${t("Days")}` : "",
+ hours ? `${hours} ${t("Hours")}` : "",
+ !days && minutes ? `${minutes} ${t("Minutes")}` : "",
+ ].filter(Boolean)
+ return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}`
+}
export default function PerformancePage() {
- const { t } = useTranslation()
+ const { t, i18n } = useTranslation()
const { canAccessPath } = usePermissions()
- const { systemInfo, metricsInfo, datausageinfo, storageinfo, loading, error, refetch } = usePerformanceData()
+ const {
+ systemInfo,
+ metricsInfo,
+ datausageinfo,
+ storageinfo,
+ loading,
+ refreshing,
+ hasLoaded,
+ error,
+ sourceErrors,
+ lastUpdatedAt,
+ metricsUpdatedAt,
+ refetch,
+ } = usePerformanceData()
const browserHref = canAccessPath("/browser") ? "/browser" : undefined
- const numberFormatter = useMemo(() => new Intl.NumberFormat(), [])
-
- const storageBackend = useMemo(
- () => storageinfo?.backend ?? (storageinfo as { Backend?: typeof storageinfo.backend })?.Backend,
- [storageinfo],
- )
+ const numberFormatter = useMemo(() => new Intl.NumberFormat(i18n.resolvedLanguage), [i18n.resolvedLanguage])
+ const storageBackend = storageinfo.backend
+ const totalCapacity = datausageinfo.total_capacity
+ const totalUsedCapacity = datausageinfo.total_used_capacity
const usedPercent = useMemo(() => {
- const total = Number(datausageinfo.total_capacity || 0)
- if (!total) return 0
- const used = Number(datausageinfo.total_used_capacity || 0)
- return Math.min(100, Math.max(0, (used / total) * 100))
- }, [datausageinfo])
-
- const lastUpdatedLabel = useMemo(() => {
- const last = metricsInfo?.aggregated?.scanner?.current_started
- const time = dayjs(last)
- return time.isValid() ? time.fromNow() : "--"
- }, [metricsInfo])
+ if (totalCapacity === undefined || totalUsedCapacity === undefined) return undefined
+ if (totalCapacity === 0) return totalUsedCapacity === 0 ? 0 : undefined
+ return Math.min(100, Math.max(0, (totalUsedCapacity / totalCapacity) * 100))
+ }, [totalCapacity, totalUsedCapacity])
const summaryMetrics = useMemo(
() => [
{
label: t("Buckets"),
- display: numberFormatter.format(systemInfo?.buckets?.count ?? 0),
+ display:
+ systemInfo.buckets?.count === undefined ? t("Unknown") : numberFormatter.format(systemInfo.buckets.count),
icon: RiArchiveLine,
caption: null as string | null,
href: browserHref,
},
{
label: t("Objects"),
- display: numberFormatter.format(systemInfo?.objects?.count ?? 0),
+ display:
+ systemInfo.objects?.count === undefined ? t("Unknown") : numberFormatter.format(systemInfo.objects.count),
icon: RiStackLine,
caption: null as string | null,
href: browserHref,
},
- {
- label: t("Total Capacity"),
- display: datausageinfo.total_capacity ? niceBytes(String(datausageinfo.total_capacity)) : "--",
- icon: RiHardDrive2Line,
- caption: datausageinfo.total_used_capacity
- ? `${t("Used")}: ${niceBytes(String(datausageinfo.total_used_capacity))}`
- : null,
- href: undefined as string | undefined,
- },
],
- [systemInfo, datausageinfo, numberFormatter, t, browserHref],
+ [browserHref, numberFormatter, systemInfo.buckets, systemInfo.objects, t],
)
- const fromLastStartTime = useMemo(() => {
- const times = metricsInfo?.aggregated?.scanner?.cycle_complete_times || []
- if (!times.length) return "--"
- const start = dayjs(times[times.length - 1])
- return dayjs().from(start)
- }, [metricsInfo])
-
- const fromLastScanTime = useMemo(() => {
- const start = dayjs(metricsInfo?.aggregated?.scanner?.current_started)
- if (!start.isValid()) return "--"
- return dayjs().from(start)
- }, [metricsInfo])
-
- const lastScanTime = useMemo(() => {
- const currentStart = dayjs(metricsInfo?.aggregated?.scanner?.current_started)
- const cycleTimes = metricsInfo?.aggregated?.scanner?.cycle_complete_times || []
- if (!currentStart.isValid()) return "--"
- const lastComplete = dayjs(cycleTimes[cycleTimes.length - 1])
- return lastComplete.isValid() && currentStart.isBefore(lastComplete)
- ? lastComplete.from(currentStart)
- : dayjs().from(currentStart)
- }, [metricsInfo])
+ const scannerStartedAt = metricsInfo.aggregated?.scanner?.current_started
+ const scannerCycle = metricsInfo.aggregated?.scanner?.current_cycle
+ const scannerCompleteTimes = metricsInfo.aggregated?.scanner?.cycle_complete_times ?? []
+ const scannerCompletedAt = scannerCompleteTimes.at(-1)
+ const scannerActive = (scannerCycle ?? 0) > 0
+ const scannerStatus = scannerActive ? t("Scanning") : scannerCompletedAt ? t("Idle") : t("Never run")
+ const scannerDuration = useMemo(() => {
+ if (!scannerStartedAt) return undefined
+ const started = dayjs(scannerStartedAt)
+ if (!started.isValid()) return undefined
+ if (scannerActive) return Math.max(0, dayjs(metricsUpdatedAt ?? undefined).diff(started, "second"))
+ if (!scannerCompletedAt) return undefined
+ const completed = dayjs(scannerCompletedAt)
+ return completed.isValid() && completed.isAfter(started)
+ ? Math.max(0, completed.diff(started, "second"))
+ : undefined
+ }, [metricsUpdatedAt, scannerActive, scannerCompletedAt, scannerStartedAt])
const usageStats = useMemo(
() => [
- { label: t("Last Normal Operation"), value: fromLastStartTime },
- { label: t("Last Scan Activity"), value: fromLastScanTime },
- { label: t("Uptime"), value: lastScanTime },
+ {
+ label: t("Last Completed Scan"),
+ value: scannerCompletedAt
+ ? (formatRelativeTime(scannerCompletedAt, i18n.resolvedLanguage, metricsUpdatedAt?.getTime()) ?? t("Unknown"))
+ : t("Unknown"),
+ },
+ {
+ label: t("Scanner Status"),
+ value: scannerStatus,
+ },
+ { label: t("Scan Duration"), value: formatDuration(scannerDuration, t) },
],
- [t, fromLastStartTime, fromLastScanTime, lastScanTime],
+ [i18n.resolvedLanguage, metricsUpdatedAt, scannerCompletedAt, scannerDuration, scannerStatus, t],
)
- const serverCounts = useMemo(() => {
- const counts: Record = {
- online: 0,
- offline: 0,
- unknown: 0,
- degraded: 0,
- }
-
- for (const server of systemInfo?.servers ?? []) {
- counts[normalizeServerHealthState(server.state)] += 1
- }
-
- return counts
- }, [systemInfo])
+ const serverSummary = useMemo(
+ () => (systemInfo.servers ? summarizeServerStates(systemInfo.servers) : undefined),
+ [systemInfo.servers],
+ )
const backendInfo = useMemo(
() => [
{
icon: RiArchiveDrawerFill,
title: t("Backend Type"),
- value: systemInfo?.backend?.backendType,
+ value: systemInfo.backend?.backendType ?? storageBackend?.BackendType,
},
{
icon: RiSecurePaymentFill,
@@ -149,17 +149,43 @@ export default function PerformancePage() {
value: storageBackend?.RRSCParity,
},
],
- [systemInfo, storageBackend, t],
+ [storageBackend, systemInfo.backend?.backendType, t],
)
- if (loading && !Object.keys(systemInfo).length && !Object.keys(datausageinfo).length) {
+ const unavailableSources = Object.keys(sourceErrors) as PerformanceDataSource[]
+ const sourceLabels: Record = {
+ system: t("Cluster information"),
+ usage: t("Storage Usage Statistics"),
+ storage: t("Storage Configuration"),
+ metrics: t("Scanner metrics"),
+ }
+
+ const refreshAction = (
+
+ {lastUpdatedAt ? (
+
+ {t("Last refreshed")}: {formatRelativeTime(lastUpdatedAt, i18n.resolvedLanguage) ?? t("Unknown")}
+
+ ) : null}
+
+
+ {refreshing ? t("Refreshing…") : t("Refresh")}
+
+
+ )
+
+ if (loading && !hasLoaded) {
return (
{t("Running Status")}
-
-
+
+
+ {t("Loading…")}
)
@@ -168,18 +194,21 @@ export default function PerformancePage() {
if (error) {
return (
-
-
- {t("Sync")}
-
- }
- >
+
{t("Running Status")}
-
-
{error}
+
+
{t(error)}
+
+ {t("Check your connection, then refresh the status page.")}
+
+
+
+ {refreshing ? t("Refreshing…") : t("Refresh")}
+
)
@@ -187,42 +216,62 @@ export default function PerformancePage() {
return (
-
-
- {t("Sync")}
-
- }
- >
+
{t("Running Status")}
-
-
+
+
+ {refreshing
+ ? t("Refreshing…")
+ : !unavailableSources.length && lastUpdatedAt
+ ? t("Status refreshed successfully")
+ : ""}
+
-
+ {unavailableSources.length ? (
+
+
{t("Some status data is unavailable.")}
+
+ {t("Previously loaded values may be stale.")} {t("Unavailable")}:{" "}
+ {unavailableSources.map((source) => sourceLabels[source]).join(", ")}. {t("Refresh to try again.")}
+
+
+ ) : null}
-
+
+
+
+
+
)
diff --git a/components/access-keys/edit-item.tsx b/components/access-keys/edit-item.tsx
index 42cda218..7fec505f 100644
--- a/components/access-keys/edit-item.tsx
+++ b/components/access-keys/edit-item.tsx
@@ -3,11 +3,12 @@
import * as React from "react"
import { useTranslation } from "react-i18next"
import dayjs from "dayjs"
+import { RiRefreshLine } from "@remixicon/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
-import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
+import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DateTimePicker } from "@/components/datetime-picker"
@@ -41,58 +42,95 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
const [description, setDescription] = React.useState("")
const [status, setStatus] = React.useState<"on" | "off">("on")
const [submitting, setSubmitting] = React.useState(false)
+ const [loading, setLoading] = React.useState(false)
+ const [loadError, setLoadError] = React.useState("")
+ const [policyError, setPolicyError] = React.useState("")
+ const [loadVersion, setLoadVersion] = React.useState(0)
const [initialName, setInitialName] = React.useState("")
const [initialDescription, setInitialDescription] = React.useState("")
const minExpiry = React.useMemo(() => dayjs().toISOString(), [])
React.useEffect(() => {
- if (open && row?.accessKey) {
- getServiceAccount(row.accessKey)
- .then(
- (res: {
- policy?: unknown
- expiration?: string
- name?: string
- description?: string
- accountStatus?: string
- }) => {
- setAccesskey(row.accessKey)
- let policyValue: string
- if (typeof res.policy === "string") {
- try {
- policyValue = JSON.stringify(JSON.parse(res.policy), null, 2)
- } catch {
- policyValue = res.policy
- }
- } else {
- policyValue = JSON.stringify(res.policy ?? {}, null, 2)
+ if (!open || !row?.accessKey) return
+
+ let cancelled = false
+ const currentAccessKey = row.accessKey
+
+ setAccesskey(currentAccessKey)
+ setPolicy("")
+ setExpiry(null)
+ setName("")
+ setDescription("")
+ setStatus("on")
+ setInitialName("")
+ setInitialDescription("")
+ setPolicyError("")
+ setLoadError("")
+ setLoading(true)
+
+ getServiceAccount(currentAccessKey)
+ .then(
+ (res: {
+ policy?: unknown
+ expiration?: string
+ name?: string
+ description?: string
+ accountStatus?: string
+ }) => {
+ if (cancelled) return
+ let policyValue: string
+ if (typeof res.policy === "string") {
+ try {
+ policyValue = JSON.stringify(JSON.parse(res.policy), null, 2)
+ } catch {
+ policyValue = res.policy
}
- setPolicy(policyValue)
- setExpiry(
- res.expiration && res.expiration !== "9999-01-01T00:00:00Z" ? dayjs(res.expiration).toISOString() : null,
- )
- const initialNameValue = res.name ?? ""
- const initialDescriptionValue = res.description ?? ""
- setName(initialNameValue)
- setDescription(initialDescriptionValue)
- setInitialName(initialNameValue)
- setInitialDescription(initialDescriptionValue)
- setStatus((res.accountStatus as "on" | "off") ?? "on")
- },
- )
- .catch(() => {
- message.error(t("Failed to get data"))
- })
+ } else {
+ policyValue = JSON.stringify(res.policy ?? {}, null, 2)
+ }
+ setPolicy(policyValue)
+ setExpiry(
+ res.expiration && res.expiration !== "9999-01-01T00:00:00Z" ? dayjs(res.expiration).toISOString() : null,
+ )
+ const initialNameValue = res.name ?? ""
+ const initialDescriptionValue = res.description ?? ""
+ setName(initialNameValue)
+ setDescription(initialDescriptionValue)
+ setInitialName(initialNameValue)
+ setInitialDescription(initialDescriptionValue)
+ setStatus((res.accountStatus as "on" | "off") ?? "on")
+ },
+ )
+ .catch((error) => {
+ if (cancelled) return
+ const reason = (error as Error)?.message || t("Failed to get data")
+ setLoadError(reason)
+ message.error(reason)
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false)
+ })
+
+ return () => {
+ cancelled = true
}
- }, [open, row?.accessKey, getServiceAccount, message, t])
+ }, [open, row?.accessKey, getServiceAccount, loadVersion, message, t])
const closeModal = () => {
onOpenChange(false)
}
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (!submitting || nextOpen) {
+ onOpenChange(nextOpen)
+ }
+ }
+
const submitForm = async () => {
- if (!accesskey) return
+ if (!accesskey || loading || loadError) return
+ if (submitting) return
+ setPolicyError("")
// Validate policy JSON format before submitting
if (policy) {
@@ -100,15 +138,15 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
const parsed = JSON.parse(policy)
// Basic structure validation
if (typeof parsed !== "object" || parsed === null) {
- message.error(t("Policy must be a valid JSON object"))
+ setPolicyError(t("Policy must be a valid JSON object"))
return
}
if (parsed.Statement !== undefined && !Array.isArray(parsed.Statement)) {
- message.error(t("Policy Statement must be an array"))
+ setPolicyError(t("Policy Statement must be an array"))
return
}
} catch {
- message.error(t("Policy format is invalid. Please check the JSON syntax."))
+ setPolicyError(t("Policy format is invalid. Please check the JSON syntax."))
return
}
}
@@ -141,101 +179,162 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
}
return (
-
-
-
+
+
+
{t("Edit Key")}
-
-
-
-
- {t("Cancel")}
-
-
- {submitting ? (
-
+
)
diff --git a/components/access-keys/new-item.tsx b/components/access-keys/new-item.tsx
index 95c6bf56..256a0903 100644
--- a/components/access-keys/new-item.tsx
+++ b/components/access-keys/new-item.tsx
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
-import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
+import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DateTimePicker } from "@/components/datetime-picker"
@@ -88,6 +88,12 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
onVisibleChange(false)
}
+ const handleOpenChange = (nextVisible: boolean) => {
+ if (!submitting || nextVisible) {
+ onVisibleChange(nextVisible)
+ }
+ }
+
const validate = () => {
const newErrors = { accessKey: "", secretKey: "", name: "" }
if (!accessKey) {
@@ -139,6 +145,7 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
}
const submitForm = async () => {
+ if (submitting) return
setSubmitError("")
if (!validate()) {
message.error(t("Please fill in the correct format"))
@@ -186,153 +193,190 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
}
return (
-
-
-
+
+
+
{t("Create Key")}
-
-
-
- {t("Access Key")}
-
- setAccessKey(e.target.value)}
- autoComplete="off"
- spellCheck={false}
- aria-invalid={Boolean(errors.accessKey)}
- />
-
- {errors.accessKey}
-
+
{
+ event.preventDefault()
+ void submitForm()
+ }}
+ >
+
+
+
+
+
+ {t("Use main account policy")}
+
+
+
+ {t("Automatically inherit the main account policy when enabled.")}
+
+
+
+
+
- {!impliedPolicy && (
-
-
- {t("Current user policy")}
-
- setPolicy(e.target.value)}
- className="h-full min-h-[200px] font-mono text-xs"
- spellCheck={false}
- />
-
-
+ {!impliedPolicy && (
+
+
+ {t("Current user policy")}
+
+ setPolicy(e.target.value)}
+ className="min-h-[24rem] font-mono text-xs lg:h-full"
+ spellCheck={false}
+ disabled={submitting}
+ />
+
+
+
+ )}
- )}
-
+
- {submitError && (
-
- {submitError}
-
- )}
+ {submitError && (
+
+ {submitError}
+
+ )}
-
-
- {t("Cancel")}
-
-
- {submitting ? (
-
-
- {t("Submit")}
-
- ) : (
- t("Submit")
- )}
-
-
+
+
+ {t("Cancel")}
+
+
+ {submitting ? (
+
+
+ {t("Submit")}
+
+ ) : (
+ t("Submit")
+ )}
+
+
+
)
diff --git a/components/audit-target/new-form.tsx b/components/audit-target/new-form.tsx
index 30cffb73..246f184c 100644
--- a/components/audit-target/new-form.tsx
+++ b/components/audit-target/new-form.tsx
@@ -122,17 +122,24 @@ export function AuditTargetNewForm({ open, onOpenChange, onSuccess }: AuditTarge
}
const handleCancel = () => {
+ if (submitting) return
onOpenChange(false)
resetForm()
}
return (
-
-
-
+ {
+ if (!submitting) onOpenChange(nextOpen)
+ }}
+ >
+
+
{type ? t("Add {type} Audit Target", { type }) : t("Add Audit Target")}
-
+
{!type ? (
{AUDIT_TARGET_TYPE_OPTIONS.map((option) => (
@@ -227,8 +234,8 @@ export function AuditTargetNewForm({ open, onOpenChange, onSuccess }: AuditTarge
)}
-
-
+
+
{t("Cancel")}
diff --git a/components/auth/heroes/hero-static.tsx b/components/auth/heroes/hero-static.tsx
index 93feb51e..d849979b 100644
--- a/components/auth/heroes/hero-static.tsx
+++ b/components/auth/heroes/hero-static.tsx
@@ -15,10 +15,10 @@ export function AuthHeroStatic() {
const theme = getThemeManifest()
return (
-
-
+
+
-
+
{t("Rust-based")}
-
{t("Reliable distributed file system")}
+
{t("Reliable distributed file system")}
{t("Visit website")}
@@ -48,7 +48,7 @@ export function AuthHeroStatic() {
fill
priority
sizes="50vw"
- className="object-cover opacity-45"
+ className="object-cover opacity-55"
/>
diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx
index a40d31b3..bd4af0f1 100644
--- a/components/auth/login-form.tsx
+++ b/components/auth/login-form.tsx
@@ -57,24 +57,24 @@ export function LoginForm({
const website = theme.links.website ?? "https://www.rustfs.com"
return (
-
+
-
-
+
+
@@ -82,22 +82,23 @@ export function LoginForm({
-
-
+
+
+
{t("Login")}
setMethod(v as LoginMethod)} className="flex flex-col gap-4">
-
-
+
+
{t("Key Login")}
-
+
{t("STS Login")}
-
+
{method === "accessKeyAndSecretKey" ? (
<>
@@ -116,6 +117,8 @@ export function LoginForm({
autoComplete="username"
type="text"
spellCheck={false}
+ required
+ className="h-11 text-base sm:h-8 sm:text-xs"
placeholder={t("Please enter account")}
/>
@@ -136,6 +139,8 @@ export function LoginForm({
autoComplete="current-password"
type="password"
spellCheck={false}
+ required
+ className="h-11 text-base sm:h-8 sm:text-xs"
placeholder={t("Please enter key")}
/>
@@ -159,6 +164,8 @@ export function LoginForm({
autoComplete="new-password"
type="text"
spellCheck={false}
+ required
+ className="h-11 text-base sm:h-8 sm:text-xs"
placeholder={t("Please enter STS username")}
/>
@@ -179,6 +186,8 @@ export function LoginForm({
autoComplete="new-password"
type="password"
spellCheck={false}
+ required
+ className="h-11 text-base sm:h-8 sm:text-xs"
placeholder={t("Please enter STS key")}
/>
@@ -199,6 +208,8 @@ export function LoginForm({
autoComplete="new-password"
type="text"
spellCheck={false}
+ required
+ className="h-11 text-base sm:h-8 sm:text-xs"
placeholder={t("Please enter STS session token")}
/>
@@ -206,7 +217,11 @@ export function LoginForm({
>
)}
-
+
{t("Login")}
@@ -227,7 +242,7 @@ export function LoginForm({
key={provider.provider_id}
type="button"
variant="outline"
- className="w-full justify-center"
+ className="h-11 w-full justify-center text-sm sm:h-8 sm:text-xs"
onClick={() => onOidcLogin(provider.provider_id)}
>
{t("Login with {name}", { name: provider.display_name })}
@@ -237,20 +252,15 @@ export function LoginForm({
)}
-
-
+
+
{t("Login Problems?")}{" "}
{t("Get Help")}
-
-
-
-
diff --git a/components/buckets/events-tab.tsx b/components/buckets/events-tab.tsx
index 9d093875..dd034f6c 100644
--- a/components/buckets/events-tab.tsx
+++ b/components/buckets/events-tab.tsx
@@ -4,6 +4,7 @@ import * as React from "react"
import { useTranslation } from "react-i18next"
import { RiAddLine, RiRefreshLine } from "@remixicon/react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataTable } from "@/components/data-table/data-table"
import { useDataTable } from "@/hooks/use-data-table"
@@ -13,16 +14,18 @@ import { useBucket } from "@/hooks/use-bucket"
import { useModuleSwitches } from "@/hooks/use-module-switches"
import { usePermissions } from "@/hooks/use-permissions"
import { canManageNotifyBackedFeature } from "@/lib/notify-module-access"
+import { createLatestRequestGate } from "@/lib/bucket-configuration"
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
-import type { NotificationItem } from "@/lib/events"
+import { getDisplayEvents, TYPE_BADGE_CLASSES, type NotificationItem } from "@/lib/events"
interface BucketEventsTabProps {
bucketName: string
hideTitle?: boolean
+ renderHeader?: (actions: React.ReactNode) => React.ReactNode
}
-export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsTabProps) {
+export function BucketEventsTab({ bucketName, hideTitle = false, renderHeader }: BucketEventsTabProps) {
const { t } = useTranslation()
const message = useMessage()
const dialog = useDialog()
@@ -34,12 +37,23 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
const [data, setData] = React.useState
([])
const [loading, setLoading] = React.useState(false)
+ const [loadError, setLoadError] = React.useState("")
+ const [notifyError, setNotifyError] = React.useState("")
+ const [mutatingId, setMutatingId] = React.useState(null)
const [newFormOpen, setNewFormOpen] = React.useState(false)
const [notifyEnabled, setNotifyEnabled] = React.useState(undefined)
+ const [requestGate] = React.useState(createLatestRequestGate)
- const canManageBucketEvents = canEditEvents && canManageNotifyBackedFeature(notifyEnabled)
+ const canManageBucketEvents =
+ canEditEvents &&
+ canManageNotifyBackedFeature(notifyEnabled) &&
+ !loadError &&
+ !notifyError &&
+ !loading &&
+ !mutatingId
const loadData = React.useCallback(async () => {
+ const requestVersion = requestGate.begin()
setLoading(true)
try {
const [response, switches] = await Promise.allSettled([
@@ -47,8 +61,14 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
getModuleSwitches({ suppress403Redirect: true }),
])
+ if (!requestGate.isCurrent(requestVersion)) return
+
if (switches.status === "fulfilled" && switches.value) {
setNotifyEnabled(switches.value.notify_enabled)
+ setNotifyError("")
+ } else {
+ setNotifyEnabled(undefined)
+ setNotifyError(t("Unable to verify whether notify is enabled. Refresh before making changes."))
}
if (response.status === "rejected") {
@@ -79,8 +99,10 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
const prefix = config.Filter?.Key?.FilterRules?.find((r) => r.Name === "Prefix")?.Value
const suffix = config.Filter?.Key?.FilterRules?.find((r) => r.Name === "Suffix")?.Value
const arn = (config as Record)[arnKey]
+ const sourceId = config.Id?.trim() || undefined
notifications.push({
- id: config.Id ?? "",
+ id: sourceId ?? `missing-${type.toLowerCase()}-${notifications.length}`,
+ sourceId,
type,
arn: arn ?? "",
events: config.Events ?? [],
@@ -103,69 +125,74 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
addFromConfig((r?.TopicConfigurations ?? []) as never[], "SNS", "TopicArn")
setData(notifications)
+ setLoadError("")
} catch {
- setData([])
+ if (!requestGate.isCurrent(requestVersion)) return
+ setLoadError(t("Unable to load event subscriptions. Refresh before making changes."))
} finally {
- setLoading(false)
+ if (requestGate.isCurrent(requestVersion)) setLoading(false)
}
- }, [bucketName, getModuleSwitches, listBucketNotifications])
+ }, [bucketName, getModuleSwitches, listBucketNotifications, requestGate, t])
React.useEffect(() => {
loadData()
- }, [loadData])
+ return () => {
+ requestGate.invalidate()
+ }
+ }, [loadData, requestGate])
const handleRowDelete = React.useCallback(
- async (row: NotificationItem) => {
+ (row: NotificationItem) => {
if (!canEditEvents) return
if (!canManageBucketEvents) {
message.warning(t("Notify is disabled. Enable notify before managing bucket event subscriptions."))
return
}
+ if (!row.sourceId) {
+ message.error(t("This subscription has no stable ID. Refresh before deleting it."))
+ return
+ }
- const confirmed = await new Promise((resolve) => {
- dialog.warning({
- title: t("Confirm Delete"),
- content: t("Are you sure you want to delete this notification configuration?"),
- positiveText: t("Delete"),
- negativeText: t("Cancel"),
- onPositiveClick: () => resolve(true),
- onNegativeClick: () => resolve(false),
- })
- })
+ dialog.warning({
+ title: t("Confirm Delete"),
+ content: `${t("Are you sure you want to delete this notification configuration?")} ${row.sourceId} · ${bucketName}`,
+ positiveText: t("Delete Event Subscription"),
+ negativeText: t("Cancel"),
+ onPositiveClick: async () => {
+ if (mutatingId) return
+ setMutatingId(row.sourceId ?? row.id)
+ try {
+ const currentResponse = await listBucketNotifications(bucketName)
+ const currentNotifications = (currentResponse ?? {}) as unknown as Record
+ const configKey =
+ row.type === "Lambda"
+ ? "LambdaFunctionConfigurations"
+ : row.type === "SQS"
+ ? "QueueConfigurations"
+ : "TopicConfigurations"
+ const configs = (currentNotifications as Record>)[configKey] ?? []
+ const matches = configs.filter((config) => config.Id === row.sourceId)
+ if (matches.length !== 1) throw new Error(t("Configuration changed. Refresh and try again."))
+ const updated = configs.filter((config) => config.Id !== row.sourceId)
+ const newConfig = {
+ ...currentNotifications,
+ ...(row.type === "Lambda"
+ ? { LambdaFunctionConfigurations: updated }
+ : row.type === "SQS"
+ ? { QueueConfigurations: updated }
+ : { TopicConfigurations: updated }),
+ }
- if (!confirmed) return
-
- try {
- setLoading(true)
- const currentResponse = await listBucketNotifications(bucketName)
- const currentNotifications = (currentResponse ?? {}) as unknown as Record
-
- const configKey =
- row.type === "Lambda"
- ? "LambdaFunctionConfigurations"
- : row.type === "SQS"
- ? "QueueConfigurations"
- : "TopicConfigurations"
- const configs = (currentNotifications as Record>)[configKey]
- const updated = configs?.filter((c) => c.Id !== row.id) ?? []
-
- const newConfig = {
- ...currentNotifications,
- ...(row.type === "Lambda"
- ? { LambdaFunctionConfigurations: updated }
- : row.type === "SQS"
- ? { QueueConfigurations: updated }
- : { TopicConfigurations: updated }),
- }
-
- await putBucketNotifications(bucketName, newConfig)
- message.success(t("Delete Success"))
- loadData()
- } catch (error) {
- message.error(`${t("Delete Failed")}: ${(error as Error).message ?? error}`)
- } finally {
- setLoading(false)
- }
+ await putBucketNotifications(bucketName, newConfig)
+ message.success(t("Delete Success"))
+ await loadData()
+ } catch (error) {
+ message.error(`${t("Delete Failed")}: ${(error as Error).message ?? error}`)
+ } finally {
+ setMutatingId(null)
+ }
+ },
+ })
},
[
bucketName,
@@ -175,6 +202,7 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
listBucketNotifications,
loadData,
message,
+ mutatingId,
putBucketNotifications,
t,
],
@@ -191,25 +219,33 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
getRowId: (row) => row.id,
})
+ const actions = (
+ <>
+ {canEditEvents ? (
+ setNewFormOpen(true)} disabled={!canManageBucketEvents}>
+
+ {t("Add Event Subscription")}
+
+ ) : null}
+
+
+ {t("Refresh")}
+
+ >
+ )
+
return (
-
- {hideTitle ? null :
{t("Events")} }
-
- {canEditEvents ? (
-
setNewFormOpen(true)} disabled={!canManageBucketEvents}>
-
- {t("Add Event Subscription")}
-
- ) : null}
-
-
- {t("Refresh")}
-
+ {renderHeader ? (
+ renderHeader(actions)
+ ) : (
+
+ {hideTitle ? null :
{t("Events")} }
+
{actions}
-
+ )}
- {canEditEvents && !canManageBucketEvents ? (
+ {canEditEvents && notifyEnabled === false ? (
{t("Notify is disabled")}
@@ -218,12 +254,80 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
) : null}
-
+ {notifyError ? (
+
+ {t("Notify status unavailable")}
+ {notifyError}
+
+ ) : null}
+
+ {loadError ? (
+
+ {t("Event subscriptions unavailable")}
+ {loadError}
+
+ ) : null}
+
+
+
+
+
+
+ {loading && data.length === 0 ? (
+
{t("Loading")}
+ ) : data.length === 0 && !loadError ? (
+
+
{t("No event subscriptions")}
+
{t("Add Event Subscription to get started")}
+
+ ) : (
+ data.map((item) => (
+
+
+
+
{item.sourceId ?? t("Unnamed subscription")}
+
{item.arn}
+
+
{item.type}
+
+
+ {getDisplayEvents(item.events).map((event) => (
+
+ {event}
+
+ ))}
+
+ {item.prefix || item.suffix ? (
+
+
+
{t("Prefix")}
+ {item.prefix || "-"}
+
+
+
{t("Suffix")}
+ {item.suffix || "-"}
+
+
+ ) : null}
+ {canEditEvents ? (
+ handleRowDelete(item)}
+ disabled={!canManageBucketEvents || !item.sourceId}
+ >
+ {`${t("Delete Event Subscription")}: ${item.sourceId ?? t("Unnamed subscription")}`}
+
+ ) : null}
+
+ ))
+ )}
+
({ bucket: bucketName }), [bucketName])
@@ -88,17 +106,37 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const [retentionPeriod, setRetentionPeriod] = React.useState(null)
const [retentionUnit, setRetentionUnit] = React.useState(null)
const [loading, setLoading] = React.useState(true)
+ const [loadedBucket, setLoadedBucket] = React.useState(null)
+ const [readErrors, setReadErrors] = React.useState>>({})
const [versionLoading, setVersionLoading] = React.useState(false)
- const [objectLockLoading] = React.useState(false)
+ const [mutation, setMutation] = React.useState(null)
+ const requestVersionRef = React.useRef(0)
+ const mutationRef = React.useRef(null)
+
+ const beginMutation = React.useCallback((next: BucketMutation) => {
+ if (mutationRef.current) return false
+ mutationRef.current = next
+ setMutation(next)
+ return true
+ }, [])
+
+ const finishMutation = React.useCallback(() => {
+ mutationRef.current = null
+ setMutation(null)
+ }, [])
const [showPolicyModal, setShowPolicyModal] = React.useState(false)
const [policyFormPolicy, setPolicyFormPolicy] = React.useState("private")
const [policyFormContent, setPolicyFormContent] = React.useState("{}")
+ const [policyFormError, setPolicyFormError] = React.useState("")
const [showEncryptModal, setShowEncryptModal] = React.useState(false)
const [encryptFormType, setEncryptFormType] = React.useState("disabled")
const [encryptFormKmsKeyId, setEncryptFormKmsKeyId] = React.useState("")
const [kmsKeyOptions, setKmsKeyOptions] = React.useState<{ label: string; value: string }[]>([])
+ const [kmsKeyLoading, setKmsKeyLoading] = React.useState(false)
+ const [kmsKeyError, setKmsKeyError] = React.useState("")
+ const [kmsReloadVersion, setKmsReloadVersion] = React.useState(0)
const [showCorsModal, setShowCorsModal] = React.useState(false)
const [corsFormEnabled, setCorsFormEnabled] = React.useState(false)
@@ -113,11 +151,13 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const [quotaFormEnabled, setQuotaFormEnabled] = React.useState(false)
const [quotaFormSize, setQuotaFormSize] = React.useState("1")
const [quotaFormUnit, setQuotaFormUnit] = React.useState("GiB")
+ const [quotaFormError, setQuotaFormError] = React.useState("")
const [showRetentionModal, setShowRetentionModal] = React.useState(false)
const [retentionFormMode, setRetentionFormMode] = React.useState("GOVERNANCE")
const [retentionFormUnit, setRetentionFormUnit] = React.useState("Days")
const [retentionPeriodInput, setRetentionPeriodInput] = React.useState("")
+ const [retentionFormError, setRetentionFormError] = React.useState("")
const [deleteTagIndex, setDeleteTagIndex] = React.useState(null)
const corsValidation = React.useMemo(
@@ -127,96 +167,166 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const fetchData = React.useCallback(async () => {
if (!bucketName) return
+ const requestVersion = ++requestVersionRef.current
setLoading(true)
try {
- const [p, e, corsResp, tagResp, lockResp, verResp, quotaResp] = await Promise.all([
- bucketApi.getBucketPolicy(bucketName).catch(() => null),
- bucketApi.getBucketEncryption(bucketName).catch(() => null),
- bucketApi.getBucketCors(bucketName).catch(() => null),
- bucketApi.getBucketTagging(bucketName).catch(() => null),
- bucketApi.getObjectLockConfiguration(bucketName).catch(() => null),
- bucketApi.getBucketVersioning(bucketName).catch(() => null),
- bucketApi.getBucketQuota(bucketName).catch(() => null),
+ const results = await Promise.allSettled([
+ getBucketPolicy(bucketName),
+ getBucketEncryption(bucketName),
+ getBucketCors(bucketName),
+ getBucketTagging(bucketName),
+ getObjectLockConfiguration(bucketName),
+ getBucketVersioning(bucketName),
+ getBucketQuota(bucketName),
])
+ if (requestVersion !== requestVersionRef.current) return
- const policyStr = (p as { Policy?: string })?.Policy ?? null
- setPolicy(policyStr)
-
- if (policyStr) {
- try {
- const parsed = JSON.parse(policyStr) as { Statement?: unknown[] }
- const detected = detectBucketPolicy(
- (parsed.Statement ?? []) as Parameters[0],
- bucketName,
- "",
- )
- setPolicyType(detected === "none" ? "custom" : detected)
- } catch {
- setPolicyType("custom")
+ const nextErrors: Partial> = {}
+ const readSetting = (
+ result: PromiseSettledResult,
+ key: BucketSettingKey,
+ kind: BucketConfigurationKind | null,
+ missingValue: T,
+ ): T | typeof UNAVAILABLE => {
+ if (result.status === "fulfilled" && result.value !== undefined) return result.value as T
+ if (result.status === "rejected" && kind && isMissingBucketConfiguration(result.reason, kind)) {
+ return missingValue
}
- } else {
- setPolicyType("private")
+ nextErrors[key] = t("Unable to load this setting. Refresh before making changes.")
+ return UNAVAILABLE
}
- setEncryption(e as unknown as Record)
- setCorsConfig(
- ((corsResp as BucketCorsConfiguration | null)?.CORSRules?.length ?? 0) > 0
- ? (corsResp as BucketCorsConfiguration)
- : null,
- )
- setTags(
- (tagResp as { TagSet?: Array<{ Key?: string; Value?: string }> })?.TagSet?.map((x) => ({
- Key: x.Key ?? "",
- Value: x.Value ?? "",
- })) ?? [],
+ const p = readSetting | null>(results[0], "policy", "policy", null)
+ const e = readSetting | null>(results[1], "encryption", "encryption", null)
+ const corsResp = readSetting(results[2], "cors", "cors", null)
+ const tagResp = readSetting<{ TagSet?: Array<{ Key?: string; Value?: string }> } | null>(
+ results[3],
+ "tagging",
+ "tagging",
+ null,
)
+ const lockResp = readSetting | null>(results[4], "objectLock", "objectLock", null)
+ const verResp = readSetting<{ Status?: string } | null>(results[5], "versioning", null, null)
+ const quotaResp = readSetting(results[6], "quota", "quota", null)
- const lockCfg = lockResp as {
- ObjectLockConfiguration?: {
- ObjectLockEnabled?: string
- Rule?: { DefaultRetention?: { Mode?: string; Days?: number; Years?: number } }
+ if (p !== UNAVAILABLE) {
+ const policyStr = (p as { Policy?: string } | null)?.Policy ?? null
+ setPolicy(policyStr)
+
+ if (policyStr) {
+ try {
+ const parsed = JSON.parse(policyStr) as { Statement?: unknown[] }
+ const detected = detectBucketPolicy(
+ (parsed.Statement ?? []) as Parameters[0],
+ bucketName,
+ "",
+ )
+ setPolicyType(detected === "none" ? "custom" : detected)
+ } catch {
+ setPolicyType("custom")
+ }
+ } else {
+ setPolicyType("private")
}
}
- const lockEnabled = lockCfg?.ObjectLockConfiguration?.ObjectLockEnabled === "Enabled"
- setObjectLock(lockEnabled)
-
- if (lockEnabled) {
- const rule = lockCfg?.ObjectLockConfiguration?.Rule?.DefaultRetention
- if (rule) {
- setRetentionMode(rule.Mode ?? null)
- setRetentionPeriod(rule.Days ?? rule.Years ?? null)
- setRetentionUnit(rule.Years ? "Years" : rule.Days ? "Days" : null)
+
+ if (e !== UNAVAILABLE) setEncryption(e)
+ if (corsResp !== UNAVAILABLE) {
+ setCorsConfig((corsResp?.CORSRules?.length ?? 0) > 0 ? corsResp : null)
+ }
+ if (tagResp !== UNAVAILABLE) {
+ setTags(
+ tagResp?.TagSet?.map((x) => ({
+ Key: x.Key ?? "",
+ Value: x.Value ?? "",
+ })) ?? [],
+ )
+ }
+
+ if (lockResp !== UNAVAILABLE) {
+ const lockCfg = lockResp as {
+ ObjectLockConfiguration?: {
+ ObjectLockEnabled?: string
+ Rule?: { DefaultRetention?: { Mode?: string; Days?: number; Years?: number } }
+ }
+ }
+ const lockEnabled = lockCfg?.ObjectLockConfiguration?.ObjectLockEnabled === "Enabled"
+ setObjectLock(lockEnabled)
+
+ if (lockEnabled) {
+ const rule = lockCfg?.ObjectLockConfiguration?.Rule?.DefaultRetention
+ if (rule) {
+ setRetentionMode(rule.Mode ?? null)
+ setRetentionPeriod(rule.Days ?? rule.Years ?? null)
+ setRetentionUnit(rule.Years ? "Years" : rule.Days ? "Days" : null)
+ } else {
+ setRetentionMode(null)
+ setRetentionPeriod(null)
+ setRetentionUnit(null)
+ }
} else {
setRetentionMode(null)
setRetentionPeriod(null)
setRetentionUnit(null)
}
- } else {
- setRetentionMode(null)
- setRetentionPeriod(null)
- setRetentionUnit(null)
}
- setVersioning((verResp as { Status?: string })?.Status ?? null)
- setQuotaInfo(quotaResp as QuotaInfo | null)
+ if (verResp !== UNAVAILABLE) setVersioning(verResp?.Status ?? null)
+ if (quotaResp !== UNAVAILABLE) setQuotaInfo(quotaResp)
+ setReadErrors(nextErrors)
+ setLoadedBucket(bucketName)
} finally {
- setLoading(false)
+ if (requestVersion === requestVersionRef.current) setLoading(false)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps -- bucketApi methods are stable
- }, [bucketName])
+ }, [
+ bucketName,
+ getBucketCors,
+ getBucketEncryption,
+ getBucketPolicy,
+ getBucketQuota,
+ getBucketTagging,
+ getBucketVersioning,
+ getObjectLockConfiguration,
+ t,
+ ])
React.useEffect(() => {
+ setShowPolicyModal(false)
+ setShowEncryptModal(false)
+ setShowCorsModal(false)
+ setShowTagModal(false)
+ setShowQuotaModal(false)
+ setShowRetentionModal(false)
+ setDeleteTagIndex(null)
+ mutationRef.current = null
+ setMutation(null)
fetchData()
+ return () => {
+ requestVersionRef.current += 1
+ }
}, [fetchData])
const openPolicyModal = () => {
- if (!canEditBucketPolicy) return
+ if (!canEditBucketPolicy || readErrors.policy || loading || mutation) return
setPolicyFormPolicy(policyType)
setPolicyFormContent(policy ?? "{}")
+ setPolicyFormError("")
setShowPolicyModal(true)
}
const submitPolicy = async () => {
+ if (policyFormPolicy === "custom") {
+ try {
+ const parsed = JSON.parse(policyFormContent) as unknown
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error()
+ } catch {
+ setPolicyFormError(t("Policy format is invalid. Please check the JSON syntax."))
+ document.getElementById("bucket-policy-content")?.focus()
+ return
+ }
+ }
+ setPolicyFormError("")
+ if (!beginMutation("policy")) return
try {
if (policyFormPolicy === "private" && !canDeleteBucketPolicy) {
message.error(t("Edit Failed"))
@@ -241,11 +351,15 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
await fetchData()
} catch (err) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
+ } finally {
+ finishMutation()
}
}
- const openEncryptModal = async () => {
- if (!canEditEncryption) return
+ const openEncryptModal = () => {
+ if (!canEditEncryption || readErrors.encryption || loading || mutation) return
+ setKmsKeyError("")
+ setKmsKeyOptions([])
const enc = encryption as {
ServerSideEncryptionConfiguration?: {
Rules?: Array<{
@@ -268,23 +382,41 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
React.useEffect(() => {
- if (showEncryptModal && encryptFormType === "SSE-KMS") {
- getKeyList()
- .then((keys) => {
- const list =
- (keys as { keys?: Array<{ key_id?: string; tags?: { name?: string }; description?: string }> })?.keys ?? []
- setKmsKeyOptions(
- list.map((k) => ({
- label: k.tags?.name ?? k.description ?? `Key-${(k.key_id ?? "").slice(0, 24)}`,
- value: k.key_id ?? "",
- })),
- )
- })
- .catch(() => setKmsKeyOptions([]))
+ if (!showEncryptModal || encryptFormType !== "SSE-KMS") return
+
+ let cancelled = false
+ setKmsKeyLoading(true)
+ setKmsKeyError("")
+ getKeyList()
+ .then((keys) => {
+ if (cancelled) return
+ const list =
+ (keys as { keys?: Array<{ key_id?: string; tags?: { name?: string }; description?: string }> })?.keys ?? []
+ setKmsKeyOptions(
+ list.map((k) => ({
+ label: k.tags?.name ?? k.description ?? `Key-${(k.key_id ?? "").slice(0, 24)}`,
+ value: k.key_id ?? "",
+ })),
+ )
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setKmsKeyOptions([])
+ setKmsKeyError(t("Failed to load key list"))
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setKmsKeyLoading(false)
+ })
+
+ return () => {
+ cancelled = true
}
- }, [showEncryptModal, encryptFormType, getKeyList])
+ }, [showEncryptModal, encryptFormType, getKeyList, kmsReloadVersion, t])
const submitEncrypt = async () => {
+ if (encryptFormType === "SSE-KMS" && (kmsKeyLoading || kmsKeyError)) return
+ if (!beginMutation("encryption")) return
try {
if (encryptFormType === "disabled") {
await bucketApi.deleteBucketEncryption(bucketName)
@@ -317,11 +449,13 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
await fetchData()
} catch (err) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
+ } finally {
+ finishMutation()
}
}
const openCorsModal = () => {
- if (!canEditCors) return
+ if (!canEditCors || readErrors.cors || loading || mutation) return
setCorsFormEnabled(Boolean(corsConfig?.CORSRules?.length))
setCorsFormContent(stringifyBucketCorsConfig(corsConfig))
setShowCorsModal(true)
@@ -329,6 +463,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const submitCors = async () => {
if (!canEditCors) return
+ if (!beginMutation("cors")) return
try {
if (!corsFormEnabled) {
@@ -349,11 +484,13 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
await fetchData()
} catch (err) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
+ } finally {
+ finishMutation()
}
}
const openTagModal = (index = -1) => {
- if (!canEditTags) return
+ if (!canEditTags || readErrors.tagging || loading || mutation) return
setEditingTagIndex(index)
if (index >= 0 && tags[index]) {
setTagFormKey(tags[index].Key)
@@ -371,27 +508,53 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
message.error(t("Please fill in complete tag information"))
return
}
- const next = [...tags]
- if (editingTagIndex >= 0) {
- next[editingTagIndex] = { Key: tagFormKey.trim(), Value: tagFormValue.trim() }
- } else {
- next.push({ Key: tagFormKey.trim(), Value: tagFormValue.trim() })
- }
+ if (!beginMutation("tagging")) return
try {
+ const latestResponse = await getBucketTagging(bucketName).catch((error: unknown) => {
+ if (isMissingBucketConfiguration(error, "tagging")) return null
+ throw error
+ })
+ const latest = latestResponse?.TagSet?.map((item) => ({ Key: item.Key ?? "", Value: item.Value ?? "" })) ?? []
+ const nextKey = tagFormKey.trim()
+ const nextValue = tagFormValue.trim()
+ const editingTag = editingTagIndex >= 0 ? tags[editingTagIndex] : null
+
+ if (editingTag) {
+ const matches = latest.filter((item) => item.Key === editingTag.Key)
+ if (matches.length !== 1) throw new Error(t("Configuration changed. Refresh and try again."))
+ if (nextKey !== editingTag.Key && latest.some((item) => item.Key === nextKey)) {
+ throw new Error(t("Tag key already exists"))
+ }
+ } else if (latest.some((item) => item.Key === nextKey)) {
+ throw new Error(t("Tag key already exists"))
+ }
+
+ const next = editingTag
+ ? latest.map((item) => (item.Key === editingTag.Key ? { Key: nextKey, Value: nextValue } : item))
+ : [...latest, { Key: nextKey, Value: nextValue }]
await bucketApi.putBucketTagging(bucketName, { TagSet: next })
message.success(t("Tag Update Success"))
setShowTagModal(false)
await fetchData()
} catch (err) {
message.error(`${t("Tag Update Failed")}: ${(err as Error).message}`)
+ } finally {
+ finishMutation()
}
}
const confirmDeleteTag = async () => {
if (!canEditTags) return
if (deleteTagIndex === null) return
- const next = tags.filter((_, i) => i !== deleteTagIndex)
+ if (!beginMutation("tagging")) return
try {
+ const target = tags[deleteTagIndex]
+ if (!target) throw new Error(t("Configuration changed. Refresh and try again."))
+ const latestResponse = await getBucketTagging(bucketName)
+ const latest = latestResponse?.TagSet?.map((item) => ({ Key: item.Key ?? "", Value: item.Value ?? "" })) ?? []
+ const matches = latest.filter((item) => item.Key === target.Key)
+ if (matches.length !== 1) throw new Error(t("Configuration changed. Refresh and try again."))
+ const next = latest.filter((item) => item.Key !== target.Key)
if (next.length === 0) {
await bucketApi.deleteBucketTagging(bucketName)
} else {
@@ -402,11 +565,14 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
await fetchData()
} catch (err) {
message.error(t("Tag Delete Failed", { error: (err as Error).message }))
+ } finally {
+ finishMutation()
}
}
const handleVersionToggle = async (enabled: boolean) => {
- if (!canEditVersioning) return
+ if (!canEditVersioning || readErrors.versioning || readErrors.objectLock || loading) return
+ if (!beginMutation("versioning")) return
setVersionLoading(true)
const prev = versioning
try {
@@ -418,11 +584,13 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
} finally {
setVersionLoading(false)
+ finishMutation()
}
}
const openQuotaModal = () => {
- if (!canEditQuota) return
+ if (!canEditQuota || readErrors.quota || loading || mutation) return
+ setQuotaFormError("")
if (quotaInfo?.quota && quotaInfo.quota > 0) {
const bytes = quotaInfo.quota
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
@@ -443,12 +611,20 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const submitQuota = async () => {
if (!canEditQuota) return
+ const parsedQuota = Number(getBytes(quotaFormSize, quotaFormUnit))
+ if (quotaFormEnabled && (!Number.isSafeInteger(parsedQuota) || parsedQuota <= 0)) {
+ setQuotaFormError(t("Enter a valid positive quota."))
+ document.getElementById("bucket-quota-size")?.focus()
+ return
+ }
+ setQuotaFormError("")
+ if (!beginMutation("quota")) return
try {
if (!quotaFormEnabled) {
await bucketApi.deleteBucketQuota(bucketName)
} else {
await bucketApi.putBucketQuota(bucketName, {
- quota: Number.parseInt(getBytes(quotaFormSize, quotaFormUnit), 10),
+ quota: parsedQuota,
quota_type: "HARD",
})
}
@@ -457,11 +633,13 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
await fetchData()
} catch (err) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
+ } finally {
+ finishMutation()
}
}
const openRetentionModal = () => {
- if (!canEditObjectLock) return
+ if (!canEditObjectLock || readErrors.objectLock || loading || mutation) return
if (!objectLock) {
message.error(t("Object lock is not enabled, cannot set retention"))
return
@@ -469,6 +647,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
setRetentionFormMode(retentionMode ?? "GOVERNANCE")
setRetentionFormUnit(retentionUnit ?? "Days")
setRetentionPeriodInput(retentionPeriod != null ? String(retentionPeriod) : "")
+ setRetentionFormError("")
setShowRetentionModal(true)
}
@@ -476,11 +655,14 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
if (!canEditObjectLock) return
const mode = retentionFormMode
const unit = retentionFormUnit
- const period = retentionPeriodInput ? Math.max(1, Number.parseInt(retentionPeriodInput, 10) || 1) : null
- if (!period) {
- message.error(t("Please fill in complete retention information"))
+ const period = Number(retentionPeriodInput)
+ if (!Number.isSafeInteger(period) || period <= 0) {
+ setRetentionFormError(t("Please fill in complete retention information"))
+ document.getElementById("bucket-retention-period")?.focus()
return
}
+ setRetentionFormError("")
+ if (!beginMutation("retention")) return
try {
await bucketApi.putObjectLockConfiguration(bucketName, {
ObjectLockEnabled: "Enabled",
@@ -497,6 +679,8 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
await fetchData()
} catch (err) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
+ } finally {
+ finishMutation()
}
}
@@ -513,257 +697,383 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const corsRules = corsConfig?.CORSRules ?? []
const corsMethods = Array.from(new Set(corsRules.flatMap((rule) => rule.AllowedMethods ?? [])))
const corsOrigins = Array.from(new Set(corsRules.flatMap((rule) => rule.AllowedOrigins ?? [])))
+ const initialLoading = loading && loadedBucket !== bucketName
+ const readErrorProps = (key: BucketSettingKey) =>
+ readErrors[key]
+ ? {
+ error: readErrors[key],
+ errorTitle: t("Configuration unavailable"),
+ retryLabel: t("Refresh"),
+ onRetry: loading ? undefined : fetchData,
+ }
+ : {}
+ const settingsNav = [
+ { href: "#access-sharing", label: t("Access & Sharing") },
+ { href: "#data-protection", label: t("Data Protection") },
+ { href: "#capacity-metadata", label: t("Capacity & Metadata") },
+ { href: "#automation", label: t("Automation") },
+ ]
- if (loading) {
+ if (initialLoading) {
return (
-
-
+
+
)
}
return (
-
-
- {/* Access Policy */}
- -
-
- {t("Access Policy")}
-
- {canEditBucketPolicy ? (
-
-
+
+
+
+
+
+
+
+
+
+ {t("Edit")}
+
+ ) : null
+ }
+ {...readErrorProps("policy")}
+ />
+ 0 ? t("Configured") : t("Not configured")}
+ statusVariant={readErrors.cors ? "destructive" : corsRules.length > 0 ? "secondary" : "outline"}
+ action={
+ canEditCors ? (
+
+
{t("Edit")}
- ) : null}
-
-
-
- {policyLabel}
-
-
-
- {/* Encryption */}
- -
-
-
- {t("Encryption")}
- {encryptionLabel}
-
-
- {canEditEncryption ? (
-
-
+ ) : null
+ }
+ {...readErrorProps("cors")}
+ >
+ {!readErrors.cors && corsRules.length > 0 ? (
+
+
+
{t("CORS Rules")}
+ {corsRules.length}
+
+
+
{t("Allowed Methods")}
+ {corsMethods.join(", ") || "-"}
+
+
+
{t("Allowed Origins")}
+ {corsOrigins.slice(0, 2).join(", ") || "-"}
+
+
+ ) : null}
+
+
+
+
+
+
{t("Edit")}
- ) : null}
-
-
-
-
- {/* CORS */}
- -
-
-
- {t("Bucket CORS")}
-
- {corsRules.length > 0 ? t("Configured") : t("Not Enabled")}
-
-
-
- {canEditCors ? (
-
-
+ ) : null
+ }
+ {...readErrorProps("encryption")}
+ />
+
+
+ {versionLoading ? (
+
+ ) : null}
+
+ ) : null
+ }
+ {...readErrorProps("versioning")}
+ >
+
+ {(objectLock ?? false)
+ ? t("Versioning stays enabled while Object Lock is active.")
+ : t("Versioning can be suspended but existing versions are preserved.")}
+
+
+
+
+
{t("Edit")}
- ) : null}
-
-
-
- {corsRules.length > 0 ? (
-
+ ) : null
+ }
+ >
+ {!readErrors.objectLock && retentionMode ? (
+
-
{t("CORS Rules")}
-
{corsRules.length}
+
{t("Retention Mode")}
+
{t(retentionMode)}
-
{t("Allowed Methods")}
-
{corsMethods.join(", ") || "-"}
+
{t("Retention Period")}
+
{retentionPeriod ?? "-"}
-
-
{t("Allowed Origins")}
-
{corsOrigins.join(", ") || "-"}
+
+
{t("Retention Unit")}
+ {retentionUnit ? t(retentionUnit) : "-"}
-
- ) : (
- {t("No Data")}
- )}
-
-
-
- {/* Tag */}
- -
-
- {t("Tag")}
-
- {canEditTags ? (
- openTagModal()}>
-
- {t("Add")}
+
+ ) : null}
+
+
+
+
+
+
+ {t("Edit")}
+
+ ) : null
+ }
+ {...readErrorProps("quota")}
+ >
+ {!readErrors.quota && quotaInfo?.quota ? (
+
+
+
{t("Quota Size")}
+ {formatBytes(quotaInfo.quota)}
+
+
+
{t("Usage")}
+
+ {formatBytes(quotaInfo.size)} ({((quotaInfo.size / quotaInfo.quota) * 100).toFixed(1)}%)
+
+
+
+ ) : null}
+
+ 0 ? t("Configured") : t("Not configured")}
+ statusVariant={readErrors.tagging ? "destructive" : tags.length > 0 ? "secondary" : "outline"}
+ action={
+ canEditTags ? (
+ openTagModal()}
+ disabled={Boolean(readErrors.tagging) || loading || mutation !== null}
+ >
+
+ {t("Add Tag")}
- ) : null}
-
-
-
- {tags.length > 0 ? (
-
+ ) : null
+ }
+ {...readErrorProps("tagging")}
+ >
+ {!readErrors.tagging && tags.length > 0 ? (
+
{tags.map((tag, index) => (
-
+
openTagModal(index)}
- disabled={!canEditTags}
- title={`${tag.Key}:${tag.Value}`}
+ disabled={!canEditTags || mutation !== null}
+ aria-label={`${t("Edit")} ${t("Tag")}: ${tag.Key}:${tag.Value}`}
>
{tag.Key}:{tag.Value}
setDeleteTagIndex(index)}
- disabled={!canEditTags}
- aria-label={t("Delete Tag")}
+ disabled={!canEditTags || mutation !== null}
+ aria-label={`${t("Delete Tag")}: ${tag.Key}:${tag.Value}`}
>
-
+
-
+
))}
-
- ) : (
- {t("No Data")}
- )}
-
-
-
- {/* Object Lock & Version Control */}
- -
-
- {objectLockLoading && }
-
-
-
-
- {t("Object Lock")}
-
-
-
-
-
-
- {t("Version Control")}
-
- {versionLoading && }
-
-
-
-
-
-
- {/* Bucket Quota */}
- -
-
-
- {t("Bucket Quota")}
-
- {quotaInfo?.quota ? t("Enabled") : t("Disabled")}
-
-
-
- {canEditQuota ? (
-
- {t("Edit")}
-
- ) : null}
-
-
- {quotaInfo?.quota ? (
-
-
-
-
{t("Quota Size")}
-
{formatBytes(quotaInfo.quota)}
-
-
-
{t("Usage")}
-
- {formatBytes(quotaInfo.size)} ({((quotaInfo.size / quotaInfo.quota) * 100).toFixed(1)}%)
-
-
-
-
- ) : null}
-
-
- {/* Retention */}
- -
-
-
- {t("Retention")}
-
- {retentionMode ? t("Enabled") : t("Disabled")}
-
-
-
- {canEditObjectLock ? (
-
- {t("Edit")}
+
+ ) : !readErrors.tagging ? (
+ {t("No tags")}
+ ) : null}
+
+
+
+
+ {[
+ {
+ label: t("Events"),
+ description: t("Send notifications when objects change."),
+ href: buildModuleBucketPath("/events", bucketName),
+ },
+ {
+ label: t("Lifecycle"),
+ description: t("Transition or expire objects automatically."),
+ href: buildModuleBucketPath("/lifecycle", bucketName),
+ },
+ {
+ label: t("Bucket Replication"),
+ description: t("Copy objects to another bucket or site."),
+ href: buildModuleBucketPath("/replication", bucketName),
+ },
+ ].map((item) => (
+ }>
+ {`${t("Open")} ${item.label}`}
+
- ) : null}
-
-
-
-
-
-
{t("Retention Mode")}
-
{retentionMode ? t(retentionMode) : "-"}
-
-
-
{t("Retention Unit")}
-
{retentionUnit ? t(retentionUnit) : "-"}
-
-
-
{t("Retention Period")}
-
{retentionPeriod ?? "-"}
-
-
-
-
-
+ }
+ />
+ ))}
+
+
{/* Policy Modal */}
-
-
-
+ !mutation && setShowPolicyModal(open)}>
+
+
{t("Set Policy")}
-
+
- {t("Policy")}
+ {t("Policy")}
setPolicyFormPolicy(v as BucketPolicyType | "custom")}
+ onValueChange={(v) => {
+ setPolicyFormPolicy(v as BucketPolicyType | "custom")
+ setPolicyFormError("")
+ }}
>
-
+
@@ -773,7 +1083,22 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
+
+ {policyFormPolicy === "public"
+ ? t("Public access can expose objects to anyone allowed by the policy. Review it before saving.")
+ : policyFormPolicy === "private"
+ ? t("Removes the bucket policy. IAM and credential-based access still apply.")
+ : t("Uses the JSON policy below. Invalid statements can block access.")}
+
+ {policyFormPolicy === "public" ? (
+
+ {t("Public")}
+
+ {t("Public access can expose objects to anyone allowed by the policy. Review it before saving.")}
+
+
+ ) : null}
{policyFormPolicy === "custom" && (
{t("Policy Content")}
@@ -783,36 +1108,48 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
id="bucket-policy-content"
name="bucket-policy-content"
value={policyFormContent}
- onChange={(e) => setPolicyFormContent(e.target.value)}
+ onChange={(e) => {
+ setPolicyFormContent(e.target.value)
+ setPolicyFormError("")
+ }}
className="min-h-[200px] font-mono text-xs"
spellCheck={false}
+ aria-invalid={Boolean(policyFormError)}
+ aria-describedby={policyFormError ? "bucket-policy-error" : undefined}
/>
+
{policyFormError || undefined}
)}
-
- setShowPolicyModal(false)}>
+
+ setShowPolicyModal(false)} disabled={mutation === "policy"}>
{t("Cancel")}
- {t("Confirm")}
+
+ {mutation === "policy" ? : null}
+ {t("Save Policy")}
+
{/* Encryption Modal */}
-
-
-
+ !mutation && setShowEncryptModal(open)}>
+
+
{t("Enable Storage Encryption")}
-
+
- {t("Encryption Type")}
+ {t("Encryption Type")}
setEncryptFormType(value ?? "")}>
-
+
@@ -822,13 +1159,33 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
+
+ {encryptFormType === "disabled"
+ ? t("Future uploads to this bucket will not be encrypted by default.")
+ : encryptFormType === "SSE-KMS"
+ ? t("Uses the selected KMS key to encrypt new objects.")
+ : t("Uses server-managed keys to encrypt new objects.")}
+
+ {encryptFormType === "disabled" ? (
+
+ {t("Disabled")}
+
+ {t("Future uploads to this bucket will not be encrypted by default.")}
+
+
+ ) : null}
{encryptFormType === "SSE-KMS" && (
- KMS Key ID
+ KMS Key ID
setEncryptFormKmsKeyId(value ?? "")}>
-
+
@@ -840,25 +1197,56 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
+ {kmsKeyLoading ? {t("Loading")} : null}
+ {!kmsKeyLoading && !kmsKeyError && kmsKeyOptions.length === 0 ? (
+ {t("No Data")}
+ ) : null}
+ {kmsKeyError ? (
+
+ {t("Failed to load key list")}
+ {t("Refresh to try again.")}
+ setKmsReloadVersion((value) => value + 1)}
+ >
+ {t("Refresh")}
+
+
+ ) : null}
)}
-
- setShowEncryptModal(false)}>
+
+ setShowEncryptModal(false)} disabled={mutation === "encryption"}>
{t("Cancel")}
- {t("Confirm")}
+
+ {mutation === "encryption" ? : null}
+ {t("Save Encryption")}
+
{/* CORS Modal */}
-
-
-
+ !mutation && setShowCorsModal(open)}>
+
+
{t("Set Bucket CORS")}
-
+
{t("Bucket CORS")}
@@ -877,6 +1265,8 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
onChange={(e) => setCorsFormContent(e.target.value)}
className="min-h-[260px] font-mono text-xs"
spellCheck={false}
+ aria-invalid={Boolean(corsValidation.error)}
+ aria-describedby="bucket-cors-validation"
/>
@@ -884,9 +1274,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
{t('CORS JSON must be an array of rules or an object with a "CORSRules" array.')}
{corsValidation.error ? (
-
{corsValidation.error}
+
+ {corsValidation.error}
+
) : (
-
{t("CORS JSON validation passed")}
+
{t("CORS JSON validation passed")}
)}
) : (
@@ -895,24 +1287,31 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
)}
-
- setShowCorsModal(false)}>
+
+ setShowCorsModal(false)} disabled={mutation === "cors"}>
{t("Cancel")}
-
- {t("Confirm")}
+
+ {mutation === "cors" ?