feat(replication): add bucket replication rule editing (#195)

This commit is contained in:
唐小鸭
2026-08-05 07:09:57 +08:00
committed by GitHub
parent 28b2cd9794
commit fee37c661e
17 changed files with 1132 additions and 30 deletions
+113 -28
View File
@@ -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<ReplicationRule[]>([])
const [targets, setTargets] = React.useState<RemoteReplicationTarget[]>([])
const [loading, setLoading] = React.useState(false)
const [loadError, setLoadError] = React.useState("")
const [mutatingRuleId, setMutatingRuleId] = React.useState<string | null>(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 }) => <span>{row.original.Destination?.StorageClass || "-"}</span>,
},
{
id: "replication-mode",
header: () => t("Mode"),
cell: ({ row }) => {
const target = targetForRule(row.original)
if (!target) return <span>-</span>
return <Badge variant="outline">{target.replicationSync ? t("Synchronous") : t("Asynchronous")}</Badge>
},
},
{
id: "actions",
header: () => t("Actions"),
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => confirmDelete(row.original)}
disabled={Boolean(loadError) || loading || mutatingRuleId !== null || !canEditReplication}
aria-label={`${t("Delete Rule")} ${row.original.ID ?? t("Unnamed rule")}`}
>
<RiDeleteBin7Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
</div>
),
cell: ({ row }) => {
const target = targetForRule(row.original)
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => target && setEditing({ rule: row.original, target })}
disabled={
Boolean(loadError) ||
loading ||
mutatingRuleId !== null ||
!canEditReplication ||
!remoteTargetsSupported ||
!target
}
aria-label={`${t("Edit")} ${row.original.ID ?? t("Unnamed rule")}`}
>
<RiEditLine className="size-4" aria-hidden />
<span>{t("Edit")}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => confirmDelete(row.original)}
disabled={Boolean(loadError) || loading || mutatingRuleId !== null || !canEditReplication}
aria-label={`${t("Delete Rule")} ${row.original.ID ?? t("Unnamed rule")}`}
>
<RiDeleteBin7Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
</div>
)
},
},
],
[canEditReplication, confirmDelete, loadError, loading, mutatingRuleId, t],
[canEditReplication, confirmDelete, loadError, loading, mutatingRuleId, remoteTargetsSupported, t, targetForRule],
)
const { table } = useDataTable<ReplicationRule>({
@@ -314,15 +368,35 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead
</div>
</dl>
{canEditReplication ? (
<Button
variant="outline"
className="min-h-11 w-full whitespace-normal break-all"
onClick={() => confirmDelete(rule)}
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
>
<RiDeleteBin7Line className="size-4" aria-hidden />
{`${t("Delete Rule")}: ${rule.ID ?? t("Unnamed rule")}`}
</Button>
<div className="space-y-2">
<Button
variant="outline"
className="min-h-11 w-full whitespace-normal break-all"
onClick={() => {
const target = targetForRule(rule)
if (target) setEditing({ rule, target })
}}
disabled={
Boolean(loadError) ||
loading ||
mutatingRuleId !== null ||
!remoteTargetsSupported ||
!targetForRule(rule)
}
>
<RiEditLine className="size-4" aria-hidden />
{`${t("Edit")}: ${rule.ID ?? t("Unnamed rule")}`}
</Button>
<Button
variant="outline"
className="min-h-11 w-full whitespace-normal break-all"
onClick={() => confirmDelete(rule)}
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
>
<RiDeleteBin7Line className="size-4" aria-hidden />
{`${t("Delete Rule")}: ${rule.ID ?? t("Unnamed rule")}`}
</Button>
</div>
) : null}
</article>
)
@@ -336,6 +410,17 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead
bucketName={bucketName}
onSuccess={loadData}
/>
<ReplicationEditForm
open={editing !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setEditing(null)
}}
bucketName={bucketName}
rule={editing?.rule ?? null}
target={editing?.target ?? null}
onSuccess={loadData}
/>
</div>
)
}
+968
View File
@@ -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<BucketReplicationTlsMode>("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<Tag[]>([{ 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<string, unknown> = {
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<EditableReplicationRule["Filter"]> = {}
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleCancel()
return
}
onOpenChange(true)
}}
disablePointerDismissal
>
<DialogContent className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="border-b px-4 py-3 pe-12 sm:px-6">
<DialogTitle>
{t("Edit Replication Rule")} ({t("Bucket")}: {bucketName || ""})
</DialogTitle>
</DialogHeader>
<form
className="contents"
aria-busy={submitting}
onSubmit={(event) => {
event.preventDefault()
void handleSave()
}}
>
<div className="min-h-0 space-y-6 overflow-y-auto overscroll-contain p-4 sm:p-6">
{saveError ? (
<div role="alert" className="border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{saveError}
</div>
) : null}
{capabilitiesError ? (
<p role="alert" className="border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{capabilitiesError}
</p>
) : null}
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="replication-edit-priority">{t("Priority")}</FieldLabel>
<FieldContent>
<Input
id="replication-edit-priority"
name="replication-edit-priority"
type="number"
inputMode="numeric"
min={1}
autoComplete="off"
value={level}
onChange={(e) => setLevel(e.target.value)}
disabled={controlsLocked || !canEditBucketField("Rule.Priority")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel>{t("Mode")}</FieldLabel>
<FieldContent>
<Select
value={modeType}
onValueChange={(value) => setModeType(value ?? "")}
disabled={controlsLocked || !canEditTargetField("replicationSync")}
>
<SelectTrigger className="w-full" aria-label={t("Mode")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{modeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-endpoint">{t("Endpoint")}</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<div className="flex h-10 items-center whitespace-nowrap border border-input bg-muted px-3 text-sm text-muted-foreground">
{tls ? "https://" : "http://"}
</div>
<Input
id="replication-edit-endpoint"
name="replication-edit-endpoint"
className="flex-1"
value={endpoint}
onChange={(e) => {
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")}
/>
</div>
</FieldContent>
<FieldError id="replication-edit-endpoint-error">{fieldErrors.endpoint}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-bucket">{t("Bucket")}</FieldLabel>
<FieldContent>
<Input
id="replication-edit-bucket"
name="replication-edit-bucket"
value={bucket}
onChange={(e) => {
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")}
/>
</FieldContent>
<FieldError id="replication-edit-bucket-error">{fieldErrors.bucket}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-access-key">{t("Access Key")}</FieldLabel>
<FieldContent>
<Input
id="replication-edit-access-key"
name="replication-edit-access-key"
value={accessKey}
onChange={(e) => {
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")}
/>
</FieldContent>
<FieldError id="replication-edit-access-key-error">{fieldErrors.accessKey}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-secret-key">{t("Secret Key")}</FieldLabel>
<FieldContent>
<Input
id="replication-edit-secret-key"
name="replication-edit-secret-key"
type="password"
value={secretKey}
onChange={(e) => {
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")}
/>
</FieldContent>
<p id="replication-edit-secret-key-hint" className="text-xs text-muted-foreground">
{t("Leave blank to keep the current credentials.")}
</p>
<FieldError id="replication-edit-secret-key-error">{fieldErrors.secretKey}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-region">{t("Region")}</FieldLabel>
<FieldContent>
{/* No region update group exists in the MinIO update contract,
so the stored region is shown read-only. */}
<Input
id="replication-edit-region"
name="replication-edit-region"
value={region}
onChange={(e) => setRegion(e.target.value)}
autoComplete="off"
placeholder={t("Please enter region")}
spellCheck={false}
disabled
/>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-storage-class">{t("Storage Class")}</FieldLabel>
<FieldContent>
<Select
value={storageType}
onValueChange={(value) => setStorageType(value ?? "")}
disabled={controlsLocked}
>
<SelectTrigger
id="replication-edit-storage-class"
className="w-full"
aria-label={t("Storage Class")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{storageClassOptions.map((option) => (
<SelectItem
key={option}
value={option}
disabled={!(capabilities?.storageClasses.supportedWriteClasses ?? []).includes(option)}
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
</div>
<Field>
<FieldLabel htmlFor="replication-edit-prefix">{t("Prefix")}</FieldLabel>
<FieldContent>
<Input
id="replication-edit-prefix"
name="replication-edit-prefix"
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
autoComplete="off"
placeholder={t("Please enter prefix")}
spellCheck={false}
disabled={controlsLocked || !canEditBucketField("Rule.Filter.Prefix")}
/>
</FieldContent>
</Field>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={addTag}
disabled={controlsLocked || !canAddTag}
>
<RiAddLine className="size-4" aria-hidden />
{t("Add Tag")}
</Button>
</div>
{tags.length > 0 && (
<div className="space-y-3">
{tags.map((tag, index) => (
<div key={index} className="grid gap-2 border p-3 md:grid-cols-2 md:items-center md:gap-4">
<Input
id={`replication-edit-tag-key-${index}`}
name={`replication-edit-tag-key-${index}`}
aria-label={t("Tag Name")}
value={tag.key}
onChange={(e) => updateTag(index, "key", e.target.value)}
autoComplete="off"
placeholder={t("Tag Name")}
spellCheck={false}
disabled={controlsLocked || !canEditCurrentTagFilter}
/>
<div className="flex items-center gap-2">
<Input
id={`replication-edit-tag-value-${index}`}
name={`replication-edit-tag-value-${index}`}
aria-label={t("Tag Value")}
value={tag.value}
onChange={(e) => updateTag(index, "value", e.target.value)}
autoComplete="off"
placeholder={t("Tag Value")}
className="flex-1"
spellCheck={false}
disabled={controlsLocked || !canEditCurrentTagFilter}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive"
aria-label={`${t("Delete")} ${t("Tag Name")} ${index + 1}`}
disabled={tags.length === 1 || controlsLocked || !canEditCurrentTagFilter}
onClick={() => removeTag(index)}
>
<RiDeleteBinLine className="size-4" aria-hidden />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label htmlFor="replication-edit-use-tls" className="text-sm font-medium">
{t("Use TLS")}
</label>
<p className="text-xs text-muted-foreground">
{t("Enable secure transport when connecting to endpoint.")}
</p>
</div>
<Switch
id="replication-edit-use-tls"
name="replication-edit-use-tls"
checked={tls}
disabled={controlsLocked || !canEditTargetField("secure")}
onCheckedChange={(checked) => {
setTls(checked)
if (!checked) {
setTlsMode("verify")
setCaCertPem("")
setFieldErrors((current) => ({ ...current, caCertPem: undefined }))
}
}}
/>
</div>
{tls ? (
<div className="space-y-3 border-s-2 border-border ps-4">
<Field>
<FieldLabel htmlFor="replication-edit-tls-verification">{t("TLS Verification")}</FieldLabel>
<FieldContent>
<Select
value={tlsMode}
onValueChange={(value) => {
if (value) setTlsMode(value as BucketReplicationTlsMode)
setFieldErrors((current) => ({ ...current, caCertPem: undefined }))
}}
disabled={controlsLocked || !canEditTargetField("skipTlsVerify")}
>
<SelectTrigger id="replication-edit-tls-verification" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="verify">{t("Default certificate verification")}</SelectItem>
<SelectItem value="custom-ca">{t("Custom CA certificate")}</SelectItem>
<SelectItem value="skip">{t("Skip TLS verification")}</SelectItem>
</SelectContent>
</Select>
</FieldContent>
</Field>
{tlsMode === "custom-ca" ? (
<Field>
<FieldLabel htmlFor="replication-edit-ca-certificate">{t("Custom CA certificate")}</FieldLabel>
<FieldContent>
<Textarea
id="replication-edit-ca-certificate"
name="replication-edit-ca-certificate"
value={caCertPem}
onChange={(event) => {
setCaCertPem(event.target.value)
if (event.target.value.trim()) {
setFieldErrors((current) => ({ ...current, caCertPem: undefined }))
}
}}
aria-invalid={Boolean(fieldErrors.caCertPem)}
aria-describedby={
fieldErrors.caCertPem
? "replication-edit-ca-certificate-error"
: "replication-edit-ca-certificate-description"
}
className="min-h-32 font-mono"
placeholder="-----BEGIN CERTIFICATE-----"
disabled={controlsLocked || !canEditTargetField("caCertPem")}
spellCheck={false}
/>
</FieldContent>
<p id="replication-edit-ca-certificate-description" className="text-xs text-muted-foreground">
{t("Paste the CA certificate in PEM format.")}
</p>
<FieldError id="replication-edit-ca-certificate-error">{fieldErrors.caCertPem}</FieldError>
</Field>
) : null}
{tlsMode === "skip" ? (
<p
role="alert"
className="border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive"
>
{t("Certificate verification is disabled. Only use this for trusted networks.")}
</p>
) : null}
</div>
) : null}
<div className="flex items-center justify-between">
<div>
<label htmlFor="replication-edit-existing-object" className="text-sm font-medium">
{t("Replicate Existing Objects")}
</label>
<p className="text-xs text-muted-foreground">
{t("Include objects that already exist in the source bucket.")}
</p>
</div>
<Switch
id="replication-edit-existing-object"
name="replication-edit-existing-object"
checked={existingObject}
onCheckedChange={setExistingObject}
disabled={controlsLocked || !canEditBucketField("Rule.ExistingObjectReplication.Status")}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label htmlFor="replication-edit-expired-delete-marker" className="text-sm font-medium">
{t("Replicate Delete Markers")}
</label>
<p className="text-xs text-muted-foreground">{t("Sync delete markers to destination bucket.")}</p>
</div>
<Switch
id="replication-edit-expired-delete-marker"
name="replication-edit-expired-delete-marker"
checked={expiredDeleteMark}
onCheckedChange={setExpiredDeleteMark}
disabled={controlsLocked || !canEditBucketField("Rule.DeleteMarkerReplication.Status")}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label htmlFor="replication-edit-delete" className="text-sm font-medium">
{t("Replicate Delete")}
</label>
<p className="text-xs text-muted-foreground">{t("Sync delete to destination bucket.")}</p>
</div>
<Switch
id="replication-edit-delete"
name="replication-edit-delete"
checked={replicateDelete}
onCheckedChange={setReplicateDelete}
disabled={controlsLocked || !canEditBucketField("Rule.DeleteReplication.Status")}
/>
</div>
{modeType === "async" && (
<div className="space-y-3">
<Field>
<FieldLabel htmlFor="replication-edit-health-check-interval">
{t("Health Check Interval (seconds)")}
</FieldLabel>
<FieldContent>
<Input
id="replication-edit-health-check-interval"
name="replication-edit-health-check-interval"
type="number"
inputMode="numeric"
min={1}
autoComplete="off"
value={timecheck}
onChange={(e) => {
setTimecheck(e.target.value)
setFieldErrors((current) => ({ ...current, timecheck: undefined }))
}}
aria-invalid={Boolean(fieldErrors.timecheck)}
aria-describedby={
fieldErrors.timecheck ? "replication-edit-health-check-interval-error" : undefined
}
className="w-32"
disabled={controlsLocked || !canEditTargetField("healthCheckDuration")}
/>
</FieldContent>
<FieldError id="replication-edit-health-check-interval-error">{fieldErrors.timecheck}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="replication-edit-bandwidth-limit">{t("Bandwidth Limit")}</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input
id="replication-edit-bandwidth-limit"
name="replication-edit-bandwidth-limit"
type="number"
inputMode="numeric"
min={0}
autoComplete="off"
value={bandwidth}
onChange={(e) => setBandwidth(Number(e.target.value))}
className="w-32"
disabled={controlsLocked || !canEditTargetField("bandwidth")}
/>
<Select
value={unit}
onValueChange={(value) => setUnit(value ?? "")}
disabled={controlsLocked || !canEditTargetField("bandwidth")}
>
<SelectTrigger className="w-28" aria-label={t("Bandwidth Unit")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{unitOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FieldContent>
</Field>
</div>
)}
</div>
</div>
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:px-6">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={handleCancel}
disabled={submitting}
>
{t("Cancel")}
</Button>
<Button type="submit" className="w-full sm:w-auto" disabled={controlsLocked}>
{submitting ? t("Saving…") : capabilitiesLoading ? t("Loading") : t("Save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+9 -2
View File
@@ -363,8 +363,15 @@ export function useBucket() {
)
const setRemoteReplicationTarget = useCallback(
async (bucket: string, data: unknown) => {
return api.put(`/set-remote-target?bucket=${encodeURIComponent(bucket)}`, data)
// `ops` names the field groups an update should touch (MinIO TargetUpdateType
// contract: "creds" | "sync" | "bandwidth" | "path"); groups not listed keep
// their stored values, so e.g. a sync-mode flip needs no credentials.
async (bucket: string, data: unknown, update = false, ops: string[] = []) => {
let query = update ? "&update=true" : ""
for (const op of ops) {
query += `&${encodeURIComponent(op)}=true`
}
return api.put(`/set-remote-target?bucket=${encodeURIComponent(bucket)}${query}`, data)
},
[api],
)
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "اختر منطقة مقترحة أو أدخل منطقة جديدة.",
"Compliance or Object Lock retention may prevent deletion.": "قد تمنع سياسة الاحتفاظ Compliance أو Object Lock الحذف.",
"Early expiry may still incur minimum storage duration charges.": "قد يترتب على انتهاء الصلاحية المبكر رسوم الحد الأدنى لمدة التخزين.",
"Edit Replication Rule": "تعديل قاعدة النسخ",
"Endpoint is derived from the region": "يتم اشتقاق نقطة النهاية من المنطقة",
"Endpoint is derived from the region and cannot be edited.": "يتم اشتقاق نقطة النهاية من المنطقة ولا يمكن تعديلها.",
"Endpoint preview": "معاينة نقطة النهاية",
"Enter or select a region": "أدخل منطقة أو اخترها",
"Leave blank to keep the current credentials.": "اتركه فارغًا للاحتفاظ ببيانات الاعتماد الحالية.",
"Region is required": "المنطقة مطلوبة",
"Remote target not found for this rule. Refresh and try again.": "لم يتم العثور على الهدف البعيد لهذه القاعدة. قم بالتحديث وحاول مرة أخرى.",
"The remote Wasabi bucket must never have had Versioning enabled.": "يجب ألا يكون Versioning قد فُعّل مطلقًا على حاوية Wasabi البعيدة.",
"Use the entered region": "استخدام المنطقة المُدخلة",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Wählen Sie eine vorgeschlagene Region oder geben Sie eine neue ein.",
"Compliance or Object Lock retention may prevent deletion.": "Compliance- oder Object-Lock-Aufbewahrung kann das Löschen verhindern.",
"Early expiry may still incur minimum storage duration charges.": "Bei vorzeitigem Ablauf können weiterhin Gebühren für die Mindestspicherdauer anfallen.",
"Edit Replication Rule": "Replikationsregel bearbeiten",
"Endpoint is derived from the region": "Endpunkt wird aus der Region abgeleitet",
"Endpoint is derived from the region and cannot be edited.": "Der Endpunkt wird aus der Region abgeleitet und kann nicht bearbeitet werden.",
"Endpoint preview": "Endpunktvorschau",
"Enter or select a region": "Region eingeben oder auswählen",
"Leave blank to keep the current credentials.": "Leer lassen, um die aktuellen Zugangsdaten beizubehalten.",
"Region is required": "Region ist erforderlich",
"Remote target not found for this rule. Refresh and try again.": "Kein Remote-Ziel für diese Regel gefunden. Aktualisieren und erneut versuchen.",
"The remote Wasabi bucket must never have had Versioning enabled.": "Für den entfernten Wasabi-Bucket darf Versionierung nie aktiviert gewesen sein.",
"Use the entered region": "Eingegebene Region verwenden",
"Wasabi": "Wasabi",
+3
View File
@@ -336,6 +336,7 @@
"Edit Group": "Edit Group",
"Edit Key": "Edit Key",
"Edit Policy": "Edit Policy",
"Edit Replication Rule": "Edit Replication Rule",
"Edit Site": "Edit Site",
"Edit Success": "Edit Success",
"Edit User": "Edit User",
@@ -581,6 +582,7 @@
"Leave empty to keep current secret": "Leave empty to keep current secret",
"Leave empty to use current host as default": "Leave empty to use current host as default",
"Legal Hold": "Legal Hold",
"Leave blank to keep the current credentials.": "Leave blank to keep the current credentials.",
"License": "License",
"License Details": "License Details",
"License Key": "License Key",
@@ -925,6 +927,7 @@
"Remote Sites": "Remote Sites",
"Remote Technical Support": "Remote Technical Support",
"Remote Tiering": "Remote Tiering",
"Remote target not found for this rule. Refresh and try again.": "Remote target not found for this rule. Refresh and try again.",
"Remove": "Remove",
"Remove All Sites": "Remove All Sites",
"Remove Encryption": "Remove Encryption",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Elija una región sugerida o introduzca una nueva.",
"Compliance or Object Lock retention may prevent deletion.": "La retención de Compliance u Object Lock puede impedir la eliminación.",
"Early expiry may still incur minimum storage duration charges.": "La expiración anticipada aún puede generar cargos por duración mínima de almacenamiento.",
"Edit Replication Rule": "Editar Regla de Replicación",
"Endpoint is derived from the region": "El endpoint se deriva de la región",
"Endpoint is derived from the region and cannot be edited.": "El endpoint se deriva de la región y no se puede editar.",
"Endpoint preview": "Vista previa del endpoint",
"Enter or select a region": "Introduzca o seleccione una región",
"Leave blank to keep the current credentials.": "Déjelo en blanco para conservar las credenciales actuales.",
"Region is required": "La región es obligatoria",
"Remote target not found for this rule. Refresh and try again.": "No se encontró el destino remoto de esta regla. Actualice e inténtelo de nuevo.",
"The remote Wasabi bucket must never have had Versioning enabled.": "El bucket remoto de Wasabi nunca debe haber tenido Versioning activado.",
"Use the entered region": "Usar la región introducida",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Choisissez une région suggérée ou saisissez-en une nouvelle.",
"Compliance or Object Lock retention may prevent deletion.": "La rétention Compliance ou Object Lock peut empêcher la suppression.",
"Early expiry may still incur minimum storage duration charges.": "Une expiration anticipée peut encore entraîner des frais de durée minimale de stockage.",
"Edit Replication Rule": "Modifier la règle de réplication",
"Endpoint is derived from the region": "Lendpoint est dérivé de la région",
"Endpoint is derived from the region and cannot be edited.": "Lendpoint est dérivé de la région et ne peut pas être modifié.",
"Endpoint preview": "Aperçu de lendpoint",
"Enter or select a region": "Saisissez ou sélectionnez une région",
"Leave blank to keep the current credentials.": "Laissez vide pour conserver les identifiants actuels.",
"Region is required": "La région est requise",
"Remote target not found for this rule. Refresh and try again.": "Cible distante introuvable pour cette règle. Actualisez et réessayez.",
"The remote Wasabi bucket must never have had Versioning enabled.": "Le bucket Wasabi distant ne doit jamais avoir eu le Versioning activé.",
"Use the entered region": "Utiliser la région saisie",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Pilih region yang disarankan atau masukkan yang baru.",
"Compliance or Object Lock retention may prevent deletion.": "Retensi Compliance atau Object Lock dapat mencegah penghapusan.",
"Early expiry may still incur minimum storage duration charges.": "Kedaluwarsa lebih awal masih dapat dikenai biaya durasi penyimpanan minimum.",
"Edit Replication Rule": "Edit Aturan Replikasi",
"Endpoint is derived from the region": "Endpoint diturunkan dari region",
"Endpoint is derived from the region and cannot be edited.": "Endpoint diturunkan dari region dan tidak dapat diedit.",
"Endpoint preview": "Pratinjau endpoint",
"Enter or select a region": "Masukkan atau pilih region",
"Leave blank to keep the current credentials.": "Biarkan kosong untuk mempertahankan kredensial saat ini.",
"Region is required": "Region wajib diisi",
"Remote target not found for this rule. Refresh and try again.": "Target jarak jauh untuk aturan ini tidak ditemukan. Segarkan dan coba lagi.",
"The remote Wasabi bucket must never have had Versioning enabled.": "Bucket Wasabi jarak jauh tidak boleh pernah mengaktifkan Versioning.",
"Use the entered region": "Gunakan region yang dimasukkan",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Scegli una regione suggerita o inseriscine una nuova.",
"Compliance or Object Lock retention may prevent deletion.": "La conservazione Compliance o Object Lock può impedire l'eliminazione.",
"Early expiry may still incur minimum storage duration charges.": "La scadenza anticipata può comunque comportare addebiti per la durata minima di archiviazione.",
"Edit Replication Rule": "Modifica regola di replica",
"Endpoint is derived from the region": "L'endpoint deriva dalla regione",
"Endpoint is derived from the region and cannot be edited.": "L'endpoint deriva dalla regione e non può essere modificato.",
"Endpoint preview": "Anteprima endpoint",
"Enter or select a region": "Inserisci o seleziona una regione",
"Leave blank to keep the current credentials.": "Lasciare vuoto per mantenere le credenziali attuali.",
"Region is required": "La regione è obbligatoria",
"Remote target not found for this rule. Refresh and try again.": "Destinazione remota non trovata per questa regola. Aggiorna e riprova.",
"The remote Wasabi bucket must never have had Versioning enabled.": "Il bucket Wasabi remoto non deve mai aver avuto il Versioning abilitato.",
"Use the entered region": "Usa la regione inserita",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "候補のリージョンを選択するか、新しいリージョンを入力してください。",
"Compliance or Object Lock retention may prevent deletion.": "Compliance または Object Lock の保持設定により削除できない場合があります。",
"Early expiry may still incur minimum storage duration charges.": "早期に期限切れにしても、最低保存期間の料金が発生する場合があります。",
"Edit Replication Rule": "レプリケーションルールを編集",
"Endpoint is derived from the region": "エンドポイントはリージョンから生成されます",
"Endpoint is derived from the region and cannot be edited.": "エンドポイントはリージョンから生成されるため編集できません。",
"Endpoint preview": "エンドポイントのプレビュー",
"Enter or select a region": "リージョンを入力または選択",
"Leave blank to keep the current credentials.": "空欄のままにすると現在の認証情報が保持されます。",
"Region is required": "リージョンは必須です",
"Remote target not found for this rule. Refresh and try again.": "このルールのリモートターゲットが見つかりません。更新してから再試行してください。",
"The remote Wasabi bucket must never have had Versioning enabled.": "リモート Wasabi バケットで Versioning が一度も有効化されていない必要があります。",
"Use the entered region": "入力したリージョンを使用",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "추천 리전을 선택하거나 새 리전을 입력하세요.",
"Compliance or Object Lock retention may prevent deletion.": "Compliance 또는 Object Lock 보존 정책으로 인해 삭제되지 않을 수 있습니다.",
"Early expiry may still incur minimum storage duration charges.": "조기 만료에도 최소 저장 기간 요금이 부과될 수 있습니다.",
"Edit Replication Rule": "복제 규칙 편집",
"Endpoint is derived from the region": "엔드포인트는 리전에서 파생됩니다",
"Endpoint is derived from the region and cannot be edited.": "엔드포인트는 리전에서 파생되며 편집할 수 없습니다.",
"Endpoint preview": "엔드포인트 미리보기",
"Enter or select a region": "리전 입력 또는 선택",
"Leave blank to keep the current credentials.": "비워 두면 현재 자격 증명이 유지됩니다.",
"Region is required": "리전은 필수입니다",
"Remote target not found for this rule. Refresh and try again.": "이 규칙의 원격 대상을 찾을 수 없습니다. 새로 고침 후 다시 시도하세요.",
"The remote Wasabi bucket must never have had Versioning enabled.": "원격 Wasabi 버킷은 Versioning이 활성화된 적이 없어야 합니다.",
"Use the entered region": "입력한 리전 사용",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Escolha uma região sugerida ou insira uma nova.",
"Compliance or Object Lock retention may prevent deletion.": "A retenção de Compliance ou Object Lock pode impedir a exclusão.",
"Early expiry may still incur minimum storage duration charges.": "A expiração antecipada ainda pode gerar cobranças de duração mínima de armazenamento.",
"Edit Replication Rule": "Editar Regra de Replicação",
"Endpoint is derived from the region": "O endpoint é derivado da região",
"Endpoint is derived from the region and cannot be edited.": "O endpoint é derivado da região e não pode ser editado.",
"Endpoint preview": "Prévia do endpoint",
"Enter or select a region": "Insira ou selecione uma região",
"Leave blank to keep the current credentials.": "Deixe em branco para manter as credenciais atuais.",
"Region is required": "A região é obrigatória",
"Remote target not found for this rule. Refresh and try again.": "Destino remoto não encontrado para esta regra. Atualize e tente novamente.",
"The remote Wasabi bucket must never have had Versioning enabled.": "O bucket remoto da Wasabi nunca pode ter tido o Versioning habilitado.",
"Use the entered region": "Usar a região inserida",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Выберите предложенный регион или введите новый.",
"Compliance or Object Lock retention may prevent deletion.": "Политика хранения Compliance или Object Lock может препятствовать удалению.",
"Early expiry may still incur minimum storage duration charges.": "При досрочном удалении может взиматься плата за минимальный срок хранения.",
"Edit Replication Rule": "Изменить правило репликации",
"Endpoint is derived from the region": "Endpoint определяется по региону",
"Endpoint is derived from the region and cannot be edited.": "Endpoint определяется по региону и не может быть изменён.",
"Endpoint preview": "Предпросмотр endpoint",
"Enter or select a region": "Введите или выберите регион",
"Leave blank to keep the current credentials.": "Оставьте пустым, чтобы сохранить текущие учетные данные.",
"Region is required": "Требуется регион",
"Remote target not found for this rule. Refresh and try again.": "Удаленная цель для этого правила не найдена. Обновите страницу и повторите попытку.",
"The remote Wasabi bucket must never have had Versioning enabled.": "В удалённом бакете Wasabi функция Versioning никогда не должна была включаться.",
"Use the entered region": "Использовать введённый регион",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Önerilen bir region seçin veya yeni bir tane girin.",
"Compliance or Object Lock retention may prevent deletion.": "Compliance veya Object Lock saklama politikası silmeyi engelleyebilir.",
"Early expiry may still incur minimum storage duration charges.": "Erken sona erme durumunda minimum depolama süresi ücretleri uygulanabilir.",
"Edit Replication Rule": "Çoğaltma Kuralını Düzenle",
"Endpoint is derived from the region": "Endpoint region bilgisinden türetilir",
"Endpoint is derived from the region and cannot be edited.": "Endpoint region bilgisinden türetilir ve düzenlenemez.",
"Endpoint preview": "Endpoint önizlemesi",
"Enter or select a region": "Region girin veya seçin",
"Leave blank to keep the current credentials.": "Mevcut kimlik bilgilerini korumak için boş bırakın.",
"Region is required": "Region gereklidir",
"Remote target not found for this rule. Refresh and try again.": "Bu kural için uzak hedef bulunamadı. Yenileyip tekrar deneyin.",
"The remote Wasabi bucket must never have had Versioning enabled.": "Uzak Wasabi bucket üzerinde Versioning daha önce hiç etkinleştirilmemiş olmalıdır.",
"Use the entered region": "Girilen region değerini kullan",
"Wasabi": "Wasabi",
+3
View File
@@ -3,11 +3,14 @@
"Choose a suggested region or enter a new one.": "Chọn region được đề xuất hoặc nhập region mới.",
"Compliance or Object Lock retention may prevent deletion.": "Chính sách lưu giữ Compliance hoặc Object Lock có thể ngăn việc xóa.",
"Early expiry may still incur minimum storage duration charges.": "Hết hạn sớm vẫn có thể phát sinh phí thời gian lưu trữ tối thiểu.",
"Edit Replication Rule": "Chỉnh sửa quy tắc sao chép",
"Endpoint is derived from the region": "Endpoint được suy ra từ region",
"Endpoint is derived from the region and cannot be edited.": "Endpoint được suy ra từ region và không thể chỉnh sửa.",
"Endpoint preview": "Xem trước endpoint",
"Enter or select a region": "Nhập hoặc chọn region",
"Leave blank to keep the current credentials.": "Để trống để giữ nguyên thông tin xác thực hiện tại.",
"Region is required": "Bắt buộc nhập region",
"Remote target not found for this rule. Refresh and try again.": "Không tìm thấy đích từ xa cho quy tắc này. Hãy làm mới và thử lại.",
"The remote Wasabi bucket must never have had Versioning enabled.": "Bucket Wasabi từ xa phải chưa từng bật Versioning.",
"Use the entered region": "Sử dụng region đã nhập",
"Wasabi": "Wasabi",
+3
View File
@@ -336,6 +336,7 @@
"Edit Group": "编辑分组",
"Edit Key": "编辑密钥",
"Edit Policy": "编辑策略",
"Edit Replication Rule": "编辑复制规则",
"Edit Site": "编辑站点",
"Edit Success": "修改成功",
"Edit User": "编辑用户",
@@ -581,6 +582,7 @@
"Leave empty to keep current secret": "留空表示保留当前密钥",
"Leave empty to use current host as default": "留空表示使用当前主机作为默认值",
"Legal Hold": "合法保留",
"Leave blank to keep the current credentials.": "留空则保持现有凭证不变。",
"License": "许可证",
"License Details": "许可证详情",
"License Key": "许可证密钥",
@@ -925,6 +927,7 @@
"Remote Sites": "远程站点",
"Remote Technical Support": "远程技术支持",
"Remote Tiering": "远程分层",
"Remote target not found for this rule. Refresh and try again.": "未找到该规则对应的远程目标,请刷新后重试。",
"Remove": "移除",
"Remove All Sites": "移除所有站点",
"Remove Encryption": "移除加密",