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")} + +