mirror of
https://github.com/rustfs/console.git
synced 2026-08-31 01:37:52 +08:00
feat: add Wasabi tier provider (#173)
This commit is contained in:
@@ -14,10 +14,13 @@ import { TiersNewForm } from "@/components/tiers/new-form"
|
||||
import { TiersChangeKey } from "@/components/tiers/change-key"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { TIER_PROVIDERS } from "@/lib/tier-config"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
function getConfig(row: TierRow): TierConfig | undefined {
|
||||
switch (row.type) {
|
||||
case "wasabi":
|
||||
return row.wasabi
|
||||
case "rustfs":
|
||||
return row.rustfs
|
||||
case "minio":
|
||||
@@ -101,7 +104,14 @@ export default function TiersPage() {
|
||||
{
|
||||
header: () => t("Tier Type"),
|
||||
accessorKey: "type",
|
||||
cell: ({ row }) => <span className="capitalize">{row.original.type || "-"}</span>,
|
||||
cell: ({ row }) => {
|
||||
const provider = TIER_PROVIDERS.find((item) => item.value === row.original.type)
|
||||
return (
|
||||
<span className={provider ? undefined : "capitalize"}>
|
||||
{provider ? t(provider.labelKey) : row.original.type || "-"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
|
||||
@@ -51,7 +51,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
const [prefix, setPrefix] = useState("")
|
||||
const [expiredDeleteMark, setExpiredDeleteMark] = useState(false)
|
||||
const [tags, setTags] = useState<Tag[]>([{ key: "", value: "" }])
|
||||
const [tiers, setTiers] = useState<Array<{ label: string; value: string }>>([])
|
||||
const [tiers, setTiers] = useState<Array<{ label: string; value: string; type: string }>>([])
|
||||
const [tiersLoading, setTiersLoading] = useState(false)
|
||||
const [tiersError, setTiersError] = useState("")
|
||||
const [tiersReloadVersion, setTiersReloadVersion] = useState(0)
|
||||
@@ -89,10 +89,10 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
return {
|
||||
label: config?.name ?? "",
|
||||
value: config?.name ?? "",
|
||||
type: item.type,
|
||||
}
|
||||
})
|
||||
setTiers(tierOptions)
|
||||
if (tierOptions.length > 0) setStorageType((current) => current || tierOptions[0].value)
|
||||
}
|
||||
} catch {
|
||||
setTiers([])
|
||||
@@ -150,6 +150,8 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
setFieldErrors({})
|
||||
}, [])
|
||||
|
||||
const selectedTierType = tiers.find((tier) => tier.value === storageType)?.type
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
@@ -623,6 +625,18 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
{tiersLoading ? <FieldDescription>{t("Loading")}</FieldDescription> : null}
|
||||
<FieldError id="lifecycle-storage-type-error">{fieldErrors.storageType}</FieldError>
|
||||
</Field>
|
||||
{selectedTierType === "wasabi" ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Wasabi lifecycle requirements")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc ps-4">
|
||||
<li>{t("The remote Wasabi bucket must never have had Versioning enabled.")}</li>
|
||||
<li>{t("Compliance or Object Lock retention may prevent deletion.")}</li>
|
||||
<li>{t("Early expiry may still incur minimum storage duration charges.")}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<details className="space-y-4">
|
||||
|
||||
+401
-236
@@ -2,14 +2,31 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ThemeImage } from "@/components/theme/image"
|
||||
import { useTiers } from "@/hooks/use-tiers"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import {
|
||||
TIER_PROVIDERS,
|
||||
WASABI_REGIONS,
|
||||
buildTierPayload,
|
||||
getWasabiEndpoint,
|
||||
type TierProviderType,
|
||||
} from "@/lib/tier-config"
|
||||
import { getThemeManifest } from "@/lib/theme/manifest"
|
||||
|
||||
interface TiersNewFormProps {
|
||||
@@ -18,20 +35,16 @@ interface TiersNewFormProps {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ labelKey: "RustFS", value: "rustfs", icon: "/logo.svg", descKey: "RustFS built-in cold storage" },
|
||||
{ labelKey: "MinIO", value: "minio", icon: "/svg/minio.svg", descKey: "External MinIO tier" },
|
||||
{ labelKey: "AWS S3", value: "s3", icon: "/svg/aws.svg", descKey: "Standard AWS S3 tier" },
|
||||
{
|
||||
labelKey: "Alibaba Cloud",
|
||||
value: "aliyun",
|
||||
icon: "/svg/aliyun.svg",
|
||||
descKey: "Alibaba Cloud Object Storage Service",
|
||||
},
|
||||
{ labelKey: "Azure Blob", value: "azure", icon: "/svg/azure.svg", descKey: "Microsoft Azure Blob Storage" },
|
||||
{ labelKey: "Google Cloud Storage", value: "gcs", icon: "/svg/google.svg", descKey: "Google Cloud Storage" },
|
||||
{ labelKey: "Cloudflare R2", value: "r2", icon: "/svg/cloudflare.svg", descKey: "Cloudflare R2 Storage" },
|
||||
] as const
|
||||
type FieldName = "name" | "region" | "accessKey" | "secretKey" | "bucket"
|
||||
type FieldErrors = Partial<Record<FieldName, string>>
|
||||
|
||||
const FIELD_IDS: Record<FieldName, string> = {
|
||||
name: "tier-name",
|
||||
region: "tier-region",
|
||||
accessKey: "tier-access-key",
|
||||
secretKey: "tier-secret-key",
|
||||
bucket: "tier-bucket",
|
||||
}
|
||||
|
||||
export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -39,99 +52,127 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp
|
||||
const { addTiers } = useTiers()
|
||||
const theme = getThemeManifest()
|
||||
|
||||
const [type, setType] = React.useState("")
|
||||
const [type, setType] = React.useState<TierProviderType | "">("")
|
||||
const [name, setName] = React.useState("")
|
||||
const [endpoint, setEndpoint] = React.useState("")
|
||||
const [accesskey, setAccesskey] = React.useState("")
|
||||
const [secretkey, setSecretkey] = React.useState("")
|
||||
const [accessKey, setAccessKey] = React.useState("")
|
||||
const [secretKey, setSecretKey] = React.useState("")
|
||||
const [creds, setCreds] = React.useState("")
|
||||
const [bucket, setBucket] = React.useState("")
|
||||
const [prefix, setPrefix] = React.useState("")
|
||||
const [region, setRegion] = React.useState("")
|
||||
const [storageclass, setStorageclass] = React.useState("STANDARD")
|
||||
const [nameError, setNameError] = React.useState("")
|
||||
const [storageClass, setStorageClass] = React.useState("STANDARD")
|
||||
const [fieldErrors, setFieldErrors] = React.useState<FieldErrors>({})
|
||||
const [saveError, setSaveError] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [focusTarget, setFocusTarget] = React.useState<"name" | TierProviderType | null>(null)
|
||||
const submittingRef = React.useRef(false)
|
||||
|
||||
const selectedOption = TYPE_OPTIONS.find((o) => o.value === type)
|
||||
const selectedOption = TIER_PROVIDERS.find((option) => option.value === type)
|
||||
const isWasabi = type === "wasabi"
|
||||
const wasabiEndpoint = isWasabi ? getWasabiEndpoint(region) : ""
|
||||
|
||||
const renderTypeIcon = (icon: string) => {
|
||||
return <ThemeImage src={icon} alt="" width={40} height={40} className="size-10 shrink-0 object-contain" />
|
||||
const renderTypeIcon = (icon: string) => (
|
||||
<ThemeImage src={icon} alt="" width={40} height={40} className="size-10 shrink-0 object-contain" />
|
||||
)
|
||||
|
||||
const clearProviderErrors = () => {
|
||||
setFieldErrors({})
|
||||
setSaveError("")
|
||||
}
|
||||
|
||||
const handleProviderSelect = (provider: TierProviderType) => {
|
||||
setFocusTarget("name")
|
||||
clearProviderErrors()
|
||||
setType(provider)
|
||||
}
|
||||
|
||||
const handleProviderBack = () => {
|
||||
if (!type) return
|
||||
const previousType = type
|
||||
setFocusTarget(previousType)
|
||||
clearProviderErrors()
|
||||
setType("")
|
||||
}
|
||||
|
||||
const resetForm = React.useCallback(() => {
|
||||
setType("")
|
||||
setName("")
|
||||
setEndpoint("")
|
||||
setAccesskey("")
|
||||
setSecretkey("")
|
||||
setAccessKey("")
|
||||
setSecretKey("")
|
||||
setCreds("")
|
||||
setBucket("")
|
||||
setPrefix("")
|
||||
setRegion("")
|
||||
setStorageclass("STANDARD")
|
||||
setNameError("")
|
||||
setStorageClass("STANDARD")
|
||||
setFieldErrors({})
|
||||
setSaveError("")
|
||||
setFocusTarget(null)
|
||||
submittingRef.current = false
|
||||
setSubmitting(false)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
}
|
||||
if (open) resetForm()
|
||||
}, [open, resetForm])
|
||||
|
||||
const filterName = (v: string) => {
|
||||
return v.replace(/[^A-Za-z0-9_]/g, "").toUpperCase()
|
||||
}
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(filterName(e.target.value))
|
||||
const clearFieldError = (field: FieldName) => {
|
||||
setFieldErrors((current) => ({ ...current, [field]: undefined }))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
if (!type) {
|
||||
message.error(t("Please select rule type"))
|
||||
return false
|
||||
const errors: FieldErrors = {}
|
||||
if (!name.trim()) errors.name = t("Please enter rule name")
|
||||
if (isWasabi) {
|
||||
if (!region.trim()) errors.region = t("Region is required")
|
||||
if (!accessKey.trim()) errors.accessKey = t("Access Key is required")
|
||||
if (!secretKey.trim()) errors.secretKey = t("Secret Key is required")
|
||||
if (!bucket.trim()) errors.bucket = t("Bucket is required")
|
||||
}
|
||||
if (!name) {
|
||||
setNameError(t("Please enter rule name"))
|
||||
return false
|
||||
}
|
||||
setNameError("")
|
||||
return true
|
||||
setFieldErrors(errors)
|
||||
const firstError = (Object.keys(FIELD_IDS) as FieldName[]).find((field) => errors[field])
|
||||
const firstErrorId = firstError ? FIELD_IDS[firstError] : null
|
||||
if (firstErrorId) document.getElementById(firstErrorId)?.focus()
|
||||
return !firstError
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return
|
||||
if (submittingRef.current) return
|
||||
if (!type || !validate()) return
|
||||
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
setSaveError("")
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
const payload = buildTierPayload(type, {
|
||||
name,
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
creds,
|
||||
bucket,
|
||||
prefix,
|
||||
region,
|
||||
storageClass: storageclass,
|
||||
}
|
||||
if (type === "gcs") {
|
||||
config.creds = creds
|
||||
} else {
|
||||
config.accessKey = accesskey
|
||||
config.secretKey = secretkey
|
||||
}
|
||||
const payload = { type, [type]: config }
|
||||
storageClass,
|
||||
})
|
||||
await addTiers(payload)
|
||||
message.success(t("Create Success"))
|
||||
onSuccess?.()
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Create Failed"))
|
||||
const errorMessage = (error as Error).message || t("Create Failed")
|
||||
setSaveError(errorMessage)
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
if (submittingRef.current) return
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
@@ -144,204 +185,328 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp
|
||||
if (!submitting) onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-lg">
|
||||
<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-4 pe-12 sm:px-6">
|
||||
<DialogTitle>{t("Add Tier")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-6 overflow-y-auto px-4 py-5 sm:px-6" aria-busy={submitting}>
|
||||
{!type ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{TYPE_OPTIONS.map((item) => (
|
||||
|
||||
<form
|
||||
className="contents"
|
||||
aria-busy={submitting}
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleSave()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
|
||||
{!type ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{TIER_PROVIDERS.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
id={`tier-provider-${item.value}`}
|
||||
type="button"
|
||||
autoFocus={focusTarget === item.value}
|
||||
onClick={() => handleProviderSelect(item.value)}
|
||||
className="min-h-20 cursor-pointer border border-border/70 text-start transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{renderTypeIcon(item.icon)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold" title={t(item.labelKey)}>
|
||||
{t(item.labelKey)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t(item.descKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setType(item.value)}
|
||||
className="cursor-pointer border border-border/70 text-start transition hover:border-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
||||
onClick={handleProviderBack}
|
||||
className="w-full cursor-pointer border text-start transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{renderTypeIcon(item.icon)}
|
||||
{selectedOption ? renderTypeIcon(selectedOption.icon) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold" title={t(item.labelKey)}>
|
||||
{t(item.labelKey)}
|
||||
<p className="text-sm text-muted-foreground">{t("Selected Type")}</p>
|
||||
<p
|
||||
className="truncate text-base font-semibold"
|
||||
title={type === "rustfs" ? theme.brand.name : t(selectedOption?.labelKey ?? "")}
|
||||
>
|
||||
{type === "rustfs" ? theme.brand.name : t(selectedOption?.labelKey ?? "")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t(item.descKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType("")}
|
||||
className="w-full cursor-pointer border text-start transition hover:border-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{selectedOption ? renderTypeIcon(selectedOption.icon) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{t("Selected Type")}</p>
|
||||
<p className="truncate text-base font-semibold" title={type === "rustfs" ? theme.brand.name : type}>
|
||||
{type === "rustfs" ? theme.brand.name : type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-name">{t("Name")} (A-Z,0-9,_)</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-name"
|
||||
name="tier-name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder={t("Please enter name")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
{nameError && <FieldDescription className="text-destructive">{nameError}</FieldDescription>}
|
||||
</Field>
|
||||
{saveError ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>{t("Create Failed")}</AlertTitle>
|
||||
<AlertDescription>{saveError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-endpoint">{t("Endpoint")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-endpoint"
|
||||
name="tier-endpoint"
|
||||
type="url"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter endpoint")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{type === "gcs" ? (
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="tier-credentials">{t("Credentials")} (JSON)</FieldLabel>
|
||||
<FieldGroup className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field data-invalid={Boolean(fieldErrors.name)}>
|
||||
<FieldLabel htmlFor="tier-name">{t("Name")} (A-Z,0-9,_)</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="tier-credentials"
|
||||
name="tier-credentials"
|
||||
value={creds}
|
||||
onChange={(e) => setCreds(e.target.value)}
|
||||
placeholder={t("Please enter GCS credentials JSON")}
|
||||
<Input
|
||||
id="tier-name"
|
||||
name="tier-name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value.replace(/[^A-Za-z0-9_]/g, "").toUpperCase())
|
||||
clearFieldError("name")
|
||||
}}
|
||||
placeholder={t("Please enter name")}
|
||||
autoFocus={focusTarget === "name"}
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
rows={6}
|
||||
aria-invalid={Boolean(fieldErrors.name)}
|
||||
aria-describedby={fieldErrors.name ? "tier-name-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="tier-name-error">{fieldErrors.name}</FieldError>
|
||||
</Field>
|
||||
|
||||
{isWasabi ? (
|
||||
<Field data-invalid={Boolean(fieldErrors.region)}>
|
||||
<FieldLabel htmlFor="tier-region">{t("Region")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Combobox
|
||||
items={[...WASABI_REGIONS]}
|
||||
value={WASABI_REGIONS.includes(region as (typeof WASABI_REGIONS)[number]) ? region : null}
|
||||
inputValue={region}
|
||||
onInputValueChange={(value) => {
|
||||
setRegion(value)
|
||||
clearFieldError("region")
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
setRegion(value ?? "")
|
||||
clearFieldError("region")
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
id="tier-region"
|
||||
name="tier-region"
|
||||
placeholder={t("Enter or select a region")}
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(fieldErrors.region)}
|
||||
aria-describedby={fieldErrors.region ? "tier-region-error" : "tier-region-description"}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{t("Use the entered region")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FieldContent>
|
||||
<FieldDescription id="tier-region-description">
|
||||
{t("Choose a suggested region or enter a new one.")}
|
||||
</FieldDescription>
|
||||
<FieldError id="tier-region-error">{fieldErrors.region}</FieldError>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-endpoint">{t("Endpoint")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-endpoint"
|
||||
name="tier-endpoint"
|
||||
type="url"
|
||||
value={endpoint}
|
||||
onChange={(event) => setEndpoint(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter endpoint")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isWasabi ? (
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="tier-endpoint-preview">{t("Endpoint preview")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-endpoint-preview"
|
||||
value={wasabiEndpoint}
|
||||
readOnly
|
||||
placeholder={t("Endpoint is derived from the region")}
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
aria-describedby="tier-endpoint-preview-description"
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription id="tier-endpoint-preview-description">
|
||||
{t("Endpoint is derived from the region and cannot be edited.")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{type === "gcs" ? (
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="tier-credentials">{t("Credentials")} (JSON)</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="tier-credentials"
|
||||
name="tier-credentials"
|
||||
value={creds}
|
||||
onChange={(event) => setCreds(event.target.value)}
|
||||
placeholder={t("Please enter GCS credentials JSON")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
rows={6}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field data-invalid={Boolean(fieldErrors.accessKey)}>
|
||||
<FieldLabel htmlFor="tier-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-access-key"
|
||||
name="tier-access-key"
|
||||
value={accessKey}
|
||||
onChange={(event) => {
|
||||
setAccessKey(event.target.value)
|
||||
clearFieldError("accessKey")
|
||||
}}
|
||||
placeholder={t("Please enter Access Key")}
|
||||
required={isWasabi}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(fieldErrors.accessKey)}
|
||||
aria-describedby={fieldErrors.accessKey ? "tier-access-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="tier-access-key-error">{fieldErrors.accessKey}</FieldError>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(fieldErrors.secretKey)}>
|
||||
<FieldLabel htmlFor="tier-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-secret-key"
|
||||
name="tier-secret-key"
|
||||
value={secretKey}
|
||||
onChange={(event) => {
|
||||
setSecretKey(event.target.value)
|
||||
clearFieldError("secretKey")
|
||||
}}
|
||||
type="password"
|
||||
placeholder={t("Please enter Secret Key")}
|
||||
required={isWasabi}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(fieldErrors.secretKey)}
|
||||
aria-describedby={fieldErrors.secretKey ? "tier-secret-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="tier-secret-key-error">{fieldErrors.secretKey}</FieldError>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(fieldErrors.bucket)}>
|
||||
<FieldLabel htmlFor="tier-bucket">{t("Bucket")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-bucket"
|
||||
name="tier-bucket"
|
||||
value={bucket}
|
||||
onChange={(event) => {
|
||||
setBucket(event.target.value)
|
||||
clearFieldError("bucket")
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter bucket")}
|
||||
required={isWasabi}
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(fieldErrors.bucket)}
|
||||
aria-describedby={fieldErrors.bucket ? "tier-bucket-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="tier-bucket-error">{fieldErrors.bucket}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-prefix">
|
||||
{t("Prefix")} ({t("Optional")})
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-prefix"
|
||||
name="tier-prefix"
|
||||
value={prefix}
|
||||
onChange={(event) => setPrefix(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-access-key"
|
||||
name="tier-access-key"
|
||||
value={accesskey}
|
||||
onChange={(e) => setAccesskey(e.target.value)}
|
||||
placeholder={t("Please enter Access Key")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-secret-key"
|
||||
name="tier-secret-key"
|
||||
value={secretkey}
|
||||
onChange={(e) => setSecretkey(e.target.value)}
|
||||
type="password"
|
||||
placeholder={t("Please enter Secret Key")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-bucket">{t("Bucket")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-bucket"
|
||||
name="tier-bucket"
|
||||
value={bucket}
|
||||
onChange={(e) => setBucket(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter bucket")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-prefix"
|
||||
name="tier-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-region">{t("Region")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-region"
|
||||
name="tier-region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter region")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-storage-class">{t("Storage Class")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-storage-class"
|
||||
name="tier-storage-class"
|
||||
value={storageclass}
|
||||
onChange={(e) => setStorageclass(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please Enter storage class")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
{!isWasabi ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-region">{t("Region")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-region"
|
||||
name="tier-region"
|
||||
value={region}
|
||||
onChange={(event) => setRegion(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter region")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tier-storage-class">{t("Storage Class")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tier-storage-class"
|
||||
name="tier-storage-class"
|
||||
value={storageClass}
|
||||
onChange={(event) => setStorageClass(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please Enter storage class")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-4 sm:px-6">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!type || submitting}>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-4 sm:px-6">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!type || submitting}>
|
||||
{submitting ? <Spinner data-icon="inline-start" /> : null}
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface TierConfig {
|
||||
|
||||
export interface TierRow {
|
||||
type: string
|
||||
wasabi?: TierConfig
|
||||
rustfs?: TierConfig
|
||||
minio?: TierConfig
|
||||
s3?: TierConfig
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "الحاوية مطلوبة",
|
||||
"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.": "قد يترتب على انتهاء الصلاحية المبكر رسوم الحد الأدنى لمدة التخزين.",
|
||||
"Endpoint is derived from the region": "يتم اشتقاق نقطة النهاية من المنطقة",
|
||||
"Endpoint is derived from the region and cannot be edited.": "يتم اشتقاق نقطة النهاية من المنطقة ولا يمكن تعديلها.",
|
||||
"Endpoint preview": "معاينة نقطة النهاية",
|
||||
"Enter or select a region": "أدخل منطقة أو اخترها",
|
||||
"Region is required": "المنطقة مطلوبة",
|
||||
"The remote Wasabi bucket must never have had Versioning enabled.": "يجب ألا يكون Versioning قد فُعّل مطلقًا على حاوية Wasabi البعيدة.",
|
||||
"Use the entered region": "استخدام المنطقة المُدخلة",
|
||||
"Wasabi": "Wasabi",
|
||||
"Wasabi hot cloud storage": "تخزين Wasabi السحابي الساخن",
|
||||
"Wasabi lifecycle requirements": "متطلبات دورة حياة Wasabi",
|
||||
"(Configuration details are private)": "(تفاصيل الإعداد خاصة)",
|
||||
"(Configured)": "(مُعَدّ)",
|
||||
"API Base URL": "عنوان URL الأساسي لـ API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Bucket ist erforderlich",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "Region ist erforderlich",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Wasabi Hot-Cloud-Speicher",
|
||||
"Wasabi lifecycle requirements": "Wasabi-Lebenszyklusanforderungen",
|
||||
"(Configuration details are private)": "(Konfigurationsdetails sind privat)",
|
||||
"(Configured)": "(Konfiguriert)",
|
||||
"API Base URL": "API-Basis-URL",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Bucket is required",
|
||||
"Choose a suggested region or enter a new one.": "Choose a suggested region or enter a new one.",
|
||||
"Compliance or Object Lock retention may prevent deletion.": "Compliance or Object Lock retention may prevent deletion.",
|
||||
"Early expiry may still incur minimum storage duration charges.": "Early expiry may still incur minimum storage duration charges.",
|
||||
"Endpoint is derived from the region": "Endpoint is derived from the region",
|
||||
"Endpoint is derived from the region and cannot be edited.": "Endpoint is derived from the region and cannot be edited.",
|
||||
"Endpoint preview": "Endpoint preview",
|
||||
"Enter or select a region": "Enter or select a region",
|
||||
"Region is required": "Region is required",
|
||||
"The remote Wasabi bucket must never have had Versioning enabled.": "The remote Wasabi bucket must never have had Versioning enabled.",
|
||||
"Use the entered region": "Use the entered region",
|
||||
"Wasabi": "Wasabi",
|
||||
"Wasabi hot cloud storage": "Wasabi hot cloud storage",
|
||||
"Wasabi lifecycle requirements": "Wasabi lifecycle requirements",
|
||||
"(Configuration details are private)": "(Configuration details are private)",
|
||||
"(Configured)": "(Configured)",
|
||||
"API Base URL": "API Base URL",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "El bucket es obligatorio",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "La región es obligatoria",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Almacenamiento Hot Cloud de Wasabi",
|
||||
"Wasabi lifecycle requirements": "Requisitos de ciclo de vida de Wasabi",
|
||||
"(Configuration details are private)": "(Los detalles de configuración son privados)",
|
||||
"(Configured)": "(Configurado)",
|
||||
"API Base URL": "URL Base de la API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Le bucket est requis",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "La région est requise",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Stockage cloud chaud Wasabi",
|
||||
"Wasabi lifecycle requirements": "Exigences de cycle de vie Wasabi",
|
||||
"(Configuration details are private)": "(Les détails de configuration sont privés)",
|
||||
"(Configured)": "(Configuré)",
|
||||
"API Base URL": "URL de base de l'API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Bucket wajib diisi",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "Region wajib diisi",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Penyimpanan hot cloud Wasabi",
|
||||
"Wasabi lifecycle requirements": "Persyaratan siklus hidup Wasabi",
|
||||
"(Configuration details are private)": "(Rincian konfigurasi bersifat pribadi)",
|
||||
"(Configured)": "(Dikonfigurasi)",
|
||||
"API Base URL": "URL Dasar API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Il bucket è obbligatorio",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "La regione è obbligatoria",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Archiviazione hot cloud Wasabi",
|
||||
"Wasabi lifecycle requirements": "Requisiti del ciclo di vita Wasabi",
|
||||
"(Configuration details are private)": "(I dettagli di configurazione sono privati)",
|
||||
"(Configured)": "(Configurato)",
|
||||
"API Base URL": "URL base API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "バケットは必須です",
|
||||
"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.": "早期に期限切れにしても、最低保存期間の料金が発生する場合があります。",
|
||||
"Endpoint is derived from the region": "エンドポイントはリージョンから生成されます",
|
||||
"Endpoint is derived from the region and cannot be edited.": "エンドポイントはリージョンから生成されるため編集できません。",
|
||||
"Endpoint preview": "エンドポイントのプレビュー",
|
||||
"Enter or select a region": "リージョンを入力または選択",
|
||||
"Region is required": "リージョンは必須です",
|
||||
"The remote Wasabi bucket must never have had Versioning enabled.": "リモート Wasabi バケットで Versioning が一度も有効化されていない必要があります。",
|
||||
"Use the entered region": "入力したリージョンを使用",
|
||||
"Wasabi": "Wasabi",
|
||||
"Wasabi hot cloud storage": "Wasabi ホットクラウドストレージ",
|
||||
"Wasabi lifecycle requirements": "Wasabi ライフサイクルの要件",
|
||||
"(Configuration details are private)": "(設定の詳細は非公開です)",
|
||||
"(Configured)": "(設定済み)",
|
||||
"API Base URL": "APIベースURL",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "버킷은 필수입니다",
|
||||
"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.": "조기 만료에도 최소 저장 기간 요금이 부과될 수 있습니다.",
|
||||
"Endpoint is derived from the region": "엔드포인트는 리전에서 파생됩니다",
|
||||
"Endpoint is derived from the region and cannot be edited.": "엔드포인트는 리전에서 파생되며 편집할 수 없습니다.",
|
||||
"Endpoint preview": "엔드포인트 미리보기",
|
||||
"Enter or select a region": "리전 입력 또는 선택",
|
||||
"Region is required": "리전은 필수입니다",
|
||||
"The remote Wasabi bucket must never have had Versioning enabled.": "원격 Wasabi 버킷은 Versioning이 활성화된 적이 없어야 합니다.",
|
||||
"Use the entered region": "입력한 리전 사용",
|
||||
"Wasabi": "Wasabi",
|
||||
"Wasabi hot cloud storage": "Wasabi 핫 클라우드 스토리지",
|
||||
"Wasabi lifecycle requirements": "Wasabi 수명 주기 요구 사항",
|
||||
"(Configuration details are private)": "(구성 세부 정보는 비공개입니다)",
|
||||
"(Configured)": "(구성됨)",
|
||||
"API Base URL": "API 기본 URL",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "O bucket é obrigatório",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "A região é obrigatória",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Armazenamento hot cloud Wasabi",
|
||||
"Wasabi lifecycle requirements": "Requisitos de ciclo de vida da Wasabi",
|
||||
"(Configuration details are private)": "(Detalhes de configuração são privados)",
|
||||
"(Configured)": "(Configurado)",
|
||||
"API Base URL": "URL Base da API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Требуется бакет",
|
||||
"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.": "При досрочном удалении может взиматься плата за минимальный срок хранения.",
|
||||
"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": "Введите или выберите регион",
|
||||
"Region is required": "Требуется регион",
|
||||
"The remote Wasabi bucket must never have had Versioning enabled.": "В удалённом бакете Wasabi функция Versioning никогда не должна была включаться.",
|
||||
"Use the entered region": "Использовать введённый регион",
|
||||
"Wasabi": "Wasabi",
|
||||
"Wasabi hot cloud storage": "Горячее облачное хранилище Wasabi",
|
||||
"Wasabi lifecycle requirements": "Требования жизненного цикла Wasabi",
|
||||
"(Configuration details are private)": "(Детали конфигурации являются приватными)",
|
||||
"(Configured)": "(Настроено)",
|
||||
"API Base URL": "Базовый URL API",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Bucket gereklidir",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "Region gereklidir",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Wasabi sıcak bulut depolama",
|
||||
"Wasabi lifecycle requirements": "Wasabi yaşam döngüsü gereksinimleri",
|
||||
"(Configuration details are private)": "(Yapılandırma ayrıntıları gizlidir)",
|
||||
"(Configured)": "(Yapılandırılmış)",
|
||||
"API Base URL": "API Temel URL",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Bắt buộc nhập bucket",
|
||||
"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.",
|
||||
"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",
|
||||
"Region is required": "Bắt buộc nhập region",
|
||||
"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",
|
||||
"Wasabi hot cloud storage": "Lưu trữ đám mây nóng Wasabi",
|
||||
"Wasabi lifecycle requirements": "Yêu cầu vòng đời Wasabi",
|
||||
"(Configuration details are private)": "(Chi tiết cấu hình được bảo mật)",
|
||||
"(Configured)": "(Đã cấu hình)",
|
||||
"API Base URL": "Đường dẫn API gốc",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"Bucket is required": "Bucket 为必填项",
|
||||
"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.": "提前到期仍可能产生最低存储时长费用。",
|
||||
"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": "输入或选择区域",
|
||||
"Region is required": "区域为必填项",
|
||||
"The remote Wasabi bucket must never have had Versioning enabled.": "远端 Wasabi Bucket 必须从未启用 Versioning。",
|
||||
"Use the entered region": "使用已输入的区域",
|
||||
"Wasabi": "Wasabi",
|
||||
"Wasabi hot cloud storage": "Wasabi 热云存储",
|
||||
"Wasabi lifecycle requirements": "Wasabi 生命周期要求",
|
||||
"(Configuration details are private)": "(配置详情为私有)",
|
||||
"(Configured)": "(已配置)",
|
||||
"API Base URL": "API基础URL",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export const TIER_PROVIDERS = [
|
||||
{ labelKey: "Wasabi", value: "wasabi", icon: "/svg/wasabi.svg", descKey: "Wasabi hot cloud storage" },
|
||||
{ labelKey: "RustFS", value: "rustfs", icon: "/logo.svg", descKey: "RustFS built-in cold storage" },
|
||||
{ labelKey: "MinIO", value: "minio", icon: "/svg/minio.svg", descKey: "External MinIO tier" },
|
||||
{ labelKey: "AWS S3", value: "s3", icon: "/svg/aws.svg", descKey: "Standard AWS S3 tier" },
|
||||
{
|
||||
labelKey: "Alibaba Cloud",
|
||||
value: "aliyun",
|
||||
icon: "/svg/aliyun.svg",
|
||||
descKey: "Alibaba Cloud Object Storage Service",
|
||||
},
|
||||
{ labelKey: "Azure Blob", value: "azure", icon: "/svg/azure.svg", descKey: "Microsoft Azure Blob Storage" },
|
||||
{ labelKey: "Google Cloud Storage", value: "gcs", icon: "/svg/google.svg", descKey: "Google Cloud Storage" },
|
||||
{ labelKey: "Cloudflare R2", value: "r2", icon: "/svg/cloudflare.svg", descKey: "Cloudflare R2 Storage" },
|
||||
] as const
|
||||
|
||||
export type TierProviderType = (typeof TIER_PROVIDERS)[number]["value"]
|
||||
|
||||
export interface TierFormValues {
|
||||
name: string
|
||||
endpoint: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
creds: string
|
||||
bucket: string
|
||||
prefix: string
|
||||
region: string
|
||||
storageClass: string
|
||||
}
|
||||
|
||||
export const WASABI_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-central-1",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-south-1",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
] as const
|
||||
|
||||
export function normalizeWasabiRegion(region: string): string {
|
||||
return region.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function getWasabiEndpoint(region: string): string {
|
||||
const normalizedRegion = normalizeWasabiRegion(region)
|
||||
if (!normalizedRegion) return ""
|
||||
if (normalizedRegion === "us-east-1") return "https://s3.wasabisys.com"
|
||||
return `https://s3.${normalizedRegion}.wasabisys.com`
|
||||
}
|
||||
|
||||
export function buildTierPayload(type: TierProviderType, values: TierFormValues) {
|
||||
if (type === "wasabi") {
|
||||
return {
|
||||
type,
|
||||
wasabi: {
|
||||
name: values.name,
|
||||
region: normalizeWasabiRegion(values.region),
|
||||
accessKey: values.accessKey,
|
||||
secretKey: values.secretKey,
|
||||
bucket: values.bucket,
|
||||
prefix: values.prefix,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
name: values.name,
|
||||
endpoint: values.endpoint,
|
||||
bucket: values.bucket,
|
||||
prefix: values.prefix,
|
||||
region: values.region,
|
||||
storageClass: values.storageClass,
|
||||
}
|
||||
if (type === "gcs") {
|
||||
config.creds = values.creds
|
||||
} else {
|
||||
config.accessKey = values.accessKey
|
||||
config.secretKey = values.secretKey
|
||||
}
|
||||
return { type, [type]: config }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134.16 134.16" role="img" aria-labelledby="wasabi-title">
|
||||
<title id="wasabi-title">Wasabi</title>
|
||||
<!-- Official Wasabi logomark path from secondary-logo-full-color-rgb.svg; geometry and brand color are unchanged. -->
|
||||
<path fill="#00ce3e" d="m98.98,111.91v-17.39l9.58,8.69c-2.84,3.26-6.06,6.18-9.58,8.7h0Zm-40.17,9.59l28.12-26.58v23.5c-6.16,2.39-12.86,3.71-19.85,3.71-2.81,0-5.57-.21-8.27-.62h0ZM12.35,72.86l29.55,26.5,29.71-27.9,15.34-14.5v21.38l-41.58,39.3c-17.89-7.71-30.9-24.65-33.01-44.79h0Zm23.36-50.97l.02,17.47-9.8-8.78c2.9-3.27,6.18-6.19,9.78-8.69h0Zm.06,55.79l-22.83-20.48c1.09-5.97,3.14-11.6,5.99-16.74l16.82,15.09.02,22.13h0ZM75.49,12.68l-12.42,11.74-15.3,14.51-.02-23.38c6.02-2.27,12.54-3.51,19.34-3.51,2.86,0,5.67.22,8.41.64h0Zm45.77,64.09c-1.05,5.86-3.02,11.4-5.77,16.47l-16.52-14.97v-21.7l22.29,20.2h0Zm.52-15.79l-28.91-26.21-29.53,27.92-15.53,14.59-.02-21.76,23.56-22.35,17.57-16.61c17.74,7.7,30.65,24.47,32.86,44.42h0Zm-7.26-41.34C101.84,6.98,85,0,67.08,0S32.32,6.98,19.65,19.65C6.98,32.32,0,49.16,0,67.08s6.98,34.76,19.65,47.43c12.67,12.67,29.52,19.65,47.43,19.65s34.76-6.98,47.43-19.65c12.67-12.67,19.65-29.52,19.65-47.43s-6.98-34.76-19.65-47.43h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134.16 134.16" role="img" aria-labelledby="wasabi-title">
|
||||
<title id="wasabi-title">Wasabi</title>
|
||||
<!-- Official Wasabi logomark path from secondary-logo-full-color-rgb.svg; geometry and brand color are unchanged. -->
|
||||
<path fill="#00ce3e" d="m98.98,111.91v-17.39l9.58,8.69c-2.84,3.26-6.06,6.18-9.58,8.7h0Zm-40.17,9.59l28.12-26.58v23.5c-6.16,2.39-12.86,3.71-19.85,3.71-2.81,0-5.57-.21-8.27-.62h0ZM12.35,72.86l29.55,26.5,29.71-27.9,15.34-14.5v21.38l-41.58,39.3c-17.89-7.71-30.9-24.65-33.01-44.79h0Zm23.36-50.97l.02,17.47-9.8-8.78c2.9-3.27,6.18-6.19,9.78-8.69h0Zm.06,55.79l-22.83-20.48c1.09-5.97,3.14-11.6,5.99-16.74l16.82,15.09.02,22.13h0ZM75.49,12.68l-12.42,11.74-15.3,14.51-.02-23.38c6.02-2.27,12.54-3.51,19.34-3.51,2.86,0,5.67.22,8.41.64h0Zm45.77,64.09c-1.05,5.86-3.02,11.4-5.77,16.47l-16.52-14.97v-21.7l22.29,20.2h0Zm.52-15.79l-28.91-26.21-29.53,27.92-15.53,14.59-.02-21.76,23.56-22.35,17.57-16.61c17.74,7.7,30.65,24.47,32.86,44.42h0Zm-7.26-41.34C101.84,6.98,85,0,67.08,0S32.32,6.98,19.65,19.65C6.98,32.32,0,49.16,0,67.08s6.98,34.76,19.65,47.43c12.67,12.67,29.52,19.65,47.43,19.65s34.76-6.98,47.43-19.65c12.67-12.67,19.65-29.52,19.65-47.43s-6.98-34.76-19.65-47.43h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -60,12 +60,16 @@ test("ApiClient redacts sensitive headers and request bodies from development lo
|
||||
headers: { Authorization: "Bearer header-secret", Cookie: "session=cookie-secret" },
|
||||
body: JSON.stringify({
|
||||
password: "body-secret",
|
||||
secretKey: "tier-secret-key",
|
||||
profile: { apiKey: "nested-secret" },
|
||||
creds: '{"private_key":"gcs-private-key"}',
|
||||
}),
|
||||
})
|
||||
|
||||
const serialized = JSON.stringify(redacted)
|
||||
assert.doesNotMatch(serialized, /body-secret|nested-secret|header-secret|cookie-secret|gcs-private-key/)
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/body-secret|tier-secret-key|nested-secret|header-secret|cookie-secret|gcs-private-key/,
|
||||
)
|
||||
assert.match(serialized, /\[REDACTED\]/)
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ test("long settings dialogs keep headers and actions visible while the form scro
|
||||
|
||||
assert.match(source, /max-h-\[min\(90dvh,52rem\)\]/)
|
||||
assert.match(source, /grid-rows-\[auto_minmax\(0,1fr\)_auto\]/)
|
||||
assert.match(source, /min-h-0 space-y-6 overflow-y-auto/)
|
||||
assert.match(source, /min-h-0[^\"]*overflow-y-auto/)
|
||||
assert.match(source, /<DialogFooter className="border-t bg-muted\/20/)
|
||||
assert.match(source, /disablePointerDismissal=\{submitting\}/)
|
||||
assert.match(source, /aria-busy=\{submitting\}/)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import { TIER_PROVIDERS, WASABI_REGIONS, buildTierPayload, getWasabiEndpoint } from "../../lib/tier-config"
|
||||
|
||||
test("Wasabi is the strict first provider without becoming a default selection", () => {
|
||||
assert.deepEqual(
|
||||
TIER_PROVIDERS.map(({ value, labelKey }) => [value, labelKey]),
|
||||
[
|
||||
["wasabi", "Wasabi"],
|
||||
["rustfs", "RustFS"],
|
||||
["minio", "MinIO"],
|
||||
["s3", "AWS S3"],
|
||||
["aliyun", "Alibaba Cloud"],
|
||||
["azure", "Azure Blob"],
|
||||
["gcs", "Google Cloud Storage"],
|
||||
["r2", "Cloudflare R2"],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test("Wasabi payload matches the backend contract exactly", () => {
|
||||
const payload = buildTierPayload("wasabi", {
|
||||
name: "ARCHIVE",
|
||||
endpoint: "https://must-not-be-sent.example",
|
||||
accessKey: "access-key",
|
||||
secretKey: "secret-key",
|
||||
bucket: "archive",
|
||||
prefix: "cold/",
|
||||
region: " EU-CENTRAL-1 ",
|
||||
storageClass: "STANDARD",
|
||||
creds: "ignored",
|
||||
})
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
type: "wasabi",
|
||||
wasabi: {
|
||||
name: "ARCHIVE",
|
||||
region: "eu-central-1",
|
||||
accessKey: "access-key",
|
||||
secretKey: "secret-key",
|
||||
bucket: "archive",
|
||||
prefix: "cold/",
|
||||
},
|
||||
})
|
||||
assert.equal("endpoint" in payload.wasabi, false)
|
||||
assert.equal("storageClass" in payload.wasabi, false)
|
||||
})
|
||||
|
||||
test("Wasabi endpoint preview follows the selected or custom region", () => {
|
||||
assert.equal(getWasabiEndpoint("us-east-1"), "https://s3.wasabisys.com")
|
||||
assert.equal(getWasabiEndpoint("ap-southeast-2"), "https://s3.ap-southeast-2.wasabisys.com")
|
||||
assert.equal(getWasabiEndpoint(" EU-CENTRAL-1 "), "https://s3.eu-central-1.wasabisys.com")
|
||||
assert.equal(getWasabiEndpoint("future-region-1"), "https://s3.future-region-1.wasabisys.com")
|
||||
assert.equal(getWasabiEndpoint(""), "")
|
||||
assert.ok(WASABI_REGIONS.includes("us-east-1"))
|
||||
})
|
||||
|
||||
test("Wasabi SVG variants are self-contained official marks for light and dark surfaces", () => {
|
||||
const sources = ["public/svg/wasabi.svg", "public/svg/wasabi-dark.svg"].map((path) => fs.readFileSync(path, "utf8"))
|
||||
|
||||
for (const source of sources) {
|
||||
assert.doesNotMatch(source, /<script|\b(?:href|src)=/i)
|
||||
assert.match(source, /fill="#00ce3e"/)
|
||||
assert.match(source, /Official Wasabi logomark path/)
|
||||
}
|
||||
assert.equal(sources[0].match(/<path[^>]*\sd="([^"]+)"/)?.[1], sources[1].match(/<path[^>]*\sd="([^"]+)"/)?.[1])
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs"
|
||||
|
||||
test("tier picker hides Huawei and Tencent without filtering existing rows", () => {
|
||||
const newFormSource = fs.readFileSync("components/tiers/new-form.tsx", "utf8")
|
||||
const configSource = fs.readFileSync("lib/tier-config.ts", "utf8")
|
||||
const pageSource = fs.readFileSync("app/(dashboard)/tiers/page.tsx", "utf8")
|
||||
const hookSource = fs.readFileSync("hooks/use-tiers.ts", "utf8")
|
||||
|
||||
@@ -11,13 +12,73 @@ test("tier picker hides Huawei and Tencent without filtering existing rows", ()
|
||||
assert.equal(newFormSource.includes("huaweiyun.svg"), false)
|
||||
assert.equal(newFormSource.includes("Tencent COS"), false)
|
||||
assert.equal(newFormSource.includes("tenxunyun.svg"), false)
|
||||
assert.equal(newFormSource.includes('labelKey: "Aliyun OSS"'), false)
|
||||
assert.equal(newFormSource.includes('labelKey: "Minio"'), false)
|
||||
assert.equal(newFormSource.includes('labelKey: "Alibaba Cloud"'), true)
|
||||
assert.equal(newFormSource.includes('labelKey: "MinIO"'), true)
|
||||
assert.equal(configSource.includes('labelKey: "Aliyun OSS"'), false)
|
||||
assert.equal(configSource.includes('labelKey: "Minio"'), false)
|
||||
assert.equal(configSource.includes('labelKey: "Alibaba Cloud"'), true)
|
||||
assert.equal(configSource.includes('labelKey: "MinIO"'), true)
|
||||
assert.equal(pageSource.includes("row.huaweicloud"), true)
|
||||
assert.equal(pageSource.includes("row.tencent"), true)
|
||||
assert.equal(hookSource.includes("huaweicloud?: TierConfig"), true)
|
||||
assert.equal(hookSource.includes("tencent?: TierConfig"), true)
|
||||
assert.equal(hookSource.includes("UNSUPPORTED_TIER_TYPES"), false)
|
||||
})
|
||||
|
||||
test("Wasabi is supported by tier rows and all name-based actions", () => {
|
||||
const newFormSource = fs.readFileSync("components/tiers/new-form.tsx", "utf8")
|
||||
const pageSource = fs.readFileSync("app/(dashboard)/tiers/page.tsx", "utf8")
|
||||
const hookSource = fs.readFileSync("hooks/use-tiers.ts", "utf8")
|
||||
|
||||
assert.match(hookSource, /wasabi\?: TierConfig/)
|
||||
assert.match(pageSource, /case "wasabi":\s+return row\.wasabi/)
|
||||
assert.match(pageSource, /getConfig\(row\.original\)/)
|
||||
assert.match(pageSource, /getConfig\(row\)\?\.name/)
|
||||
assert.match(pageSource, /TIER_PROVIDERS\.find/)
|
||||
assert.match(pageSource, /className=\{provider \? undefined : "capitalize"\}/)
|
||||
assert.match(newFormSource, /buildTierPayload\(type,/)
|
||||
assert.match(newFormSource, /selectedOption\?\.labelKey/)
|
||||
})
|
||||
|
||||
test("Wasabi form keeps failures in context and protects duplicate submissions", () => {
|
||||
const source = fs.readFileSync("components/tiers/new-form.tsx", "utf8")
|
||||
|
||||
assert.match(source, /const \[saveError, setSaveError\]/)
|
||||
assert.match(source, /if \(submittingRef\.current\) return/)
|
||||
assert.match(source, /setSaveError\(errorMessage\)/)
|
||||
assert.match(source, /role="alert"/)
|
||||
assert.match(source, /document\.getElementById\(firstErrorId\)\?\.focus\(\)/)
|
||||
const failureHandler = source.slice(source.indexOf("} catch (error)"), source.indexOf("} finally"))
|
||||
assert.doesNotMatch(failureHandler, /resetForm\(\)/)
|
||||
assert.match(source, /grid grid-cols-1 gap-4 md:grid-cols-2/)
|
||||
assert.match(source, /focus-visible:ring-1 focus-visible:ring-ring\/50/)
|
||||
assert.match(source, /setFocusTarget\("name"\)/)
|
||||
assert.match(source, /setFocusTarget\(previousType\)/)
|
||||
assert.match(source, /autoFocus=\{focusTarget === item\.value\}/)
|
||||
assert.match(source, /autoFocus=\{focusTarget === "name"\}/)
|
||||
assert.match(source, /const clearProviderErrors = \(\) =>/)
|
||||
assert.equal(source.match(/clearProviderErrors\(\)/g)?.length, 2)
|
||||
assert.match(source, /aria-describedby="tier-endpoint-preview-description"/)
|
||||
assert.match(source, /<FieldDescription id="tier-endpoint-preview-description">/)
|
||||
})
|
||||
|
||||
test("Wasabi required fields expose native semantics while preserving custom validation", () => {
|
||||
const source = fs.readFileSync("components/tiers/new-form.tsx", "utf8")
|
||||
const requiredFields = ["tier-name", "tier-region", "tier-access-key", "tier-secret-key", "tier-bucket"]
|
||||
|
||||
assert.match(source, /<form[\s\S]*?noValidate/)
|
||||
for (const id of requiredFields) {
|
||||
const controlStart = source.indexOf(`id="${id}"`)
|
||||
const controlEnd = source.indexOf("/>", controlStart)
|
||||
|
||||
assert.notEqual(controlStart, -1, `${id} should exist`)
|
||||
assert.match(source.slice(controlStart, controlEnd), /\brequired(?:=|\s|$)/, `${id} should be marked required`)
|
||||
}
|
||||
})
|
||||
|
||||
test("Lifecycle reads Wasabi aliases dynamically and requires an explicit selection", () => {
|
||||
const source = fs.readFileSync("components/lifecycle/new-form.tsx", "utf8")
|
||||
|
||||
assert.match(source, /item\[item\.type\]/)
|
||||
assert.doesNotMatch(source, /tierOptions\[0\]\.value/)
|
||||
assert.match(source, /selectedTierType === "wasabi"/)
|
||||
assert.doesNotMatch(source, /newRule\.wasabi|payload\.wasabi/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user