From 8ecf64136e476872e5b692fcb0788f6838762afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Wed, 5 Aug 2026 01:56:54 +0800 Subject: [PATCH] feat(replication): add bucket replication rule editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replication tab only supported adding and deleting rules, so target settings — most importantly the sync/async replication mode — could never be changed after creation. Add an edit dialog with full field parity with the add form (priority, mode, endpoint, target bucket, credentials, region, storage class, prefix, tags, TLS, existing-object/delete-marker/delete toggles, health check, bandwidth), prefilled from the rule and its remote target. Saving updates the target via set-remote-target?update=true using the server's MinIO-style field-group ops (creds/sync/bandwidth) computed by change detection, then rewrites the rule in place. The secret key can stay blank: untouched groups keep their stored values server-side, so flipping the replication mode no longer requires re-entering credentials; the secret is only required when connection settings actually change. The rules table gains a replication-mode column joined from the bucket's remote targets. The region field is read-only in the edit dialog because the MinIO update contract defines no region group. --- components/buckets/replication-tab.tsx | 141 +++- components/replication/edit-form.tsx | 968 +++++++++++++++++++++++++ hooks/use-bucket.ts | 11 +- i18n/locales/ar-MA.json | 3 + i18n/locales/de-DE.json | 3 + i18n/locales/en-US.json | 3 + i18n/locales/es-ES.json | 3 + i18n/locales/fr-FR.json | 3 + i18n/locales/id-ID.json | 3 + i18n/locales/it-IT.json | 3 + i18n/locales/ja-JP.json | 3 + i18n/locales/ko-KR.json | 3 + i18n/locales/pt-BR.json | 3 + i18n/locales/ru-RU.json | 3 + i18n/locales/tr-TR.json | 3 + i18n/locales/vi-VN.json | 3 + i18n/locales/zh-CN.json | 3 + 17 files changed, 1132 insertions(+), 30 deletions(-) create mode 100644 components/replication/edit-form.tsx diff --git a/components/buckets/replication-tab.tsx b/components/buckets/replication-tab.tsx index b381d9f..88360d6 100644 --- a/components/buckets/replication-tab.tsx +++ b/components/buckets/replication-tab.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useTranslation } from "react-i18next" -import { RiAddLine, RiRefreshLine, RiDeleteBin7Line } from "@remixicon/react" +import { RiAddLine, RiRefreshLine, RiDeleteBin7Line, RiEditLine } from "@remixicon/react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" @@ -12,6 +12,11 @@ import { useBucket } from "@/hooks/use-bucket" import { usePermissions } from "@/hooks/use-permissions" import { useRuntimeCapabilities } from "@/hooks/use-runtime-capabilities" import { ReplicationNewForm } from "@/components/replication/new-form" +import { + ReplicationEditForm, + type EditableReplicationRule, + type RemoteReplicationTarget, +} from "@/components/replication/edit-form" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { isMissingBucketConfiguration, removeMatchingBucketRule } from "@/lib/bucket-configuration" @@ -37,8 +42,13 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead const dialog = useDialog() const { canCapability } = usePermissions() const { capabilities, error: capabilitiesError } = useRuntimeCapabilities() - const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } = - useBucket() + const { + getBucketReplication, + putBucketReplication, + deleteBucketReplication, + deleteRemoteReplicationTarget, + listRemoteReplicationTargets, + } = useBucket() const replicationContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName]) const replicationSupported = capabilities?.replication.bucketReplication.status.state === "supported" const remoteTargetsSupported = capabilities?.replication.remoteTargets.status.state === "supported" @@ -51,10 +61,15 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead const canAddReplication = canEditReplication && remoteTargetsSupported const [data, setData] = React.useState([]) + const [targets, setTargets] = React.useState([]) const [loading, setLoading] = React.useState(false) const [loadError, setLoadError] = React.useState("") const [mutatingRuleId, setMutatingRuleId] = React.useState(null) const [newFormOpen, setNewFormOpen] = React.useState(false) + const [editing, setEditing] = React.useState<{ + rule: EditableReplicationRule + target: RemoteReplicationTarget + } | null>(null) const requestVersionRef = React.useRef(0) const loadData = React.useCallback(async () => { @@ -62,13 +77,18 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead setLoading(true) try { const res = await getBucketReplication(bucketName) + // Target details (sync mode, bandwidth) are optional context: a listing + // failure must not block the rules table, it only disables per-row edit. + const targetList = await listRemoteReplicationTargets(bucketName).catch(() => []) if (requestVersion !== requestVersionRef.current) return setData(res?.ReplicationConfiguration?.Rules ?? []) + setTargets(Array.isArray(targetList) ? (targetList as RemoteReplicationTarget[]) : []) setLoadError("") } catch (error) { if (requestVersion !== requestVersionRef.current) return if (isMissingBucketConfiguration(error, "replication")) { setData([]) + setTargets([]) setLoadError("") } else { setLoadError(t("Unable to load replication rules. Refresh before making changes.")) @@ -76,7 +96,12 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead } finally { if (requestVersion === requestVersionRef.current) setLoading(false) } - }, [bucketName, getBucketReplication, t]) + }, [bucketName, getBucketReplication, listRemoteReplicationTargets, t]) + + const targetForRule = React.useCallback( + (rule: ReplicationRule) => targets.find((target) => target.arn && target.arn === rule.Destination?.Bucket) ?? null, + [targets], + ) React.useEffect(() => { loadData() @@ -199,27 +224,56 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead header: () => t("Storage Class"), cell: ({ row }) => {row.original.Destination?.StorageClass || "-"}, }, + { + id: "replication-mode", + header: () => t("Mode"), + cell: ({ row }) => { + const target = targetForRule(row.original) + if (!target) return - + return {target.replicationSync ? t("Synchronous") : t("Asynchronous")} + }, + }, { id: "actions", header: () => t("Actions"), enableSorting: false, - cell: ({ row }) => ( -
- -
- ), + cell: ({ row }) => { + const target = targetForRule(row.original) + return ( +
+ + +
+ ) + }, }, ], - [canEditReplication, confirmDelete, loadError, loading, mutatingRuleId, t], + [canEditReplication, confirmDelete, loadError, loading, mutatingRuleId, remoteTargetsSupported, t, targetForRule], ) const { table } = useDataTable({ @@ -314,15 +368,35 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead {canEditReplication ? ( - +
+ + +
) : null} ) @@ -336,6 +410,17 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead bucketName={bucketName} onSuccess={loadData} /> + + { + if (!nextOpen) setEditing(null) + }} + bucketName={bucketName} + rule={editing?.rule ?? null} + target={editing?.target ?? null} + onSuccess={loadData} + /> ) } diff --git a/components/replication/edit-form.tsx b/components/replication/edit-form.tsx new file mode 100644 index 0000000..4c0e888 --- /dev/null +++ b/components/replication/edit-form.tsx @@ -0,0 +1,968 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback, useMemo } from "react" +import { useTranslation } from "react-i18next" +import { RiAddLine, RiDeleteBinLine } from "@remixicon/react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field" +import { useBucket } from "@/hooks/use-bucket" +import { useRuntimeCapabilities } from "@/hooks/use-runtime-capabilities" +import { useMessage } from "@/lib/feedback/message" +import { getBytes } from "@/lib/functions" +import { isMissingBucketConfiguration, normalizeReplicationRulesForRolelessConfig } from "@/lib/bucket-configuration" +import { buildBucketReplicationTlsPayload, type BucketReplicationTlsMode } from "@/lib/bucket-replication-tls" +import { getRuntimeCapabilityFieldState } from "@/lib/runtime-capabilities" + +export interface RemoteReplicationTarget { + arn?: string + endpoint?: string + targetbucket?: string + secure?: boolean + region?: string + replicationSync?: boolean + bandwidth_limit?: number + healthCheckDuration?: number + skipTlsVerify?: boolean + caCertPem?: string + credentials?: { accessKey?: string } +} + +interface Tag { + key: string + value: string +} + +export interface EditableReplicationRule { + ID?: string + Status?: string + Priority?: number + Filter?: { + Prefix?: string + Tag?: { Key?: string; Value?: string } + And?: { Prefix?: string; Tags?: { Key?: string; Value?: string }[] } + } + ExistingObjectReplication?: { Status?: string } + DeleteMarkerReplication?: { Status?: string } + DeleteReplication?: { Status?: string } + Destination?: { Bucket?: string; StorageClass?: string } +} + +interface ReplicationEditFormProps { + open: boolean + onOpenChange: (open: boolean) => void + bucketName: string | null + rule: EditableReplicationRule | null + target: RemoteReplicationTarget | null + onSuccess?: () => void +} + +const BANDWIDTH_UNITS = ["Gi", "Mi", "Ki"] as const + +function bytesToBandwidth(bytes: number | undefined): { bandwidth: number; unit: string } { + if (!bytes || bytes <= 0) return { bandwidth: 100, unit: "Gi" } + for (const unit of BANDWIDTH_UNITS) { + const factor = unit === "Gi" ? 1024 ** 3 : unit === "Mi" ? 1024 ** 2 : 1024 + if (bytes % factor === 0) return { bandwidth: bytes / factor, unit } + } + return { bandwidth: Math.max(1, Math.round(bytes / 1024)), unit: "Ki" } +} + +function ruleTags(rule: EditableReplicationRule | null): Tag[] { + const andTags = rule?.Filter?.And?.Tags + if (andTags?.length) { + return andTags.map((tag) => ({ key: tag.Key ?? "", value: tag.Value ?? "" })) + } + const single = rule?.Filter?.Tag + if (single?.Key) { + return [{ key: single.Key, value: single.Value ?? "" }] + } + return [{ key: "", value: "" }] +} + +export function ReplicationEditForm({ + open, + onOpenChange, + bucketName, + rule, + target, + onSuccess, +}: ReplicationEditFormProps) { + const { t } = useTranslation() + const message = useMessage() + const { setRemoteReplicationTarget, putBucketReplication, getBucketReplication } = useBucket() + const { capabilities, isLoading: capabilitiesLoading, error: capabilitiesError } = useRuntimeCapabilities() + + const [level, setLevel] = useState("1") + const [endpoint, setEndpoint] = useState("") + const [tls, setTls] = useState(false) + const [tlsMode, setTlsMode] = useState("verify") + const [caCertPem, setCaCertPem] = useState("") + const [accessKey, setAccessKey] = useState("") + const [secretKey, setSecretKey] = useState("") + const [bucket, setBucket] = useState("") + const [region, setRegion] = useState("us-east-1") + const [modeType, setModeType] = useState("async") + const [timecheck, setTimecheck] = useState("60") + const [unit, setUnit] = useState("Gi") + const [bandwidth, setBandwidth] = useState(100) + const [storageType, setStorageType] = useState("STANDARD") + const [prefix, setPrefix] = useState("") + const [tags, setTags] = useState([{ key: "", value: "" }]) + const [existingObject, setExistingObject] = useState(true) + const [expiredDeleteMark, setExpiredDeleteMark] = useState(true) + const [replicateDelete, setReplicateDelete] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [saveError, setSaveError] = useState("") + const [fieldErrors, setFieldErrors] = useState<{ + endpoint?: string + bucket?: string + accessKey?: string + secretKey?: string + timecheck?: string + caCertPem?: string + }>({}) + + const modeOptions = useMemo( + () => [ + { label: t("Asynchronous"), value: "async" }, + { label: t("Synchronous"), value: "sync" }, + ], + [t], + ) + + const unitOptions = useMemo( + () => [ + { label: "KiB/s", value: "Ki" }, + { label: "MiB/s", value: "Mi" }, + { label: "GiB/s", value: "Gi" }, + ], + [], + ) + + const canEditBucketField = useCallback( + (fieldName: string) => getRuntimeCapabilityFieldState(capabilities, "bucketReplication", fieldName) === "supported", + [capabilities], + ) + + const canEditTargetField = useCallback( + (fieldName: string) => getRuntimeCapabilityFieldState(capabilities, "remoteTargets", fieldName) === "supported", + [capabilities], + ) + + const canEditCurrentTagFilter = + tags.length > 1 ? canEditBucketField("Rule.Filter.And") : canEditBucketField("Rule.Filter.Tag") + const canAddTag = canEditBucketField("Rule.Filter.And") + + const storageClassOptions = useMemo(() => { + const supported = capabilities?.storageClasses.supportedWriteClasses ?? [] + const current = storageType.trim() || "STANDARD" + const values = [...supported] + if (!values.includes(current)) { + values.push(current) + } + return values + }, [capabilities, storageType]) + + const replicationFeaturesSupported = + capabilities?.replication.bucketReplication.status.state === "supported" && + capabilities.replication.remoteTargets.status.state === "supported" + const requiredBucketFieldsSupported = [ + "Role", + "Rule.ID", + "Rule.Status", + "Rule.Priority", + "Rule.Destination.Bucket", + ].every(canEditBucketField) + const requiredTargetFieldsSupported = [ + "sourcebucket", + "endpoint", + "credentials.accessKey", + "credentials.secretKey", + "targetbucket", + "secure", + "path", + "api", + "type", + "region", + "bandwidth", + "replicationSync", + "skipTlsVerify", + "caCertPem", + ].every(canEditTargetField) + const controlsLocked = + submitting || + capabilitiesLoading || + !capabilities || + !replicationFeaturesSupported || + !requiredBucketFieldsSupported || + !requiredTargetFieldsSupported || + !target?.arn + + const resetFormFromRule = useCallback(() => { + setLevel(String(rule?.Priority ?? 1)) + setEndpoint(target?.endpoint ?? "") + setTls(Boolean(target?.secure)) + setTlsMode(target?.skipTlsVerify ? "skip" : target?.caCertPem ? "custom-ca" : "verify") + setCaCertPem(target?.caCertPem ?? "") + setAccessKey(target?.credentials?.accessKey ?? "") + setSecretKey("") + setBucket(target?.targetbucket ?? "") + setRegion(target?.region || "us-east-1") + setModeType(target?.replicationSync ? "sync" : "async") + setTimecheck(String(target?.healthCheckDuration || 60)) + const initial = bytesToBandwidth(target?.bandwidth_limit) + setBandwidth(initial.bandwidth) + setUnit(initial.unit) + setStorageType(rule?.Destination?.StorageClass || "STANDARD") + setPrefix(rule?.Filter?.Prefix ?? rule?.Filter?.And?.Prefix ?? "") + setTags(ruleTags(rule)) + setExistingObject(rule?.ExistingObjectReplication?.Status === "Enabled") + setExpiredDeleteMark(rule?.DeleteMarkerReplication?.Status === "Enabled") + setReplicateDelete(rule?.DeleteReplication?.Status === "Enabled") + setSubmitting(false) + setSaveError("") + setFieldErrors({}) + }, [rule, target]) + + useEffect(() => { + if (open) { + resetFormFromRule() + } + }, [open, resetFormFromRule]) + + const addTag = () => { + setTags((prev) => [...prev, { key: "", value: "" }]) + } + + const removeTag = (index: number) => { + if (tags.length === 1) return + setTags((prev) => prev.filter((_, i) => i !== index)) + } + + const updateTag = (index: number, field: "key" | "value", value: string) => { + setTags((prev) => prev.map((tag, i) => (i === index ? { ...tag, [field]: value } : tag))) + } + + // Field-group change detection, mirroring the server's MinIO-style update ops: + // only groups that actually changed are sent, and credentials are required + // only when the connection group ("creds") is being replaced. + const initialTlsMode: BucketReplicationTlsMode = target?.skipTlsVerify + ? "skip" + : target?.caCertPem + ? "custom-ca" + : "verify" + const connectionChanged = + endpoint !== (target?.endpoint ?? "") || + bucket !== (target?.targetbucket ?? "") || + tls !== Boolean(target?.secure) || + (tls && tlsMode !== initialTlsMode) || + (tls && tlsMode === "custom-ca" && caCertPem !== (target?.caCertPem ?? "")) || + accessKey !== (target?.credentials?.accessKey ?? "") + const credsOp = connectionChanged || secretKey !== "" + const syncOp = (modeType === "sync") !== Boolean(target?.replicationSync) + const bandwidthOp = + modeType === "async" && (Number(getBytes(String(bandwidth), unit, true)) || 0) !== (target?.bandwidth_limit ?? 0) + + const validate = () => { + const errors: typeof fieldErrors = {} + if (!endpoint) errors.endpoint = t("Please enter endpoint") + if (!bucket) errors.bucket = t("Please enter bucket") + if (!accessKey) errors.accessKey = t("Please enter Access Key") + if (connectionChanged && !secretKey) errors.secretKey = t("Please enter Secret Key") + if (modeType === "async" && Number(timecheck) < 1) { + errors.timecheck = t("Please enter valid health check interval") + } + if (tls && tlsMode === "custom-ca" && !caCertPem.trim()) { + errors.caCertPem = t("Custom CA certificate is required") + } + setFieldErrors(errors) + const firstErrorId = errors.endpoint + ? "replication-edit-endpoint" + : errors.bucket + ? "replication-edit-bucket" + : errors.accessKey + ? "replication-edit-access-key" + : errors.secretKey + ? "replication-edit-secret-key" + : errors.timecheck + ? "replication-edit-health-check-interval" + : errors.caCertPem + ? "replication-edit-ca-certificate" + : null + if (firstErrorId) document.getElementById(firstErrorId)?.focus() + return !firstErrorId + } + + const handleSave = async () => { + if (submitting || controlsLocked) return + if (!validate()) return + if (!bucketName || !rule || !target?.arn) { + message.error(t("Remote target not found for this rule. Refresh and try again.")) + return + } + setSubmitting(true) + setSaveError("") + let remoteTargetSaved = false + try { + const tlsConfig = buildBucketReplicationTlsPayload(tls, tlsMode, caCertPem) + const config: Record = { + sourcebucket: bucketName, + endpoint, + credentials: { + accessKey, + secretKey, + }, + targetbucket: bucket, + secure: tls, + skipTlsVerify: tlsConfig.skipTlsVerify, + caCertPem: tlsConfig.caCertPem, + region, + path: "auto", + api: "s3v4", + type: "replication", + replicationSync: modeType === "sync", + arn: target.arn, + ...(canEditTargetField("healthCheckDuration") ? { healthCheckDuration: Number(timecheck) || 60 } : {}), + } + if (modeType === "async") { + config.bandwidth = Number(getBytes(String(bandwidth), unit, true)) || 0 + } + + const targetOps = [ + ...(credsOp ? ["creds"] : []), + ...(syncOp ? ["sync"] : []), + ...(bandwidthOp ? ["bandwidth"] : []), + ] + if (targetOps.length > 0) { + await setRemoteReplicationTarget(bucketName, config, true, targetOps) + remoteTargetSaved = true + } + + const updatedRule: EditableReplicationRule = { + ...(rule.ID && canEditBucketField("Rule.ID") ? { ID: rule.ID } : {}), + ...(canEditBucketField("Rule.Status") ? { Status: rule.Status ?? "Enabled" } : {}), + ...(canEditBucketField("Rule.Priority") ? { Priority: parseInt(level) || 1 } : {}), + ...(canEditBucketField("Rule.ExistingObjectReplication.Status") + ? { ExistingObjectReplication: { Status: existingObject ? "Enabled" : "Disabled" } } + : {}), + ...(canEditBucketField("Rule.DeleteMarkerReplication.Status") + ? { DeleteMarkerReplication: { Status: expiredDeleteMark ? "Enabled" : "Disabled" } } + : {}), + ...(canEditBucketField("Rule.DeleteReplication.Status") + ? { DeleteReplication: { Status: replicateDelete ? "Enabled" : "Disabled" } } + : {}), + ...(canEditBucketField("Rule.Destination.Bucket") + ? { Destination: { Bucket: target.arn, StorageClass: storageType || "STANDARD" } } + : {}), + } + + const validTags = tags.filter((tag) => tag.key && tag.value) + const filter: NonNullable = {} + if (prefix && canEditBucketField("Rule.Filter.Prefix")) { + filter.Prefix = prefix + } + if (validTags.length === 1) { + const [singleTag] = validTags + if (singleTag && canEditBucketField("Rule.Filter.Tag")) { + filter.Tag = { Key: singleTag.key, Value: singleTag.value } + } + } else if (validTags.length > 1 && canEditBucketField("Rule.Filter.And")) { + filter.And = { + ...(prefix && canEditBucketField("Rule.Filter.Prefix") ? { Prefix: prefix } : {}), + Tags: validTags.map((tag) => ({ Key: tag.key, Value: tag.value })), + } + delete filter.Prefix + } + if (Object.keys(filter).length > 0) { + updatedRule.Filter = filter + } + + let latestConfig: { + ReplicationConfiguration?: { Role?: string; Rules?: EditableReplicationRule[] } + } | null = null + try { + latestConfig = (await getBucketReplication(bucketName)) as { + ReplicationConfiguration?: { Role?: string; Rules?: EditableReplicationRule[] } + } + } catch (error) { + if (!isMissingBucketConfiguration(error, "replication")) { + throw error + } + } + + const existingRules = normalizeReplicationRulesForRolelessConfig( + latestConfig?.ReplicationConfiguration?.Rules ?? [], + latestConfig?.ReplicationConfiguration?.Role, + ) as EditableReplicationRule[] + const matchIndex = rule.ID + ? existingRules.findIndex((item) => item.ID === rule.ID) + : existingRules.findIndex((item) => JSON.stringify(item) === JSON.stringify(rule)) + if (matchIndex === -1) { + throw new Error(t("Configuration changed. Refresh and try again.")) + } + const nextRules = [...existingRules] + nextRules[matchIndex] = updatedRule + + await putBucketReplication(bucketName, { + Role: "", + Rules: nextRules, + }) + remoteTargetSaved = false + message.success(t("Update Success")) + onSuccess?.() + onOpenChange(false) + } catch (error) { + console.error(error) + let errorMessage = (error as Error).message || t("Save failed") + if (remoteTargetSaved) { + errorMessage = `${errorMessage}. ${t("The remote target may have been saved. Review the replication configuration before retrying.")}` + } + setSaveError(errorMessage) + message.error(errorMessage) + } finally { + setSubmitting(false) + } + } + + const handleCancel = () => { + if (submitting) return + onOpenChange(false) + } + + return ( + { + if (!nextOpen) { + handleCancel() + return + } + onOpenChange(true) + }} + disablePointerDismissal + > + + + + {t("Edit Replication Rule")} ({t("Bucket")}: {bucketName || ""}) + + + +
{ + event.preventDefault() + void handleSave() + }} + > +
+ {saveError ? ( +
+ {saveError} +
+ ) : null} + {capabilitiesError ? ( +

+ {capabilitiesError} +

+ ) : null} +
+
+ + {t("Priority")} + + setLevel(e.target.value)} + disabled={controlsLocked || !canEditBucketField("Rule.Priority")} + /> + + + + {t("Mode")} + + + + + + {t("Endpoint")} + +
+
+ {tls ? "https://" : "http://"} +
+ { + setEndpoint(e.target.value) + setFieldErrors((current) => ({ ...current, endpoint: undefined })) + }} + aria-invalid={Boolean(fieldErrors.endpoint)} + aria-describedby={fieldErrors.endpoint ? "replication-edit-endpoint-error" : undefined} + autoComplete="off" + placeholder={t("Please enter endpoint")} + spellCheck={false} + disabled={controlsLocked || !canEditTargetField("endpoint")} + /> +
+
+ {fieldErrors.endpoint} +
+ + {t("Bucket")} + + { + setBucket(e.target.value) + setFieldErrors((current) => ({ ...current, bucket: undefined })) + }} + aria-invalid={Boolean(fieldErrors.bucket)} + aria-describedby={fieldErrors.bucket ? "replication-edit-bucket-error" : undefined} + autoComplete="off" + placeholder={t("Please enter bucket")} + spellCheck={false} + disabled={controlsLocked || !canEditTargetField("targetbucket")} + /> + + {fieldErrors.bucket} + + + {t("Access Key")} + + { + setAccessKey(e.target.value) + setFieldErrors((current) => ({ ...current, accessKey: undefined })) + }} + aria-invalid={Boolean(fieldErrors.accessKey)} + aria-describedby={fieldErrors.accessKey ? "replication-edit-access-key-error" : undefined} + placeholder={t("Please enter Access Key")} + autoComplete="off" + spellCheck={false} + disabled={controlsLocked || !canEditTargetField("credentials.accessKey")} + /> + + {fieldErrors.accessKey} + + + {t("Secret Key")} + + { + setSecretKey(e.target.value) + setFieldErrors((current) => ({ ...current, secretKey: undefined })) + }} + aria-invalid={Boolean(fieldErrors.secretKey)} + aria-describedby={ + fieldErrors.secretKey ? "replication-edit-secret-key-error" : "replication-edit-secret-key-hint" + } + placeholder={t("Please enter Secret Key")} + autoComplete="off" + spellCheck={false} + disabled={controlsLocked || !canEditTargetField("credentials.secretKey")} + /> + +

