feat: add Wasabi tier provider (#173)

This commit is contained in:
cxymds
2026-07-20 10:37:12 +08:00
committed by GitHub
parent 2a78a8075e
commit e1b93972c0
25 changed files with 865 additions and 245 deletions
+11 -1
View File
@@ -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",
+16 -2
View File
@@ -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
View File
@@ -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>
)
+1
View File
@@ -14,6 +14,7 @@ export interface TierConfig {
export interface TierRow {
type: string
wasabi?: TierConfig
rustfs?: TierConfig
minio?: TierConfig
s3?: TierConfig
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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": "Lendpoint est dérivé de la région",
"Endpoint is derived from the region and cannot be edited.": "Lendpoint est dérivé de la région et ne peut pas être modifié.",
"Endpoint preview": "Aperçu de lendpoint",
"Enter or select a region": "Saisissez ou sélectionnez une région",
"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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+91
View File
@@ -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 }
}
+5
View File
@@ -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

+5
View File
@@ -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

+5 -1
View File
@@ -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\]/)
})
+1 -1
View File
@@ -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\}/)
+68
View File
@@ -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])
})
+65 -4
View File
@@ -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/)
})