mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
feat(replication): add bucket replication rule editing (#195)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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,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",
|
||||
|
||||
@@ -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,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,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": "L’endpoint est dérivé de la région",
|
||||
"Endpoint is derived from the region and cannot be edited.": "L’endpoint est dérivé de la région et ne peut pas être modifié.",
|
||||
"Endpoint preview": "Aperçu de l’endpoint",
|
||||
"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,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,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,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,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,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,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,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,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",
|
||||
|
||||
@@ -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": "移除加密",
|
||||
|
||||
Reference in New Issue
Block a user