+ {t("Leave blank to keep the current credentials.")} +

+ {fieldErrors.secretKey} +
+ + {t("Region")} + + {/* No region update group exists in the MinIO update contract, + so the stored region is shown read-only. */} + setRegion(e.target.value)} + autoComplete="off" + placeholder={t("Please enter region")} + spellCheck={false} + disabled + /> + + + + {t("Storage Class")} + + + + +
+ + + {t("Prefix")} + + setPrefix(e.target.value)} + autoComplete="off" + placeholder={t("Please enter prefix")} + spellCheck={false} + disabled={controlsLocked || !canEditBucketField("Rule.Filter.Prefix")} + /> + + + +
+
+ {t("Tags")} + +
+ {tags.length > 0 && ( +
+ {tags.map((tag, index) => ( +
+ updateTag(index, "key", e.target.value)} + autoComplete="off" + placeholder={t("Tag Name")} + spellCheck={false} + disabled={controlsLocked || !canEditCurrentTagFilter} + /> +
+ updateTag(index, "value", e.target.value)} + autoComplete="off" + placeholder={t("Tag Value")} + className="flex-1" + spellCheck={false} + disabled={controlsLocked || !canEditCurrentTagFilter} + /> + +
+
+ ))} +
+ )} +
+
+ +
+
+
+ +

+ {t("Enable secure transport when connecting to endpoint.")} +

+
+ { + setTls(checked) + if (!checked) { + setTlsMode("verify") + setCaCertPem("") + setFieldErrors((current) => ({ ...current, caCertPem: undefined })) + } + }} + /> +
+ + {tls ? ( +
+ + {t("TLS Verification")} + + + + + + {tlsMode === "custom-ca" ? ( + + {t("Custom CA certificate")} + +