mirror of
https://github.com/rustfs/console.git
synced 2026-08-30 17:14:47 +08:00
refactor: improve console UI safety and consistency (#160)
This commit is contained in:
@@ -110,6 +110,7 @@ Before committing any code changes, you MUST run and pass:
|
||||
|
||||
## UI Theme Overrides
|
||||
|
||||
- For every Console UI, interaction, settings, form, dialog, table, responsive-layout, or visual-review change, read and follow `skills/rustfs-console-design-guide/SKILL.md` before editing. Use `skills/ui-audit/SKILL.md` as the audit workflow and the Console design guide as the source of design decisions.
|
||||
- Apply visual tweaks (e.g. removing shadows, altering colors) at usage sites via classes such as `class="shadow-none"`.
|
||||
- When extending shadcn components, create wrapper components (e.g. `BucketSelector.tsx`) instead of forking primitives.
|
||||
- Do not change base colors or theme variables defined in `console-new` unless explicitly required by the migration plan.
|
||||
|
||||
+25
-23
@@ -102,29 +102,31 @@ function ConfigPageContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-muted p-4 sm:p-6 lg:p-20">
|
||||
<div className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-muted p-4 sm:p-8 lg:p-12">
|
||||
<Image
|
||||
src={buildRoute("/backgrounds/scillate.svg")}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="absolute inset-0 z-0 opacity-45 object-cover"
|
||||
className="absolute inset-0 z-0 object-cover opacity-30"
|
||||
/>
|
||||
<div className="z-10 mx-auto flex w-full max-w-7xl flex-col overflow-hidden border bg-card shadow-none lg:min-h-[560px] lg:flex-row">
|
||||
<div className="hidden w-1/2 lg:block">
|
||||
<div className="z-10 mx-auto flex w-full max-w-6xl flex-col overflow-hidden border bg-card shadow-none lg:min-h-[600px] lg:flex-row">
|
||||
<div className="hidden lg:block lg:w-5/12">
|
||||
<AuthHeroStatic />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center justify-center bg-card lg:w-1/2">
|
||||
<div className="max-w-sm w-full space-y-6 p-4 sm:p-7">
|
||||
<ThemeLogo width={112} height={24} priority />
|
||||
<div className="py-6">
|
||||
<h1 className="block text-2xl font-bold text-foreground">{t("Server Configuration")}</h1>
|
||||
<div className="flex w-full flex-col items-center justify-center bg-card lg:w-7/12">
|
||||
<div className="w-full max-w-md p-6 sm:p-8 lg:p-10">
|
||||
<ThemeLogo width={112} height={24} className="lg:hidden" priority />
|
||||
<div className="mt-8 lg:mt-0">
|
||||
<h1 className="block font-heading text-2xl font-semibold tracking-tight text-foreground">
|
||||
{t("Server Configuration")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("Please configure your RustFS server address")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<form onSubmit={validateAndSave} autoComplete="off">
|
||||
<div className="mt-8 space-y-4">
|
||||
<form onSubmit={validateAndSave} autoComplete="off" aria-busy={isSaving}>
|
||||
<div className="grid gap-y-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="serverHost">{t("Server Address")}</FieldLabel>
|
||||
@@ -138,6 +140,7 @@ function ConfigPageContent() {
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-11 text-base sm:h-8 sm:text-xs"
|
||||
placeholder={t("Please enter server address (e.g., http://localhost:9000)")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -147,14 +150,18 @@ function ConfigPageContent() {
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button type="submit" className="w-full sm:flex-1" disabled={isSaving}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 w-full text-sm sm:h-8 sm:flex-1 sm:text-xs"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("Save Configuration")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
className="h-11 w-full text-sm sm:h-8 sm:w-auto sm:text-xs"
|
||||
onClick={resetToCurrentHost}
|
||||
disabled={isSaving}
|
||||
>
|
||||
@@ -163,8 +170,8 @@ function ConfigPageContent() {
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
variant="ghost"
|
||||
className="h-11 w-full text-sm sm:h-8 sm:w-auto sm:text-xs"
|
||||
onClick={skipConfig}
|
||||
disabled={isSaving}
|
||||
>
|
||||
@@ -175,20 +182,15 @@ function ConfigPageContent() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="my-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="mt-8 flex items-center justify-between gap-4 border-t pt-5">
|
||||
<p className="min-w-0 text-sm text-muted-foreground">
|
||||
{t("Need help?")}{" "}
|
||||
<Link href={docs} className="text-foreground underline-offset-4 hover:underline">
|
||||
{t("View Documentation")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-1/2 items-center justify-around gap-4">
|
||||
<div className="inline-flex">
|
||||
<div className="flex shrink-0 items-center gap-1 [&_[data-slot=button]]:size-10 sm:[&_[data-slot=button]]:size-8">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div className="inline-flex">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main id="main-content" tabIndex={-1} className="min-h-screen min-w-0">
|
||||
<main id="main-content" tabIndex={-1} className="min-h-dvh min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export interface BackendInfoItem {
|
||||
icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>
|
||||
@@ -11,26 +11,29 @@ export interface BackendInfoItem {
|
||||
export function PerformanceBackendCard({ items, t }: { items: BackendInfoItem[]; t: (key: string) => string }) {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{items.length ? t("Backend Services") : ""}</CardTitle>
|
||||
<CardDescription>
|
||||
{items.length ? t("Key services and configuration values reported by the cluster.") : ""}
|
||||
</CardDescription>
|
||||
<CardHeader className="pb-3">
|
||||
<h2 id="storage-configuration-title" className="text-base font-semibold">
|
||||
{t("Storage Configuration")}
|
||||
</h2>
|
||||
<CardDescription>{t("Parity and backend settings reported by the cluster.")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<dl className="grid gap-0 border-y md:grid-cols-3" aria-labelledby="storage-configuration-title">
|
||||
{items.map((item) => (
|
||||
<Card key={item.title} className="border bg-muted/40 shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{item.title}</CardTitle>
|
||||
<item.icon className="size-5 text-muted-foreground" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xl font-semibold text-foreground">{item.value ?? "-"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div
|
||||
key={item.title}
|
||||
className="flex min-w-0 items-center gap-3 border-b py-4 last:border-b-0 md:border-e md:border-b-0 md:px-4 md:first:ps-0 md:last:border-e-0 md:last:pe-0"
|
||||
>
|
||||
<item.icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs text-muted-foreground">{item.title}</dt>
|
||||
<dd className="mt-1 break-words text-base font-semibold text-foreground">
|
||||
{item.value ?? t("Unknown")}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,72 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
|
||||
|
||||
function HealthMetric({ label, value, unavailableLabel }: { label: string; value?: number; unavailableLabel: string }) {
|
||||
return (
|
||||
<div className="min-w-0 border-t pt-3">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-1 text-lg font-semibold tabular-nums">{value ?? unavailableLabel}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PerformanceInfrastructureCard({
|
||||
onlineServers,
|
||||
offlineServers,
|
||||
unknownServers,
|
||||
degradedServers,
|
||||
initializingServers,
|
||||
unknownServers,
|
||||
onlineDisks,
|
||||
offlineDisks,
|
||||
unknownDisks,
|
||||
t,
|
||||
}: {
|
||||
onlineServers: number
|
||||
offlineServers: number
|
||||
unknownServers: number
|
||||
degradedServers: number
|
||||
onlineDisks: number
|
||||
offlineDisks: number
|
||||
unknownDisks: number
|
||||
onlineServers?: number
|
||||
offlineServers?: number
|
||||
degradedServers?: number
|
||||
initializingServers?: number
|
||||
unknownServers?: number
|
||||
onlineDisks?: number
|
||||
offlineDisks?: number
|
||||
unknownDisks?: number
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
const needsAttention = Boolean((offlineServers ?? 0) + (degradedServers ?? 0) + (offlineDisks ?? 0))
|
||||
const hasUnknownState = Boolean((initializingServers ?? 0) + (unknownServers ?? 0) + (unknownDisks ?? 0))
|
||||
const serverCounts = [onlineServers, offlineServers, degradedServers, initializingServers, unknownServers]
|
||||
const diskCounts = [onlineDisks, offlineDisks, unknownDisks]
|
||||
const dataUnavailable =
|
||||
serverCounts.some((value) => value === undefined) ||
|
||||
diskCounts.some((value) => value === undefined) ||
|
||||
serverCounts.reduce<number>((total, value) => total + (value ?? 0), 0) === 0 ||
|
||||
diskCounts.reduce<number>((total, value) => total + (value ?? 0), 0) === 0
|
||||
const status = needsAttention
|
||||
? t("Needs attention")
|
||||
: hasUnknownState || dataUnavailable
|
||||
? t("Unknown")
|
||||
: t("Healthy")
|
||||
const description = needsAttention
|
||||
? t("Offline or degraded resources require attention.")
|
||||
: hasUnknownState || dataUnavailable
|
||||
? t("Some resource health data is unavailable or still initializing.")
|
||||
: t("All reported servers and disks are online.")
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<Card className="h-full shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t("Infrastructure Health")}</CardTitle>
|
||||
<CardDescription>{t("Real-time status of cluster servers and backend storage devices.")}</CardDescription>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<h2 id="cluster-health-title" className="text-base font-semibold">
|
||||
{t("Cluster Health")}
|
||||
</h2>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant={needsAttention ? "destructive" : hasUnknownState || dataUnavailable ? "outline" : "secondary"}
|
||||
aria-live="polite"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="border bg-muted/40 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("Servers")}</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Online")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{onlineServers}</p>
|
||||
</div>
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Offline")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{offlineServers}</p>
|
||||
</div>
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Unknown")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{unknownServers}</p>
|
||||
</div>
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Degraded")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{degradedServers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border bg-muted/40 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("Disks")}</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Online")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{onlineDisks}</p>
|
||||
</div>
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Offline")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{offlineDisks}</p>
|
||||
</div>
|
||||
<div className="border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("Unknown")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{unknownDisks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2" aria-labelledby="cluster-health-title">
|
||||
<section aria-labelledby="server-health-title">
|
||||
<h3 id="server-health-title" className="text-sm font-medium text-muted-foreground">
|
||||
{t("Servers")}
|
||||
</h3>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<HealthMetric label={t("Online")} value={onlineServers} unavailableLabel={t("Unavailable")} />
|
||||
<HealthMetric label={t("Offline")} value={offlineServers} unavailableLabel={t("Unavailable")} />
|
||||
{degradedServers ? (
|
||||
<HealthMetric label={t("Degraded")} value={degradedServers} unavailableLabel={t("Unavailable")} />
|
||||
) : null}
|
||||
{initializingServers ? (
|
||||
<HealthMetric
|
||||
label={t("Initializing")}
|
||||
value={initializingServers}
|
||||
unavailableLabel={t("Unavailable")}
|
||||
/>
|
||||
) : null}
|
||||
{unknownServers ? (
|
||||
<HealthMetric label={t("Unknown")} value={unknownServers} unavailableLabel={t("Unavailable")} />
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-labelledby="disk-health-title">
|
||||
<h3 id="disk-health-title" className="text-sm font-medium text-muted-foreground">
|
||||
{t("Disks")}
|
||||
</h3>
|
||||
<dl className="mt-3 grid grid-cols-3 gap-x-4 gap-y-3">
|
||||
<HealthMetric label={t("Online")} value={onlineDisks} unavailableLabel={t("Unavailable")} />
|
||||
<HealthMetric label={t("Offline")} value={offlineDisks} unavailableLabel={t("Unavailable")} />
|
||||
{unknownDisks ? (
|
||||
<HealthMetric label={t("Unknown")} value={unknownDisks} unavailableLabel={t("Unavailable")} />
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,69 +2,89 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import type { ServerInfo } from "@/hooks/use-performance-data"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
type Translate = (key: string) => string
|
||||
|
||||
function countOnlineDrives(server: ServerInfo, type: string) {
|
||||
return (server?.drives || []).filter((d) => d.state === type).length
|
||||
function countOnlineDrives(server: ServerInfo) {
|
||||
return (server.drives ?? []).filter((drive) => {
|
||||
const state = drive.state?.toLowerCase()
|
||||
return state === "ok" || state === "online"
|
||||
}).length
|
||||
}
|
||||
|
||||
function countOnlineNetworks(server: ServerInfo, type: string) {
|
||||
return Object.values(server?.network || {}).filter((state) => state === type).length
|
||||
function countOnlineNetworks(server: ServerInfo) {
|
||||
return Object.values(server.network ?? {}).filter((state) => state.toLowerCase() === "online").length
|
||||
}
|
||||
|
||||
type PerformanceServerSort =
|
||||
| "default"
|
||||
| "endpoint-asc"
|
||||
| "endpoint-desc"
|
||||
| "status-online"
|
||||
| "status-offline"
|
||||
| "status-unknown"
|
||||
| "status-degraded"
|
||||
| "uptime-desc"
|
||||
| "uptime-asc"
|
||||
function getStateLabel(state: ServerHealthState, t: Translate) {
|
||||
switch (state) {
|
||||
case "online":
|
||||
return t("Online")
|
||||
case "offline":
|
||||
return t("Offline")
|
||||
case "degraded":
|
||||
return t("Degraded")
|
||||
case "initializing":
|
||||
return t("Initializing")
|
||||
default:
|
||||
return t("Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
function getStatePriority(state: ServerHealthState) {
|
||||
return { offline: 0, degraded: 1, unknown: 2, initializing: 3, online: 4 }[state]
|
||||
}
|
||||
|
||||
function getStateVariant(state: ServerHealthState): "secondary" | "destructive" | "outline" {
|
||||
if (state === "online") return "secondary"
|
||||
if (state === "offline" || state === "degraded") return "destructive"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | undefined, t: Translate) {
|
||||
if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown")
|
||||
const totalMinutes = Math.floor(seconds / 60)
|
||||
const days = Math.floor(totalMinutes / 1440)
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const parts = [
|
||||
days ? `${days} ${t("Days")}` : "",
|
||||
hours ? `${hours} ${t("Hours")}` : "",
|
||||
!days && minutes ? `${minutes} ${t("Minutes")}` : "",
|
||||
].filter(Boolean)
|
||||
return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}`
|
||||
}
|
||||
|
||||
function formatBytes(value: number | undefined, t: Translate) {
|
||||
return value === undefined || !Number.isFinite(value) || value < 0 ? t("Unknown") : niceBytes(String(value))
|
||||
}
|
||||
|
||||
function getDriveUsagePercent(drive: NonNullable<ServerInfo["drives"]>[number]) {
|
||||
if (!Number.isFinite(drive.totalspace) || !drive.totalspace || drive.totalspace <= 0) return undefined
|
||||
if (!Number.isFinite(drive.usedspace) || drive.usedspace === undefined || drive.usedspace < 0) return undefined
|
||||
const used = drive.usedspace
|
||||
return Math.min(100, Math.max(0, (used / drive.totalspace) * 100))
|
||||
}
|
||||
|
||||
function getDriveState(drive: NonNullable<ServerInfo["drives"]>[number]) {
|
||||
const state = drive.state?.toLowerCase()
|
||||
if (state === "ok" || state === "online") return "online" as const
|
||||
if (state === "offline" || state === "faulty" || state === "error") return "offline" as const
|
||||
return "unknown" as const
|
||||
}
|
||||
|
||||
type PerformanceServerSort = "attention" | "endpoint-asc" | "endpoint-desc" | "uptime-desc" | "uptime-asc"
|
||||
type PerformanceServerFilter = "all" | ServerHealthState
|
||||
|
||||
const serverStates: ServerHealthState[] = ["online", "offline", "unknown", "degraded"]
|
||||
|
||||
function getServerState(server: ServerInfo) {
|
||||
return normalizeServerHealthState(server.state)
|
||||
}
|
||||
|
||||
function getServerStateLabel(state: ServerHealthState, t: (key: string) => string) {
|
||||
if (state === "online") return t("Online")
|
||||
if (state === "offline") return t("Offline")
|
||||
if (state === "degraded") return t("Degraded")
|
||||
return t("Unknown")
|
||||
}
|
||||
|
||||
function getServerStateTone(state: ServerHealthState) {
|
||||
if (state === "online") return "bg-primary"
|
||||
if (state === "offline") return "bg-destructive"
|
||||
if (state === "degraded") return "bg-amber-500"
|
||||
return "bg-muted-foreground"
|
||||
}
|
||||
|
||||
function getServerStateButtonClass(state: ServerHealthState, active: boolean) {
|
||||
if (!active) return "shadow-none"
|
||||
if (state === "offline") return "border-destructive/40 text-destructive shadow-none"
|
||||
if (state === "degraded") return "border-amber-500/50 text-amber-700 shadow-none dark:text-amber-300"
|
||||
if (state === "unknown") return "border-muted-foreground/40 text-muted-foreground shadow-none"
|
||||
return "shadow-none"
|
||||
}
|
||||
|
||||
function compareEndpoint(left: ServerInfo, right: ServerInfo, direction: "asc" | "desc") {
|
||||
const normalizedLeft = left.endpoint?.trim()
|
||||
const normalizedRight = right.endpoint?.trim()
|
||||
@@ -82,8 +102,8 @@ function compareEndpoint(left: ServerInfo, right: ServerInfo, direction: "asc" |
|
||||
}
|
||||
|
||||
function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | "desc") {
|
||||
const hasLeft = Number.isFinite(left.uptime)
|
||||
const hasRight = Number.isFinite(right.uptime)
|
||||
const hasLeft = Number.isFinite(left.uptime) && (left.uptime ?? -1) >= 0
|
||||
const hasRight = Number.isFinite(right.uptime) && (right.uptime ?? -1) >= 0
|
||||
|
||||
if (!hasLeft && !hasRight) return 0
|
||||
if (!hasLeft) return 1
|
||||
@@ -92,31 +112,29 @@ function compareUptime(left: ServerInfo, right: ServerInfo, direction: "asc" | "
|
||||
return direction === "asc" ? left.uptime! - right.uptime! : right.uptime! - left.uptime!
|
||||
}
|
||||
|
||||
export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t: (key: string) => string }) {
|
||||
const [sortBy, setSortBy] = React.useState<PerformanceServerSort>("default")
|
||||
const filterOrder: ServerHealthState[] = ["offline", "degraded", "initializing", "unknown", "online"]
|
||||
|
||||
export function PerformanceServerList({ servers, t }: { servers?: ServerInfo[]; t: Translate }) {
|
||||
const [sortBy, setSortBy] = React.useState<PerformanceServerSort>("attention")
|
||||
const [filterBy, setFilterBy] = React.useState<PerformanceServerFilter>("all")
|
||||
const stateCounts = React.useMemo(
|
||||
() =>
|
||||
servers.reduce(
|
||||
(counts, server) => {
|
||||
counts[getServerState(server)] += 1
|
||||
return counts
|
||||
},
|
||||
{ online: 0, offline: 0, unknown: 0, degraded: 0 } satisfies Record<ServerHealthState, number>,
|
||||
),
|
||||
[servers],
|
||||
)
|
||||
const reportedServers = React.useMemo(() => servers ?? [], [servers])
|
||||
|
||||
const stateCounts = React.useMemo(() => {
|
||||
const counts: Record<ServerHealthState, number> = {
|
||||
online: 0,
|
||||
offline: 0,
|
||||
degraded: 0,
|
||||
initializing: 0,
|
||||
unknown: 0,
|
||||
}
|
||||
for (const server of reportedServers) counts[normalizeServerHealthState(server.state)] += 1
|
||||
return counts
|
||||
}, [reportedServers])
|
||||
|
||||
const visibleServers = React.useMemo(() => {
|
||||
const rows = servers
|
||||
.map((server, originalIndex) => ({
|
||||
server,
|
||||
originalIndex,
|
||||
}))
|
||||
.filter(({ server }) => {
|
||||
if (filterBy !== "all") return getServerState(server) === filterBy
|
||||
return true
|
||||
})
|
||||
const rows = reportedServers
|
||||
.map((server, originalIndex) => ({ server, originalIndex }))
|
||||
.filter(({ server }) => filterBy === "all" || normalizeServerHealthState(server.state) === filterBy)
|
||||
|
||||
return rows.sort((left, right) => {
|
||||
switch (sortBy) {
|
||||
@@ -124,166 +142,206 @@ export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t
|
||||
return compareEndpoint(left.server, right.server, "asc") || left.originalIndex - right.originalIndex
|
||||
case "endpoint-desc":
|
||||
return compareEndpoint(left.server, right.server, "desc") || left.originalIndex - right.originalIndex
|
||||
case "status-online":
|
||||
return (
|
||||
Number(getServerState(right.server) === "online") - Number(getServerState(left.server) === "online") ||
|
||||
compareEndpoint(left.server, right.server, "asc") ||
|
||||
left.originalIndex - right.originalIndex
|
||||
)
|
||||
case "status-offline":
|
||||
return (
|
||||
Number(getServerState(right.server) === "offline") - Number(getServerState(left.server) === "offline") ||
|
||||
compareEndpoint(left.server, right.server, "asc") ||
|
||||
left.originalIndex - right.originalIndex
|
||||
)
|
||||
case "status-unknown":
|
||||
return (
|
||||
Number(getServerState(right.server) === "unknown") - Number(getServerState(left.server) === "unknown") ||
|
||||
compareEndpoint(left.server, right.server, "asc") ||
|
||||
left.originalIndex - right.originalIndex
|
||||
)
|
||||
case "status-degraded":
|
||||
return (
|
||||
Number(getServerState(right.server) === "degraded") - Number(getServerState(left.server) === "degraded") ||
|
||||
compareEndpoint(left.server, right.server, "asc") ||
|
||||
left.originalIndex - right.originalIndex
|
||||
)
|
||||
case "uptime-desc":
|
||||
return compareUptime(left.server, right.server, "desc") || left.originalIndex - right.originalIndex
|
||||
case "uptime-asc":
|
||||
return compareUptime(left.server, right.server, "asc") || left.originalIndex - right.originalIndex
|
||||
case "default":
|
||||
case "attention":
|
||||
default:
|
||||
return left.originalIndex - right.originalIndex
|
||||
return (
|
||||
getStatePriority(normalizeServerHealthState(left.server.state)) -
|
||||
getStatePriority(normalizeServerHealthState(right.server.state)) ||
|
||||
compareEndpoint(left.server, right.server, "asc") ||
|
||||
left.originalIndex - right.originalIndex
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [filterBy, servers, sortBy])
|
||||
}, [filterBy, reportedServers, sortBy])
|
||||
|
||||
const filters: PerformanceServerFilter[] = [
|
||||
"all",
|
||||
...filterOrder.filter((state) => stateCounts[state] > 0 || filterBy === state),
|
||||
]
|
||||
const sortLabels: Record<PerformanceServerSort, string> = {
|
||||
attention: t("Needs attention first"),
|
||||
"endpoint-asc": t("Endpoint, ascending"),
|
||||
"endpoint-desc": t("Endpoint, descending"),
|
||||
"uptime-desc": t("Uptime, longest first"),
|
||||
"uptime-asc": t("Uptime, shortest first"),
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>{t("Server List")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("Inspect individual server health, disk utilization, and network status.")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:items-end">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("Total")}: {visibleServers.length}/{servers.length}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{serverStates.map((state) => (
|
||||
<Button
|
||||
key={state}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filterBy === state ? "secondary" : "outline"}
|
||||
className={getServerStateButtonClass(state, filterBy === state)}
|
||||
onClick={() => setFilterBy((current) => (current === state ? "all" : state))}
|
||||
>
|
||||
{getServerStateLabel(state, t)} ({stateCounts[state]})
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
<span className="text-sm text-muted-foreground">{t("Sort by")}</span>
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as PerformanceServerSort)}>
|
||||
<SelectTrigger className="w-[180px] shadow-none" aria-label={t("Sort by")}>
|
||||
<SelectValue placeholder={t("Default")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("Default")}</SelectItem>
|
||||
<SelectItem value="endpoint-asc">{`${t("Endpoint")} ↑`}</SelectItem>
|
||||
<SelectItem value="endpoint-desc">{`${t("Endpoint")} ↓`}</SelectItem>
|
||||
<SelectItem value="status-online">{`${t("Status")} · ${t("Online")}`}</SelectItem>
|
||||
<SelectItem value="status-offline">{`${t("Status")} · ${t("Offline")}`}</SelectItem>
|
||||
<SelectItem value="status-unknown">{`${t("Status")} · ${t("Unknown")}`}</SelectItem>
|
||||
<SelectItem value="status-degraded">{`${t("Status")} · ${t("Degraded")}`}</SelectItem>
|
||||
<SelectItem value="uptime-desc">{`${t("Uptime")} ↓`}</SelectItem>
|
||||
<SelectItem value="uptime-asc">{`${t("Uptime")} ↑`}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<h2 id="server-list-title" className="text-base font-semibold">
|
||||
{t("Server List")}
|
||||
</h2>
|
||||
<CardDescription>
|
||||
{t("Inspect individual server health, disk utilization, and network status.")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{servers !== undefined ? (
|
||||
<span className="text-sm text-muted-foreground" role="status" aria-live="polite">
|
||||
{t("Total")}: {visibleServers.length}/{reportedServers.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{servers !== undefined && reportedServers.length ? (
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap" role="group" aria-label={t("Filter servers")}>
|
||||
{filters.map((filter) => {
|
||||
const selected = filterBy === filter
|
||||
const label = filter === "all" ? t("All") : getStateLabel(filter, t)
|
||||
const count = filter === "all" ? reportedServers.length : stateCounts[filter]
|
||||
return (
|
||||
<Button
|
||||
key={filter}
|
||||
type="button"
|
||||
variant={selected ? "secondary" : "outline"}
|
||||
className={cn(
|
||||
"min-h-11 whitespace-normal shadow-none sm:min-h-0",
|
||||
filter === "offline" || filter === "degraded" ? "data-[selected=true]:text-destructive" : "",
|
||||
)}
|
||||
data-selected={selected}
|
||||
aria-pressed={selected}
|
||||
aria-controls="performance-server-list"
|
||||
onClick={() => setFilterBy(filter)}
|
||||
>
|
||||
{label} ({count})
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center">
|
||||
<label htmlFor="performance-server-sort" className="text-sm text-muted-foreground">
|
||||
{t("Sort by")}
|
||||
</label>
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as PerformanceServerSort)}>
|
||||
<SelectTrigger
|
||||
id="performance-server-sort"
|
||||
className="min-h-11 w-full shadow-none sm:min-h-0 sm:w-[230px]"
|
||||
>
|
||||
<SelectValue>{sortLabels[sortBy]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="min-h-11">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Accordion className="space-y-2">
|
||||
{visibleServers.map(({ server, originalIndex }) => (
|
||||
<AccordionItem
|
||||
key={server.endpoint ? `${server.endpoint}-${originalIndex}` : `server-${originalIndex}`}
|
||||
value={server.endpoint ? `${server.endpoint}-${originalIndex}` : `server-${originalIndex}`}
|
||||
>
|
||||
<AccordionTrigger>
|
||||
<div className="flex flex-col gap-2 text-start sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn("inline-flex h-2 w-2 rounded-full", getServerStateTone(getServerState(server)))}
|
||||
/>
|
||||
<span className="font-semibold">{server.endpoint ?? "--"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getServerStateLabel(getServerState(server), t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("Disks")}: {countOnlineDrives(server, "ok")} / {server.drives?.length ?? 0}
|
||||
</span>
|
||||
<span>
|
||||
{t("Network")}: {countOnlineNetworks(server, "online")} /{" "}
|
||||
{Object.keys(server.network ?? {}).length}
|
||||
</span>
|
||||
<span>
|
||||
{t("Uptime")}:{" "}
|
||||
{server.uptime != null ? dayjs().subtract(server.uptime, "second").fromNow() : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="pb-2 text-xs text-muted-foreground">
|
||||
{t("Version")}: {server.version ?? "--"}
|
||||
</p>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex gap-4 pb-2">
|
||||
{(server.drives || []).map((drive) => (
|
||||
<Card key={drive.uuid ?? drive.drive_path ?? drive.path} className="min-w-[260px] shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{drive.drive_path ?? drive.path ?? "--"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{niceBytes(String(drive.usedspace ?? 0))} / {niceBytes(String(drive.totalspace ?? 0))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress
|
||||
value={drive.totalspace ? ((drive.usedspace ?? 0) / drive.totalspace) * 100 : 0}
|
||||
className="mb-3 h-2"
|
||||
/>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
{t("Used")}:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{niceBytes(String(drive.usedspace ?? 0))}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
{t("Available")}:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{niceBytes(String(drive.availspace ?? 0))}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
{servers === undefined || reportedServers.length === 0 ? (
|
||||
<div id="performance-server-list" className="border border-dashed p-8 text-center">
|
||||
<p className="text-sm font-medium">{t("No server information was reported.")}</p>
|
||||
</div>
|
||||
) : visibleServers.length ? (
|
||||
<Accordion id="performance-server-list" className="space-y-2" aria-labelledby="server-list-title">
|
||||
{visibleServers.map(({ server, originalIndex }) => {
|
||||
const state = normalizeServerHealthState(server.state)
|
||||
return (
|
||||
<AccordionItem
|
||||
key={server.endpoint ?? `server-${originalIndex}`}
|
||||
value={server.endpoint ?? `server-${originalIndex}`}
|
||||
>
|
||||
<AccordionTrigger className="min-h-11 py-3">
|
||||
<div className="grid min-w-0 flex-1 gap-2 pe-3 text-start lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant={getStateVariant(state)}>{getStateLabel(state, t)}</Badge>
|
||||
<span className="min-w-0 break-words font-semibold [overflow-wrap:anywhere]">
|
||||
{server.endpoint ?? t("Unknown")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground sm:flex sm:flex-wrap">
|
||||
<span>
|
||||
{t("Disks")}: {countOnlineDrives(server)} / {server.drives?.length ?? 0}
|
||||
</span>
|
||||
<span>
|
||||
{t("Network")}: {countOnlineNetworks(server)} / {Object.keys(server.network ?? {}).length}
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
{t("Uptime")}: {formatUptime(server.uptime, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="pb-3 text-xs text-muted-foreground">
|
||||
{t("Version")}: <span className="break-all">{server.version ?? t("Unknown")}</span>
|
||||
</p>
|
||||
{server.drives?.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{server.drives.map((drive, driveIndex) => {
|
||||
const path = drive.drive_path ?? drive.path ?? t("Unknown")
|
||||
const driveState = getDriveState(drive)
|
||||
const usedPercent = getDriveUsagePercent(drive)
|
||||
return (
|
||||
<Card
|
||||
key={`${drive.uuid ?? drive.drive_path ?? drive.path ?? "drive"}-${driveIndex}`}
|
||||
className="min-w-0 shadow-none"
|
||||
>
|
||||
<CardHeader className="gap-2 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<h4 className="min-w-0 break-all text-sm font-medium">{path}</h4>
|
||||
<Badge variant={getStateVariant(driveState)}>{getStateLabel(driveState, t)}</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs tabular-nums">
|
||||
{formatBytes(drive.usedspace, t)} / {formatBytes(drive.totalspace, t)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Progress
|
||||
value={usedPercent ?? null}
|
||||
aria-label={`${path} ${t("Storage Usage Statistics")}: ${usedPercent === undefined ? t("Unknown") : `${usedPercent.toFixed(0)}%`}`}
|
||||
className="h-2"
|
||||
/>
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("Used")}</dt>
|
||||
<dd className="mt-1 font-medium text-foreground tabular-nums">
|
||||
{formatBytes(drive.usedspace, t)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("Available")}</dt>
|
||||
<dd className="mt-1 font-medium text-foreground tabular-nums">
|
||||
{formatBytes(drive.availspace, t)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{t("No drive information was reported for this server.")}
|
||||
</p>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div id="performance-server-list" className="border border-dashed p-8 text-center">
|
||||
<p className="text-sm font-medium">{t("No servers match the current filter")}</p>
|
||||
<Button type="button" variant="link" className="mt-2 min-h-11" onClick={() => setFilterBy("all")}>
|
||||
{t("Show all servers")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -13,35 +13,44 @@ export interface SummaryMetric {
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function PerformanceSummaryCards({ metrics }: { metrics: SummaryMetric[] }) {
|
||||
export function PerformanceSummaryCards({ metrics, title }: { metrics: SummaryMetric[]; title: string }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{metrics.map((metric) => {
|
||||
const cardContent = (
|
||||
<Card
|
||||
key={metric.label}
|
||||
className={cn("shadow-none", metric.href && "cursor-pointer transition-colors hover:bg-muted/50")}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{metric.label}</CardTitle>
|
||||
<metric.icon className="size-5 text-muted-foreground" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<p className="text-2xl font-semibold text-foreground">{metric.display}</p>
|
||||
{metric.caption ? <p className="shrink-0 text-xs text-muted-foreground">{metric.caption}</p> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return metric.href ? (
|
||||
<Link key={metric.label} href={metric.href} aria-label={metric.label}>
|
||||
{cardContent}
|
||||
</Link>
|
||||
) : (
|
||||
<React.Fragment key={metric.label}>{cardContent}</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section aria-labelledby="inventory-title" className="space-y-3">
|
||||
<h2 id="inventory-title" className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{metrics.map((metric) => {
|
||||
const cardContent = (
|
||||
<Card
|
||||
className={cn("h-full shadow-none", metric.href && "cursor-pointer transition-colors hover:bg-muted/50")}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{metric.label}</CardTitle>
|
||||
<metric.icon className="size-5 text-muted-foreground" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
|
||||
<p className="break-words text-2xl font-semibold text-foreground tabular-nums">{metric.display}</p>
|
||||
{metric.caption ? <p className="text-xs text-muted-foreground">{metric.caption}</p> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return metric.href ? (
|
||||
<Link
|
||||
key={metric.label}
|
||||
href={metric.href}
|
||||
aria-label={`${metric.label}: ${metric.display}`}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{cardContent}
|
||||
</Link>
|
||||
) : (
|
||||
<React.Fragment key={metric.label}>{cardContent}</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RiDatabase2Line } from "@remixicon/react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
|
||||
@@ -11,52 +11,76 @@ export interface UsageStat {
|
||||
}
|
||||
|
||||
export function PerformanceUsageCard({
|
||||
lastUpdatedLabel,
|
||||
totalCapacity,
|
||||
totalUsedCapacity,
|
||||
usedPercent,
|
||||
usageStats,
|
||||
t,
|
||||
}: {
|
||||
lastUpdatedLabel: string
|
||||
totalUsedCapacity: number
|
||||
usedPercent: number
|
||||
totalCapacity?: number
|
||||
totalUsedCapacity?: number
|
||||
usedPercent?: number
|
||||
usageStats: UsageStat[]
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
const availableCapacity =
|
||||
totalCapacity !== undefined && totalUsedCapacity !== undefined
|
||||
? Math.max(totalCapacity - totalUsedCapacity, 0)
|
||||
: undefined
|
||||
const formatCapacity = (value?: number) => (value === undefined ? t("Unknown") : niceBytes(String(value)))
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<Card className="h-full shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{t("Usage Report")}</CardTitle>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("Last Scan Activity")}: {lastUpdatedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription>{t("Monitor overall storage usage and recent scanner activity at a glance.")}</CardDescription>
|
||||
<h2 id="storage-usage-title" className="text-base font-semibold">
|
||||
{t("Storage Usage and Scanner")}
|
||||
</h2>
|
||||
<CardDescription>{t("Review capacity pressure and scanner activity in one place.")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-4" aria-labelledby="storage-usage-title">
|
||||
<div className="flex items-center gap-4">
|
||||
<RiDatabase2Line className="size-6 text-primary" aria-hidden />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{t("Used Capacity")}</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{niceBytes(String(totalUsedCapacity))}</p>
|
||||
<p className="text-2xl font-semibold text-foreground tabular-nums">{formatCapacity(totalUsedCapacity)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<Progress value={usedPercent} className="h-2" />
|
||||
<p className="text-end text-xs text-muted-foreground">{usedPercent.toFixed(0)}%</p>
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={usedPercent ?? null}
|
||||
aria-label={`${t("Used Capacity")}: ${usedPercent === undefined ? t("Unknown") : `${usedPercent.toFixed(0)}%`}`}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-end text-xs text-muted-foreground tabular-nums">
|
||||
{usedPercent === undefined ? t("Unknown") : `${usedPercent.toFixed(0)}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<dl className="grid grid-cols-3 gap-3 border-y py-4">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Used")}</dt>
|
||||
<dd className="mt-1 text-sm font-medium tabular-nums">{formatCapacity(totalUsedCapacity)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Available")}</dt>
|
||||
<dd className="mt-1 text-sm font-medium tabular-nums">{formatCapacity(availableCapacity)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Total Capacity")}</dt>
|
||||
<dd className="mt-1 text-sm font-medium tabular-nums">{formatCapacity(totalCapacity)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl className="grid gap-4 sm:grid-cols-3">
|
||||
{usageStats.map((item) => (
|
||||
<div key={item.label} className="border bg-muted/40 p-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-medium text-foreground">{item.value}</p>
|
||||
<div key={item.label} className="min-w-0">
|
||||
<dt className="text-xs text-muted-foreground">{item.label}</dt>
|
||||
<dd className="mt-1 break-words text-sm font-medium text-foreground">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -191,25 +191,30 @@ export default function AuditTargetPage() {
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search")}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="grid w-full shrink-0 gap-2 sm:grid-cols-[minmax(12rem,1fr)_auto] lg:w-auto">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search")}
|
||||
clearable
|
||||
className="h-11 w-full sm:h-8 sm:w-64"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:justify-end lg:flex-nowrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 w-full sm:h-8 sm:w-auto"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={!canManageTargets}
|
||||
>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Audit Target")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 w-full sm:h-8 sm:w-auto" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!canManageTargets}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Audit Target")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Audit Targets")}</h1>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiArrowLeftLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { BucketInfo } from "@/components/buckets/info"
|
||||
import { BucketList } from "@/components/buckets/list"
|
||||
|
||||
export default function BucketSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -12,15 +16,37 @@ export default function BucketSettingsPage() {
|
||||
|
||||
const bucketName = searchParams.get("bucket") ?? ""
|
||||
|
||||
if (!bucketName) {
|
||||
return (
|
||||
<Page>
|
||||
<BucketList
|
||||
title={<h1 className="text-2xl font-bold">{t("Bucket Settings")}</h1>}
|
||||
emptyDescription={t("Create a bucket to start storing objects.")}
|
||||
getBucketHref={(name) => `/buckets?bucket=${encodeURIComponent(name)}`}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("Bucket")}: {bucketName || "…"}
|
||||
</h1>
|
||||
<PageHeader
|
||||
description={
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("Bucket")}: <span className="break-all font-medium text-foreground">{bucketName}</span>
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/browser" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Bucket Settings")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
{bucketName ? <BucketInfo bucketName={bucketName} /> : null}
|
||||
<BucketInfo key={bucketName} bucketName={bucketName} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,25 +191,30 @@ export default function EventsTargetPage() {
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search")}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="grid w-full shrink-0 gap-2 sm:grid-cols-[minmax(12rem,1fr)_auto] lg:w-auto">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search")}
|
||||
clearable
|
||||
className="h-11 w-full sm:h-8 sm:w-64"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:justify-end lg:flex-nowrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 w-full sm:h-8 sm:w-auto"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={!canManageDestinations}
|
||||
>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Destination")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 w-full sm:h-8 sm:w-auto" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!canManageDestinations}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Destination")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Event Destinations")}</h1>
|
||||
|
||||
@@ -30,20 +30,31 @@ export default function EventsPage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/events" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("Events")}: {bucketName}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<BucketEventsTab bucketName={bucketName} hideTitle />
|
||||
<BucketEventsTab
|
||||
key={bucketName}
|
||||
bucketName={bucketName}
|
||||
hideTitle
|
||||
renderHeader={(actions) => (
|
||||
<PageHeader
|
||||
description={
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("Bucket")}: <span className="break-all font-medium text-foreground">{bucketName}</span>
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/events" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
{actions}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Events")}</h1>
|
||||
</PageHeader>
|
||||
)}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ImportExportPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Page className="max-w-5xl">
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("Import/Export")}</h1>
|
||||
</PageHeader>
|
||||
@@ -146,9 +146,9 @@ export default function ImportExportPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{exportHighlights.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3 border bg-muted/40 p-3">
|
||||
<div key={item.label} className="flex items-center gap-3 bg-muted/30 p-3">
|
||||
<item.icon className={`size-5 ${item.iconClass}`} aria-hidden />
|
||||
<span className="text-sm font-medium text-foreground">{t(item.label)}</span>
|
||||
</div>
|
||||
@@ -163,11 +163,17 @@ export default function ImportExportPage() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardFooter className="flex flex-col items-stretch gap-3 text-start sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("Download complete IAM configuration as ZIP file")}
|
||||
</div>
|
||||
<Button variant="default" size="lg" disabled={isLoading} onClick={handleExportIam}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isLoading}
|
||||
onClick={handleExportIam}
|
||||
>
|
||||
<RiDownload2Line className="size-4" aria-hidden />
|
||||
<span>{isLoading ? t("Exporting…") : t("Export Now")}</span>
|
||||
</Button>
|
||||
@@ -189,38 +195,46 @@ export default function ImportExportPage() {
|
||||
{t("Only ZIP files are supported, and file size should not exceed 10MB")}
|
||||
</p>
|
||||
</UploadZone>
|
||||
{uploadError && <p className="text-sm text-destructive">{uploadError}</p>}
|
||||
{uploadError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedFile && (
|
||||
<Card className="border-dashed bg-muted/30 shadow-none">
|
||||
<CardContent className="flex items-start justify-between gap-3 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
aria-label={t("Remove")}
|
||||
onClick={clearSelectedFile}
|
||||
>
|
||||
<RiCloseLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-start justify-between gap-3 bg-muted/30 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-all text-sm font-medium text-foreground">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-destructive"
|
||||
aria-label={t("Remove")}
|
||||
onClick={clearSelectedFile}
|
||||
>
|
||||
<RiCloseLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<CardFooter className="flex flex-col items-stretch gap-3 text-start sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1 break-all text-sm text-muted-foreground">
|
||||
{selectedFile
|
||||
? t("Ready to import: {filename}", {
|
||||
filename: selectedFile.name,
|
||||
})
|
||||
: t("Please select a ZIP file to import")}
|
||||
</div>
|
||||
<Button variant="default" size="lg" disabled={isLoading || !selectedFile} onClick={handleImportIam}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isLoading || !selectedFile}
|
||||
onClick={handleImportIam}
|
||||
>
|
||||
<RiUpload2Line className="size-4" aria-hidden />
|
||||
<span>{isLoading ? t("Importing…") : t("Import Now")}</span>
|
||||
</Button>
|
||||
|
||||
@@ -30,20 +30,31 @@ export default function LifecyclePage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/lifecycle" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("Lifecycle")}: {bucketName}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<BucketLifecycleTab bucketName={bucketName} hideTitle />
|
||||
<BucketLifecycleTab
|
||||
key={bucketName}
|
||||
bucketName={bucketName}
|
||||
hideTitle
|
||||
renderHeader={(actions) => (
|
||||
<PageHeader
|
||||
description={
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("Bucket")}: <span className="break-all font-medium text-foreground">{bucketName}</span>
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/lifecycle" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
{actions}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Lifecycle")}</h1>
|
||||
</PageHeader>
|
||||
)}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ export default function OidcPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
loading={loading}
|
||||
|
||||
@@ -12,8 +12,9 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { usePoolOperations } from "@/hooks/use-pool-operations"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
|
||||
import {
|
||||
@@ -141,7 +142,15 @@ function canStartDecommission(row: PoolRow, selectionLocked: boolean, activePool
|
||||
return row.displayState === "ready" && !selectionLocked && isActivePool(row.pool) && activePoolCount > 1
|
||||
}
|
||||
|
||||
function UsageMeter({ value, tone = "primary" }: { value: number; tone?: "primary" | "destructive" | "muted" }) {
|
||||
function UsageMeter({
|
||||
value,
|
||||
label,
|
||||
tone = "primary",
|
||||
}: {
|
||||
value: number
|
||||
label: string
|
||||
tone?: "primary" | "destructive" | "muted"
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-32 items-center gap-3">
|
||||
<Progress
|
||||
@@ -151,6 +160,7 @@ function UsageMeter({ value, tone = "primary" }: { value: number; tone?: "primar
|
||||
tone === "destructive" && "[&_[data-slot=progress-indicator]]:bg-destructive",
|
||||
tone === "muted" && "[&_[data-slot=progress-indicator]]:bg-muted-foreground",
|
||||
)}
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">{Math.round(value)}%</span>
|
||||
</div>
|
||||
@@ -159,6 +169,7 @@ function UsageMeter({ value, tone = "primary" }: { value: number; tone?: "primar
|
||||
|
||||
export default function PoolDecommissionPage() {
|
||||
const { t } = useTranslation()
|
||||
const dialog = useDialog()
|
||||
const message = useMessage()
|
||||
const {
|
||||
getPoolsOverview,
|
||||
@@ -169,10 +180,11 @@ export default function PoolDecommissionPage() {
|
||||
clearDecommission,
|
||||
} = usePoolOperations()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [dataReady, setDataReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedPoolId, setSelectedPoolId] = useState("")
|
||||
const [confirmingPoolId, setConfirmingPoolId] = useState("")
|
||||
const [overview, setOverview] = useState<PoolsOverview>({
|
||||
pools: [] as PoolSummary[],
|
||||
totalCapacity: 0,
|
||||
@@ -184,9 +196,15 @@ export default function PoolDecommissionPage() {
|
||||
const [statuses, setStatuses] = useState<Record<string, DecommissionInfo | null>>({})
|
||||
const [rebalanceState, setRebalanceState] = useState<RebalanceDisplayState>("idle")
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const requestVersionRef = useRef(0)
|
||||
const mutationRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const shouldPollRef = useRef(false)
|
||||
const interactionLockedRef = useRef(true)
|
||||
const poolRowsRef = useRef<PoolRow[]>([])
|
||||
|
||||
const clearPoll = () => {
|
||||
if (pollRef.current) {
|
||||
if (pollRef.current !== null) {
|
||||
window.clearTimeout(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
@@ -194,35 +212,53 @@ export default function PoolDecommissionPage() {
|
||||
|
||||
const loadData = useCallback(
|
||||
async (showSpinner = true) => {
|
||||
const requestId = ++requestVersionRef.current
|
||||
if (showSpinner) setLoading(true)
|
||||
else setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [nextOverview, rebalanceStatus] = await Promise.all([
|
||||
const [nextOverview, rebalanceStatus, decommissionStatuses] = await Promise.all([
|
||||
getPoolsOverview(),
|
||||
getRebalanceStatus().catch(() => null),
|
||||
getRebalanceStatus(),
|
||||
getDecommissionStatuses(),
|
||||
])
|
||||
if (!mountedRef.current || requestId !== requestVersionRef.current) return false
|
||||
const nextRebalanceState = deriveRebalanceDisplayState(rebalanceStatus, nextOverview.supportState)
|
||||
const decommissionStatuses = await getDecommissionStatuses().catch(() => [])
|
||||
const statusEntries = decommissionStatuses.map((status) => [status.poolId, status])
|
||||
|
||||
setOverview(nextOverview)
|
||||
setStatuses(Object.fromEntries(statusEntries) as Record<string, DecommissionInfo | null>)
|
||||
setRebalanceState(nextRebalanceState)
|
||||
if (!selectedPoolId && nextOverview.pools[0]?.id) {
|
||||
setSelectedPoolId(nextOverview.pools[0].id)
|
||||
}
|
||||
setSelectedPoolId((current) =>
|
||||
current && nextOverview.pools.some((pool) => pool.id === current)
|
||||
? current
|
||||
: (nextOverview.pools[0]?.id ?? ""),
|
||||
)
|
||||
setDataReady(true)
|
||||
return true
|
||||
} catch (loadError) {
|
||||
if (!mountedRef.current || requestId !== requestVersionRef.current) return false
|
||||
setError((loadError as Error).message || t("Load Failed"))
|
||||
setDataReady(false)
|
||||
return false
|
||||
} finally {
|
||||
if (showSpinner) setLoading(false)
|
||||
if (mountedRef.current && requestId === requestVersionRef.current) {
|
||||
if (showSpinner) setLoading(false)
|
||||
else setRefreshing(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDecommissionStatuses, getPoolsOverview, getRebalanceStatus, selectedPoolId, t],
|
||||
[getDecommissionStatuses, getPoolsOverview, getRebalanceStatus, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
void loadData()
|
||||
return clearPoll
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
requestVersionRef.current += 1
|
||||
clearPoll()
|
||||
}
|
||||
}, [loadData])
|
||||
|
||||
const poolRows = useMemo(
|
||||
@@ -232,15 +268,10 @@ export default function PoolDecommissionPage() {
|
||||
return {
|
||||
pool,
|
||||
status: rowStatus,
|
||||
displayState: getPoolDisplayState(
|
||||
rowStatus,
|
||||
overview.supportState,
|
||||
rebalanceState,
|
||||
confirmingPoolId === pool.id,
|
||||
),
|
||||
displayState: getPoolDisplayState(rowStatus, overview.supportState, rebalanceState, false),
|
||||
}
|
||||
}),
|
||||
[confirmingPoolId, overview.pools, overview.supportState, rebalanceState, statuses],
|
||||
[overview.pools, overview.supportState, rebalanceState, statuses],
|
||||
)
|
||||
|
||||
const activeTask = poolRows.find((row) => isTaskLocked(row))
|
||||
@@ -251,67 +282,218 @@ export default function PoolDecommissionPage() {
|
||||
(selectedRow && hasDecommissionProgress(selectedRow.displayState) && selectedRow.status ? selectedRow : undefined)
|
||||
const selectionLocked = isTaskLocked(activeTask)
|
||||
const activePoolCount = poolRows.filter((row) => isActivePool(row.pool)).length
|
||||
const selectedPoolName = selectedRow?.pool.name ?? "--"
|
||||
const trackedProgress = trackedTask ? getProgressPercent(trackedTask.status, trackedTask.pool) : 0
|
||||
const interactionLocked = loading || refreshing || submitting || Boolean(error) || !dataReady
|
||||
|
||||
useEffect(() => {
|
||||
shouldPollRef.current = dataReady && poolRows.some((row) => shouldPoll(row.displayState))
|
||||
poolRowsRef.current = poolRows
|
||||
}, [dataReady, poolRows])
|
||||
|
||||
useEffect(() => {
|
||||
interactionLockedRef.current = interactionLocked
|
||||
}, [interactionLocked])
|
||||
|
||||
useEffect(() => {
|
||||
clearPoll()
|
||||
if (!poolRows.some((row) => shouldPoll(row.displayState))) return
|
||||
pollRef.current = window.setTimeout(() => {
|
||||
void loadData(false)
|
||||
}, POLL_MS)
|
||||
return clearPoll
|
||||
}, [loadData, poolRows])
|
||||
if (!shouldPollRef.current) return
|
||||
let cancelled = false
|
||||
|
||||
const scheduleNextPoll = async () => {
|
||||
const succeeded = await loadData(false)
|
||||
if (!cancelled && succeeded && shouldPollRef.current) {
|
||||
pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearPoll()
|
||||
}
|
||||
}, [dataReady, loadData, poolRows])
|
||||
|
||||
const handleStart = async (poolId: string) => {
|
||||
if (mutationRef.current) return
|
||||
const currentRows = poolRowsRef.current
|
||||
const currentRow = currentRows.find((row) => row.pool.id === poolId)
|
||||
if (
|
||||
interactionLockedRef.current ||
|
||||
!currentRow ||
|
||||
!canStartDecommission(
|
||||
currentRow,
|
||||
isTaskLocked(currentRows.find((row) => isTaskLocked(row))),
|
||||
currentRows.filter((row) => isActivePool(row.pool)).length,
|
||||
)
|
||||
)
|
||||
return
|
||||
mutationRef.current = true
|
||||
requestVersionRef.current += 1
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await startDecommission(poolId)
|
||||
setConfirmingPoolId("")
|
||||
const nextStatus = await startDecommission(poolId)
|
||||
if (!mountedRef.current) return
|
||||
setStatuses((current) => ({ ...current, [poolId]: nextStatus }))
|
||||
setSelectedPoolId(poolId)
|
||||
message.success(t("Pool decommission started"))
|
||||
await loadData(false)
|
||||
} catch (startError) {
|
||||
message.error((startError as Error).message || t("Failed to start decommission"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
mutationRef.current = false
|
||||
if (mountedRef.current) setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (poolId: string) => {
|
||||
if (mutationRef.current) return
|
||||
const currentRow = poolRowsRef.current.find((row) => row.pool.id === poolId)
|
||||
if (interactionLockedRef.current || currentRow?.displayState !== "running") return
|
||||
mutationRef.current = true
|
||||
requestVersionRef.current += 1
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await cancelDecommission(poolId)
|
||||
const nextStatus = await cancelDecommission(poolId)
|
||||
if (!mountedRef.current) return
|
||||
setStatuses((current) => ({ ...current, [poolId]: nextStatus }))
|
||||
setSelectedPoolId(poolId)
|
||||
message.success(t("Pool decommission cancel requested"))
|
||||
await loadData(false)
|
||||
} catch (cancelError) {
|
||||
message.error((cancelError as Error).message || t("Failed to cancel decommission"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
mutationRef.current = false
|
||||
if (mountedRef.current) setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async (poolId: string) => {
|
||||
if (mutationRef.current) return
|
||||
const currentRow = poolRowsRef.current.find((row) => row.pool.id === poolId)
|
||||
if (interactionLockedRef.current || !currentRow || !canClearDecommission(currentRow)) return
|
||||
mutationRef.current = true
|
||||
requestVersionRef.current += 1
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await clearDecommission(poolId)
|
||||
if (selectedPoolId === poolId) setConfirmingPoolId("")
|
||||
const nextStatus = await clearDecommission(poolId)
|
||||
if (!mountedRef.current) return
|
||||
setStatuses((current) => ({ ...current, [poolId]: nextStatus }))
|
||||
message.success(t("Decommission cleared"))
|
||||
await loadData(false)
|
||||
} catch (clearError) {
|
||||
message.error((clearError as Error).message || t("Failed to clear decommission"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
mutationRef.current = false
|
||||
if (mountedRef.current) setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmStart = (row: PoolRow) => {
|
||||
dialog.warning({
|
||||
title: t("Review before decommission"),
|
||||
content: `${t("This action retires the selected pool and should be used only after verifying rebalance has completed.")} ${t("Selected Pool")}: ${row.pool.name}`,
|
||||
positiveText: t("Start Decommission"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => handleStart(row.pool.id),
|
||||
})
|
||||
}
|
||||
|
||||
const confirmCancel = (row: PoolRow) => {
|
||||
dialog.warning({
|
||||
title: t("Pool Decommission"),
|
||||
content: `${t("Selected Pool")}: ${row.pool.name}`,
|
||||
positiveText: t("Cancel"),
|
||||
negativeText: t("Close"),
|
||||
onPositiveClick: () => handleCancel(row.pool.id),
|
||||
})
|
||||
}
|
||||
|
||||
const confirmClear = (row: PoolRow) => {
|
||||
dialog.error({
|
||||
title: t("Clear Decommission"),
|
||||
content: `${t("Selected Pool")}: ${row.pool.name}`,
|
||||
positiveText: t("Clear Decommission"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => handleClear(row.pool.id),
|
||||
})
|
||||
}
|
||||
|
||||
const renderPoolAction = (row: PoolRow, fullWidth = false) => {
|
||||
const className = fullWidth ? "min-h-10 w-full" : undefined
|
||||
if (row.displayState === "ready") {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={className}
|
||||
disabled={!canStartDecommission(row, selectionLocked, activePoolCount) || interactionLocked}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setSelectedPoolId(row.pool.id)
|
||||
confirmStart(row)
|
||||
}}
|
||||
>
|
||||
<RiPlayCircleLine className="size-4" aria-hidden />
|
||||
{t("Start Decommission")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (row.displayState === "running") {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={className}
|
||||
disabled={interactionLocked}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
confirmCancel(row)
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (canClearDecommission(row)) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className={className}
|
||||
disabled={interactionLocked}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
confirmClear(row)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
{t("Clear Decommission")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground">--</span>
|
||||
}
|
||||
|
||||
const initialLoading = loading && !overview.pools.length && !error
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading || submitting}>
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading || refreshing || submitting}>
|
||||
{loading || refreshing ? (
|
||||
<Spinner className="me-2 size-4" />
|
||||
) : (
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
)}
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
}
|
||||
@@ -320,12 +502,32 @@ export default function PoolDecommissionPage() {
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Load Failed")}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{error}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadData()}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{initialLoading ? (
|
||||
<div className="flex min-h-64 items-center justify-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-5" aria-hidden />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trackedTask ? (
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Card className="rounded-none" aria-busy={refreshing || submitting}>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle>{t("Current Pool Decommission Status")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{trackedTask.pool.name}</p>
|
||||
<p className="break-all text-sm text-muted-foreground">{trackedTask.pool.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getPoolStatusBadgeVariant(trackedTask.displayState)}>
|
||||
@@ -333,8 +535,9 @@ export default function PoolDecommissionPage() {
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleCancel(trackedTask.pool.id)}
|
||||
disabled={trackedTask.displayState !== "running" || submitting}
|
||||
className="min-h-10 w-full sm:w-auto"
|
||||
onClick={() => confirmCancel(trackedTask)}
|
||||
disabled={trackedTask.displayState !== "running" || interactionLocked}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
@@ -342,7 +545,11 @@ export default function PoolDecommissionPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Progress value={trackedProgress} className="flex-1" />
|
||||
<Progress
|
||||
value={trackedProgress}
|
||||
className="flex-1"
|
||||
aria-label={`${trackedTask.pool.name} ${t("Progress")}`}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm font-semibold tabular-nums">
|
||||
{Math.round(trackedProgress)}%
|
||||
</span>
|
||||
@@ -382,31 +589,31 @@ export default function PoolDecommissionPage() {
|
||||
<div className="grid gap-3 border-t pt-4 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Current Object")}</p>
|
||||
<p className="truncate font-medium">{getCurrentObject(trackedTask.status)}</p>
|
||||
<p className="break-all font-medium">{getCurrentObject(trackedTask.status)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("State")}</p>
|
||||
<p className="truncate font-medium">
|
||||
<p className="break-words font-medium">
|
||||
{trackedTask.status?.stage || trackedTask.status?.status || "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Waiting Reason")}</p>
|
||||
<p className="truncate font-medium">{trackedTask.status?.waitingReason || "--"}</p>
|
||||
<p className="break-words font-medium">{trackedTask.status?.waitingReason || "--"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{overview.supportState === "unsupported" ? (
|
||||
{dataReady && overview.supportState === "unsupported" ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Single pool decommission is not supported")}</AlertTitle>
|
||||
<AlertDescription>{t("Decommission requires more than one pool in the cluster.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isRebalanceBlockingDecommission(rebalanceState) ? (
|
||||
{dataReady && isRebalanceBlockingDecommission(rebalanceState) ? (
|
||||
<Alert>
|
||||
<RiAlertLine className="size-4" aria-hidden />
|
||||
<AlertTitle>{t("Rebalance must complete before decommission")}</AlertTitle>
|
||||
@@ -414,16 +621,9 @@ export default function PoolDecommissionPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Load Failed")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
{overview.pools.length ? (
|
||||
<Card className="rounded-none" aria-busy={refreshing || submitting}>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t("Pool Decommission")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -433,223 +633,202 @@ export default function PoolDecommissionPage() {
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={selectionLocked ? "outline" : "secondary"}>
|
||||
{selectionLocked ? t("Running") : t("Ready")}
|
||||
{!dataReady || error ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{loading ? (
|
||||
{loading && !overview.pools.length ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-14">{t("ID")}</TableHead>
|
||||
<TableHead>{t("Pool")}</TableHead>
|
||||
<TableHead>{t("Status")}</TableHead>
|
||||
<TableHead>{t("Total Capacity")}</TableHead>
|
||||
<TableHead>{t("Used Capacity")}</TableHead>
|
||||
<TableHead>{t("Available")}</TableHead>
|
||||
<TableHead>{t("Usage")}</TableHead>
|
||||
<TableHead>{t("Progress")}</TableHead>
|
||||
<TableHead>{t("Bytes Moved")}</TableHead>
|
||||
<TableHead className="text-end">{t("Actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{poolRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center text-muted-foreground">
|
||||
{t("No Data")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
poolRows.map((row) => {
|
||||
const { pool, status: rowStatus, displayState: rowState } = row
|
||||
const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState)
|
||||
const progressPercent = showProgress ? getProgressPercent(rowStatus, pool) : 0
|
||||
const isSelected = selectedRow?.pool.id === pool.id
|
||||
const canStart = canStartDecommission(row, selectionLocked, activePoolCount) && !submitting
|
||||
const canConfirm = rowState === "confirming" && !submitting
|
||||
const canCancel = rowState === "running" && !submitting
|
||||
const canClear = canClearDecommission(row) && !submitting
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={pool.id}
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
rowState === "running" && "bg-primary/5",
|
||||
rowState === "failed" && "bg-destructive/10",
|
||||
rowState === "canceled" && "bg-muted/60",
|
||||
)}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
onClick={() => {
|
||||
if (selectionLocked) return
|
||||
setSelectedPoolId(pool.id)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
if (selectionLocked) return
|
||||
setSelectedPoolId(pool.id)
|
||||
}}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">{pool.id}</TableCell>
|
||||
<TableCell className="max-w-[340px] truncate font-medium">{pool.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getPoolStatusBadgeVariant(rowState)}>
|
||||
{formatPoolStatusLabel(row, t)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.total)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.used)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.available)}</TableCell>
|
||||
<TableCell>
|
||||
<UsageMeter value={pool.usagePercent} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{showProgress ? <UsageMeter value={progressPercent} tone="muted" /> : "--"}
|
||||
</TableCell>
|
||||
<TableCell>{showProgress ? formatBytesValue(rowStatus?.bytes) : "--"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
{rowState === "confirming" ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void handleStart(pool.id)
|
||||
}}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
{submitting && selectedPoolId === pool.id ? (
|
||||
<Spinner className="me-2 size-4" />
|
||||
) : null}
|
||||
{t("Confirm")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setConfirmingPoolId("")
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canStart}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setSelectedPoolId(pool.id)
|
||||
setConfirmingPoolId(pool.id)
|
||||
}}
|
||||
>
|
||||
<RiPlayCircleLine className="me-1 size-3.5" aria-hidden />
|
||||
{t("Start Decommission")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canCancel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void handleCancel(pool.id)
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={!canClear}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void handleClear(pool.id)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBin5Line className="me-1 size-3.5" aria-hidden />
|
||||
{t("Clear Decommission")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{confirmingPoolId ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Review before decommission")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.",
|
||||
)}{" "}
|
||||
{t("Selected Pool")}:{" "}
|
||||
{overview.pools.find((pool) => pool.id === confirmingPoolId)?.name ?? "--"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{selectedRow ? (
|
||||
<div className="grid grid-cols-2 gap-4 border bg-muted/20 p-3 sm:grid-cols-4">
|
||||
<div className="col-span-2 min-w-0 sm:col-span-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectionLocked ? t("Selection Locked") : t("Selected Pool")}
|
||||
</p>
|
||||
<p className="break-all text-sm font-semibold">{selectedRow.pool.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Total Capacity")}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatBytesValue(selectedRow.pool.total)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Used Capacity")}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatBytesValue(selectedRow.pool.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Available")}</p>
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{formatBytesValue(selectedRow.pool.available)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Updated At")}</p>
|
||||
<p className="text-sm font-medium">{formatDateTime(selectedRow.pool.lastUpdate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
{poolRows.map((row) => {
|
||||
const showProgress = Boolean(row.status) && hasDecommissionProgress(row.displayState)
|
||||
const progressPercent = showProgress ? getProgressPercent(row.status, row.pool) : 0
|
||||
const isSelected = selectedRow?.pool.id === row.pool.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.pool.id}
|
||||
role="button"
|
||||
tabIndex={selectionLocked ? undefined : 0}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"space-y-3 border p-3",
|
||||
isSelected && "border-primary/40 bg-primary/5",
|
||||
row.displayState === "failed" && "border-destructive/40 bg-destructive/5",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!selectionLocked) setSelectedPoolId(row.pool.id)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || selectionLocked) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
setSelectedPoolId(row.pool.id)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 break-all text-sm font-medium">{row.pool.name}</p>
|
||||
<Badge variant={getPoolStatusBadgeVariant(row.displayState)}>
|
||||
{formatPoolStatusLabel(row, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Used Capacity")}</p>
|
||||
<p className="tabular-nums">{formatBytesValue(row.pool.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Available")}</p>
|
||||
<p className="tabular-nums">{formatBytesValue(row.pool.available)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{showProgress ? t("Progress") : t("Usage")}</span>
|
||||
<span className="tabular-nums">
|
||||
{Math.round(showProgress ? progressPercent : row.pool.usagePercent)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={showProgress ? progressPercent : row.pool.usagePercent}
|
||||
aria-label={`${row.pool.name} ${showProgress ? t("Progress") : t("Usage")}`}
|
||||
/>
|
||||
</div>
|
||||
{renderPoolAction(row, true)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableCaption className="sr-only">{t("Pool Decommission")}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{t("Pool")}</TableHead>
|
||||
<TableHead scope="col">{t("Status")}</TableHead>
|
||||
<TableHead scope="col">{t("Total Capacity")}</TableHead>
|
||||
<TableHead scope="col">{t("Usage")}</TableHead>
|
||||
<TableHead scope="col">{t("Progress")}</TableHead>
|
||||
<TableHead scope="col" className="text-end">
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{poolRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
{t("No Data")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
poolRows.map((row) => {
|
||||
const { pool, status: rowStatus, displayState: rowState } = row
|
||||
const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState)
|
||||
const progressPercent = showProgress ? getProgressPercent(rowStatus, pool) : 0
|
||||
const isSelected = selectedRow?.pool.id === pool.id
|
||||
return (
|
||||
<TableRow
|
||||
key={pool.id}
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectionLocked ? "cursor-default" : "cursor-pointer",
|
||||
rowState === "running" && "bg-primary/5",
|
||||
rowState === "failed" && "bg-destructive/10",
|
||||
rowState === "canceled" && "bg-muted/60",
|
||||
)}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
onClick={() => {
|
||||
if (selectionLocked) return
|
||||
setSelectedPoolId(pool.id)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
if (selectionLocked) return
|
||||
setSelectedPoolId(pool.id)
|
||||
}}
|
||||
>
|
||||
<TableHead scope="row" className="max-w-[30rem] whitespace-normal">
|
||||
<span className="break-all font-medium">{pool.name}</span>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Badge variant={getPoolStatusBadgeVariant(rowState)}>
|
||||
{formatPoolStatusLabel(row, t)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="tabular-nums">{formatBytesValue(pool.total)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Available")}: {formatBytesValue(pool.available)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UsageMeter value={pool.usagePercent} label={`${pool.name} ${t("Usage")}`} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{showProgress ? (
|
||||
<UsageMeter
|
||||
value={progressPercent}
|
||||
tone="muted"
|
||||
label={`${pool.name} ${t("Progress")}`}
|
||||
/>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-end">
|
||||
<div className="inline-flex justify-end">{renderPoolAction(row)}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="rounded-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("Selected Pool")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{selectionLocked ? t("Selection Locked") : t("Pool")}</p>
|
||||
<p className="truncate text-sm font-semibold">{selectedPoolName}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Total Capacity")}</p>
|
||||
<p className="text-sm font-medium">{formatBytesValue(selectedRow?.pool.total)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Used Capacity")}</p>
|
||||
<p className="text-sm font-medium">{formatBytesValue(selectedRow?.pool.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Available")}</p>
|
||||
<p className="text-sm font-medium">{formatBytesValue(selectedRow?.pool.available)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Updated At")}</p>
|
||||
<p className="text-sm font-medium">{formatDateTime(selectedRow?.pool.lastUpdate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t("Usage")}</span>
|
||||
<span>{Math.round(selectedRow?.pool.usagePercent ?? 0)}%</span>
|
||||
</div>
|
||||
<Progress value={selectedRow?.pool.usagePercent ?? 0} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
+279
-127
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { usePoolOperations } from "@/hooks/use-pool-operations"
|
||||
import type { PoolSummary, PoolsOverview, RebalanceDisplayState, RebalanceStatus } from "@/lib/pool-operations"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
@@ -63,7 +63,9 @@ export default function RebalancePage() {
|
||||
const message = useMessage()
|
||||
const { getRebalanceViewModel, startRebalance, stopRebalance } = usePoolOperations()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [dataReady, setDataReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [overview, setOverview] = useState<PoolsOverview>(() => ({
|
||||
pools: [] as PoolSummary[],
|
||||
@@ -76,6 +78,11 @@ export default function RebalancePage() {
|
||||
const [status, setStatus] = useState<RebalanceStatus | null>(null)
|
||||
const [displayState, setDisplayState] = useState<RebalanceDisplayState>("idle")
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const requestVersionRef = useRef(0)
|
||||
const mutationRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const displayStateRef = useRef<RebalanceDisplayState>("idle")
|
||||
const interactionLockedRef = useRef(true)
|
||||
|
||||
const clearPoll = () => {
|
||||
if (pollRef.current) {
|
||||
@@ -86,55 +93,103 @@ export default function RebalancePage() {
|
||||
|
||||
const loadData = useCallback(
|
||||
async (showSpinner = true) => {
|
||||
const requestId = ++requestVersionRef.current
|
||||
if (showSpinner) setLoading(true)
|
||||
else setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const view = await getRebalanceViewModel()
|
||||
if (!mountedRef.current || requestId !== requestVersionRef.current) return false
|
||||
setOverview(view.overview)
|
||||
setStatus(view.status)
|
||||
setDisplayState(view.displayState)
|
||||
displayStateRef.current = view.displayState
|
||||
setDataReady(true)
|
||||
return true
|
||||
} catch (loadError) {
|
||||
if (!mountedRef.current || requestId !== requestVersionRef.current) return false
|
||||
setError((loadError as Error).message || t("Load Failed"))
|
||||
setDataReady(false)
|
||||
return false
|
||||
} finally {
|
||||
if (showSpinner) setLoading(false)
|
||||
if (mountedRef.current && requestId === requestVersionRef.current) {
|
||||
if (showSpinner) setLoading(false)
|
||||
else setRefreshing(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[getRebalanceViewModel, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
void loadData()
|
||||
return clearPoll
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
requestVersionRef.current += 1
|
||||
clearPoll()
|
||||
}
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
clearPoll()
|
||||
if (!shouldPoll(displayState)) return
|
||||
pollRef.current = window.setTimeout(() => {
|
||||
void loadData(false)
|
||||
}, POLL_MS)
|
||||
return clearPoll
|
||||
}, [displayState, loadData])
|
||||
displayStateRef.current = displayState
|
||||
}, [displayState])
|
||||
|
||||
useEffect(() => {
|
||||
clearPoll()
|
||||
if (!dataReady || !shouldPoll(displayState)) return
|
||||
let cancelled = false
|
||||
|
||||
const scheduleNextPoll = async () => {
|
||||
const succeeded = await loadData(false)
|
||||
if (!cancelled && succeeded && shouldPoll(displayStateRef.current)) {
|
||||
pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
pollRef.current = window.setTimeout(() => void scheduleNextPoll(), POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearPoll()
|
||||
}
|
||||
}, [dataReady, displayState, loadData])
|
||||
|
||||
const interactionLocked = loading || refreshing || submitting || Boolean(error) || !dataReady
|
||||
const canStart =
|
||||
!interactionLocked &&
|
||||
overview.supportState === "supported" &&
|
||||
overview.pools.filter((pool) => pool.status.trim().toLowerCase() === "active").length > 1 &&
|
||||
!overview.pools.some((pool) =>
|
||||
["decommissioning", "queued", "running"].includes(pool.decommissionStatus.trim().toLowerCase()),
|
||||
) &&
|
||||
["idle", "completed", "failed", "stopped"].includes(displayState)
|
||||
const canStop = ["starting", "running"].includes(displayState)
|
||||
const canStop = !interactionLocked && ["starting", "running"].includes(displayState)
|
||||
const showStop = ["starting", "running", "stopping"].includes(displayState)
|
||||
|
||||
useEffect(() => {
|
||||
interactionLockedRef.current = interactionLocked
|
||||
}, [interactionLocked])
|
||||
|
||||
const handleStart = async () => {
|
||||
if (mutationRef.current) return
|
||||
if (interactionLockedRef.current) return
|
||||
mutationRef.current = true
|
||||
requestVersionRef.current += 1
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await startRebalance()
|
||||
const nextStatus = await startRebalance()
|
||||
if (!mountedRef.current) return
|
||||
setStatus(nextStatus)
|
||||
setDisplayState("starting")
|
||||
displayStateRef.current = "starting"
|
||||
message.success(t("Rebalance started"))
|
||||
await loadData(false)
|
||||
} catch (startError) {
|
||||
message.error((startError as Error).message || t("Failed to start rebalance"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
mutationRef.current = false
|
||||
if (mountedRef.current) setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,24 +197,35 @@ export default function RebalancePage() {
|
||||
dialog.warning({
|
||||
title: t("Stop rebalance"),
|
||||
content: t("Are you sure you want to stop the current rebalance operation?"),
|
||||
positiveText: t("Confirm"),
|
||||
positiveText: t("Stop rebalance"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: async () => {
|
||||
if (mutationRef.current) return
|
||||
if (interactionLockedRef.current || !["starting", "running"].includes(displayStateRef.current)) return
|
||||
mutationRef.current = true
|
||||
requestVersionRef.current += 1
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await stopRebalance()
|
||||
const nextStatus = await stopRebalance()
|
||||
if (!mountedRef.current) return
|
||||
setStatus(nextStatus)
|
||||
setDisplayState("stopping")
|
||||
displayStateRef.current = "stopping"
|
||||
message.success(t("Rebalance stop requested"))
|
||||
await loadData(false)
|
||||
} catch (stopError) {
|
||||
message.error((stopError as Error).message || t("Failed to stop rebalance"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
mutationRef.current = false
|
||||
if (mountedRef.current) setSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!dataReady || error) return t("Unknown")
|
||||
switch (displayState) {
|
||||
case "unsupported":
|
||||
return t("Unsupported")
|
||||
@@ -180,7 +246,7 @@ export default function RebalancePage() {
|
||||
default:
|
||||
return t("Unknown")
|
||||
}
|
||||
}, [displayState, t])
|
||||
}, [dataReady, displayState, error, t])
|
||||
|
||||
const pools = useMemo(() => {
|
||||
const statusPools = new Map((status?.pools ?? []).map((pool) => [pool.id, pool]))
|
||||
@@ -195,28 +261,40 @@ export default function RebalancePage() {
|
||||
}
|
||||
})
|
||||
}, [overview.pools, status?.pools])
|
||||
const initialLoading = loading && !overview.pools.length && !error
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading || submitting}>
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
<Button onClick={handleStart} disabled={!canStart || submitting}>
|
||||
{submitting && canStart ? (
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading || refreshing || submitting}>
|
||||
{loading || refreshing ? (
|
||||
<Spinner className="me-2 size-4" />
|
||||
) : (
|
||||
<RiPlayCircleLine className="me-2 size-4" aria-hidden />
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
)}
|
||||
{t("Start Rebalance")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleStop} disabled={!canStop || submitting}>
|
||||
<RiPauseCircleLine className="me-2 size-4" aria-hidden />
|
||||
{t("Stop")}
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
{showStop ? (
|
||||
<Button variant="outline" onClick={handleStop} disabled={!canStop}>
|
||||
{submitting ? (
|
||||
<Spinner className="me-2 size-4" />
|
||||
) : (
|
||||
<RiPauseCircleLine className="me-2 size-4" aria-hidden />
|
||||
)}
|
||||
{t("Stop rebalance")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleStart} disabled={!canStart}>
|
||||
{submitting ? (
|
||||
<Spinner className="me-2 size-4" />
|
||||
) : (
|
||||
<RiPlayCircleLine className="me-2 size-4" aria-hidden />
|
||||
)}
|
||||
{t("Start Rebalance")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -224,115 +302,189 @@ export default function RebalancePage() {
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<PoolsOverviewCard overview={overview} operationLabel={t("Rebalance")} showDecommissionColumns={false} />
|
||||
|
||||
{overview.supportState === "unsupported" ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Single pool rebalance is not supported")}</AlertTitle>
|
||||
<AlertDescription>{t("Rebalance requires more than one pool in the cluster.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Load Failed")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{error}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadData()}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t("Current Rebalance Status")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("Track overall rebalance progress and pool-by-pool movement.")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
displayState === "failed" ? "destructive" : displayState === "completed" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Progress")}</p>
|
||||
<p className="text-sm font-medium">{Math.round(status?.progressPercent ?? 0)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
|
||||
<p className="text-sm font-medium">{formatBytesValue(status?.totals?.bytes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Elapsed")}</p>
|
||||
<p className="text-sm font-medium">{formatDuration(status?.totals?.elapsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("ETA")}</p>
|
||||
<p className="text-sm font-medium">{formatDuration(status?.totals?.eta)}</p>
|
||||
</div>
|
||||
{initialLoading ? (
|
||||
<div className="flex min-h-64 items-center justify-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-5" aria-hidden />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : overview.pools.length ? (
|
||||
<>
|
||||
<PoolsOverviewCard overview={overview} operationLabel={t("Rebalance")} />
|
||||
|
||||
{dataReady && overview.supportState === "unsupported" ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Single pool rebalance is not supported")}</AlertTitle>
|
||||
<AlertDescription>{t("Rebalance requires more than one pool in the cluster.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-none" aria-busy={refreshing || submitting}>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t("Current Rebalance Status")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("Track overall rebalance progress and pool-by-pool movement.")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
displayState === "failed" ? "destructive" : displayState === "completed" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{loading && !overview.pools.length ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Progress")}</p>
|
||||
<p className="text-sm font-medium">{Math.round(status?.progressPercent ?? 0)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
|
||||
<p className="text-sm font-medium">{formatBytesValue(status?.totals?.bytes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Elapsed")}</p>
|
||||
<p className="text-sm font-medium">{formatDuration(status?.totals?.elapsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("ETA")}</p>
|
||||
<p className="text-sm font-medium">{formatDuration(status?.totals?.eta)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={status?.progressPercent ?? 0} className="h-2" />
|
||||
<Progress
|
||||
value={status?.progressPercent ?? 0}
|
||||
className="h-2"
|
||||
aria-label={`${t("Rebalance")} ${t("Progress")}`}
|
||||
/>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("Pool")}</TableHead>
|
||||
<TableHead>{t("Status")}</TableHead>
|
||||
<TableHead>{t("Used Capacity")}</TableHead>
|
||||
<TableHead>{t("Bytes Moved")}</TableHead>
|
||||
<TableHead>{t("Objects")}</TableHead>
|
||||
<TableHead>{t("Versions")}</TableHead>
|
||||
<TableHead>{t("Cleanup Warnings")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
{t("No Data")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pools.map((pool) => (
|
||||
<TableRow
|
||||
<div className="space-y-3 md:hidden">
|
||||
{pools.map((pool) => (
|
||||
<div
|
||||
key={pool.id}
|
||||
className={cn(isFailedRebalancePool(pool) && "bg-destructive/15 hover:bg-destructive/20")}
|
||||
className={cn("space-y-3 border p-3", isFailedRebalancePool(pool) && "border-destructive/40")}
|
||||
>
|
||||
<TableCell>{pool.name}</TableCell>
|
||||
<TableCell>{pool.rebalanceStatus || "--"}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.used)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.progress.bytes)}</TableCell>
|
||||
<TableCell>{formatNumberValue(pool.progress.objects)}</TableCell>
|
||||
<TableCell>{formatNumberValue(pool.progress.versions)}</TableCell>
|
||||
<TableCell>
|
||||
{pool.cleanupWarnings.count > 0 ? (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-900 hover:bg-amber-100">
|
||||
{formatCleanupWarnings(pool)}
|
||||
</Badge>
|
||||
) : (
|
||||
formatCleanupWarnings(pool)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 break-all text-sm font-medium">{pool.name}</p>
|
||||
<Badge variant={isFailedRebalancePool(pool) ? "destructive" : "secondary"}>
|
||||
{pool.rebalanceStatus || "--"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Used Capacity")}</p>
|
||||
<p className="tabular-nums">{formatBytesValue(pool.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
|
||||
<p className="tabular-nums">{formatBytesValue(pool.progress.bytes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Objects")}</p>
|
||||
<p className="tabular-nums">{formatNumberValue(pool.progress.objects)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Versions")}</p>
|
||||
<p className="tabular-nums">{formatNumberValue(pool.progress.versions)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{pool.cleanupWarnings.count > 0 ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t("Cleanup Warnings")}: {pool.cleanupWarnings.count}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableCaption className="sr-only">{t("Current Rebalance Status")}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{t("Pool")}</TableHead>
|
||||
<TableHead scope="col">{t("Status")}</TableHead>
|
||||
<TableHead scope="col">{t("Used Capacity")}</TableHead>
|
||||
<TableHead scope="col">{t("Bytes Moved")}</TableHead>
|
||||
<TableHead scope="col">{t("Objects")}</TableHead>
|
||||
<TableHead scope="col">{t("Cleanup Warnings")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
{t("No Data")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pools.map((pool) => (
|
||||
<TableRow
|
||||
key={pool.id}
|
||||
className={cn(
|
||||
isFailedRebalancePool(pool) && "bg-destructive/15 hover:bg-destructive/20",
|
||||
)}
|
||||
>
|
||||
<TableHead scope="row" className="max-w-[28rem] whitespace-normal">
|
||||
<span className="break-all font-medium">{pool.name}</span>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Badge variant={isFailedRebalancePool(pool) ? "destructive" : "secondary"}>
|
||||
{pool.rebalanceStatus || "--"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.used)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.progress.bytes)}</TableCell>
|
||||
<TableCell>
|
||||
{formatNumberValue(pool.progress.objects)} /{" "}
|
||||
{formatNumberValue(pool.progress.versions)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{pool.cleanupWarnings.count > 0 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-amber-100 text-amber-900 hover:bg-amber-100"
|
||||
title={formatCleanupWarnings(pool)}
|
||||
>
|
||||
{pool.cleanupWarnings.count}
|
||||
</Badge>
|
||||
) : (
|
||||
formatCleanupWarnings(pool)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -30,20 +30,31 @@ export default function ReplicationPage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/replication" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("Bucket Replication")}: {bucketName}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<BucketReplicationTab bucketName={bucketName} hideTitle />
|
||||
<BucketReplicationTab
|
||||
key={bucketName}
|
||||
bucketName={bucketName}
|
||||
hideTitle
|
||||
renderHeader={(actions) => (
|
||||
<PageHeader
|
||||
description={
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("Bucket")}: <span className="break-all font-medium text-foreground">{bucketName}</span>
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/replication" />}>
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
<span>{t("Buckets")}</span>
|
||||
</Button>
|
||||
{actions}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Bucket Replication")}</h1>
|
||||
</PageHeader>
|
||||
)}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function SettingsPage() {
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const applySnapshot = useCallback((nextSnapshot: ModuleSwitchSnapshot) => {
|
||||
setSnapshot(nextSnapshot)
|
||||
@@ -56,10 +57,13 @@ export default function SettingsPage() {
|
||||
|
||||
const loadSwitches = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
applySnapshot(await getModuleSwitches())
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Failed to load module switches"))
|
||||
const description = (error as Error).message || t("Failed to load module switches")
|
||||
setLoadError(description)
|
||||
message.error(description)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -88,7 +92,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!snapshot) return
|
||||
if (!snapshot || loadError) return
|
||||
if (!canUpdateModuleSwitches) {
|
||||
message.warning(t("You do not have permission to update module switches."))
|
||||
return
|
||||
@@ -154,7 +158,11 @@ export default function SettingsPage() {
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
{canUpdateModuleSwitches ? (
|
||||
<Button type="button" onClick={handleSave} disabled={!snapshot || !isDirty || loading || saving}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!snapshot || Boolean(loadError) || !isDirty || loading || saving}
|
||||
>
|
||||
{saving ? <Spinner className="me-2 size-4" /> : <RiSave3Line className="me-2 size-4" aria-hidden />}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
@@ -165,7 +173,7 @@ export default function SettingsPage() {
|
||||
<h1 className="text-2xl font-bold">{t("Settings")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="max-w-4xl space-y-4">
|
||||
{hasEnvManagedSwitch ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Environment variables are controlling some switches")}</AlertTitle>
|
||||
@@ -182,11 +190,24 @@ export default function SettingsPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>{t("Failed to load module switches")}</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>{loadError}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={loadSwitches} disabled={loading || saving}>
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y rounded-none border">
|
||||
{switchItems.map((item) => {
|
||||
const isEnvManaged = isEnvManagedSource(item.source)
|
||||
return (
|
||||
<Field key={item.name} className="grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<Field key={item.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FieldLabel htmlFor={`${item.name}-module-switch`} className="text-sm font-medium">
|
||||
@@ -203,12 +224,12 @@ export default function SettingsPage() {
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<FieldContent className="md:justify-self-end">
|
||||
<FieldContent className="justify-self-end">
|
||||
<Switch
|
||||
id={`${item.name}-module-switch`}
|
||||
checked={item.checked}
|
||||
onCheckedChange={(checked) => updateSwitch(item.name, checked)}
|
||||
disabled={!canUpdateModuleSwitches || isEnvManaged || loading || saving}
|
||||
disabled={!canUpdateModuleSwitches || isEnvManaged || Boolean(loadError) || loading || saving}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
+1164
-489
File diff suppressed because it is too large
Load Diff
+174
-125
@@ -4,139 +4,139 @@ import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { usePerformanceData } from "@/hooks/use-performance-data"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
import { normalizeServerHealthState, type ServerHealthState } from "@/lib/performance-data"
|
||||
import { usePerformanceData, type PerformanceDataSource } from "@/hooks/use-performance-data"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { formatRelativeTime, summarizeServerStates } from "@/lib/performance-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RiArchiveDrawerFill,
|
||||
RiArchiveLine,
|
||||
RiHardDrive2Line,
|
||||
RiListSettingsFill,
|
||||
RiRefreshLine,
|
||||
RiSecurePaymentFill,
|
||||
RiStackLine,
|
||||
} from "@remixicon/react"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { PerformanceBackendCard } from "../_components/performance-backend-card"
|
||||
import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card"
|
||||
import { PerformanceServerList } from "../_components/performance-server-list"
|
||||
import { PerformanceSummaryCards } from "../_components/performance-summary-cards"
|
||||
import { PerformanceUsageCard } from "../_components/performance-usage-card"
|
||||
import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card"
|
||||
import { PerformanceBackendCard } from "../_components/performance-backend-card"
|
||||
import { PerformanceServerList } from "../_components/performance-server-list"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
function formatDuration(seconds: number | undefined, t: (key: string) => string) {
|
||||
if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return t("Unknown")
|
||||
const totalMinutes = Math.floor(seconds / 60)
|
||||
const days = Math.floor(totalMinutes / 1440)
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const parts = [
|
||||
days ? `${days} ${t("Days")}` : "",
|
||||
hours ? `${hours} ${t("Hours")}` : "",
|
||||
!days && minutes ? `${minutes} ${t("Minutes")}` : "",
|
||||
].filter(Boolean)
|
||||
return parts.slice(0, 2).join(" ") || `0 ${t("Minutes")}`
|
||||
}
|
||||
|
||||
export default function PerformancePage() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { canAccessPath } = usePermissions()
|
||||
const { systemInfo, metricsInfo, datausageinfo, storageinfo, loading, error, refetch } = usePerformanceData()
|
||||
const {
|
||||
systemInfo,
|
||||
metricsInfo,
|
||||
datausageinfo,
|
||||
storageinfo,
|
||||
loading,
|
||||
refreshing,
|
||||
hasLoaded,
|
||||
error,
|
||||
sourceErrors,
|
||||
lastUpdatedAt,
|
||||
metricsUpdatedAt,
|
||||
refetch,
|
||||
} = usePerformanceData()
|
||||
const browserHref = canAccessPath("/browser") ? "/browser" : undefined
|
||||
|
||||
const numberFormatter = useMemo(() => new Intl.NumberFormat(), [])
|
||||
|
||||
const storageBackend = useMemo(
|
||||
() => storageinfo?.backend ?? (storageinfo as { Backend?: typeof storageinfo.backend })?.Backend,
|
||||
[storageinfo],
|
||||
)
|
||||
const numberFormatter = useMemo(() => new Intl.NumberFormat(i18n.resolvedLanguage), [i18n.resolvedLanguage])
|
||||
const storageBackend = storageinfo.backend
|
||||
|
||||
const totalCapacity = datausageinfo.total_capacity
|
||||
const totalUsedCapacity = datausageinfo.total_used_capacity
|
||||
const usedPercent = useMemo(() => {
|
||||
const total = Number(datausageinfo.total_capacity || 0)
|
||||
if (!total) return 0
|
||||
const used = Number(datausageinfo.total_used_capacity || 0)
|
||||
return Math.min(100, Math.max(0, (used / total) * 100))
|
||||
}, [datausageinfo])
|
||||
|
||||
const lastUpdatedLabel = useMemo(() => {
|
||||
const last = metricsInfo?.aggregated?.scanner?.current_started
|
||||
const time = dayjs(last)
|
||||
return time.isValid() ? time.fromNow() : "--"
|
||||
}, [metricsInfo])
|
||||
if (totalCapacity === undefined || totalUsedCapacity === undefined) return undefined
|
||||
if (totalCapacity === 0) return totalUsedCapacity === 0 ? 0 : undefined
|
||||
return Math.min(100, Math.max(0, (totalUsedCapacity / totalCapacity) * 100))
|
||||
}, [totalCapacity, totalUsedCapacity])
|
||||
|
||||
const summaryMetrics = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("Buckets"),
|
||||
display: numberFormatter.format(systemInfo?.buckets?.count ?? 0),
|
||||
display:
|
||||
systemInfo.buckets?.count === undefined ? t("Unknown") : numberFormatter.format(systemInfo.buckets.count),
|
||||
icon: RiArchiveLine,
|
||||
caption: null as string | null,
|
||||
href: browserHref,
|
||||
},
|
||||
{
|
||||
label: t("Objects"),
|
||||
display: numberFormatter.format(systemInfo?.objects?.count ?? 0),
|
||||
display:
|
||||
systemInfo.objects?.count === undefined ? t("Unknown") : numberFormatter.format(systemInfo.objects.count),
|
||||
icon: RiStackLine,
|
||||
caption: null as string | null,
|
||||
href: browserHref,
|
||||
},
|
||||
{
|
||||
label: t("Total Capacity"),
|
||||
display: datausageinfo.total_capacity ? niceBytes(String(datausageinfo.total_capacity)) : "--",
|
||||
icon: RiHardDrive2Line,
|
||||
caption: datausageinfo.total_used_capacity
|
||||
? `${t("Used")}: ${niceBytes(String(datausageinfo.total_used_capacity))}`
|
||||
: null,
|
||||
href: undefined as string | undefined,
|
||||
},
|
||||
],
|
||||
[systemInfo, datausageinfo, numberFormatter, t, browserHref],
|
||||
[browserHref, numberFormatter, systemInfo.buckets, systemInfo.objects, t],
|
||||
)
|
||||
|
||||
const fromLastStartTime = useMemo(() => {
|
||||
const times = metricsInfo?.aggregated?.scanner?.cycle_complete_times || []
|
||||
if (!times.length) return "--"
|
||||
const start = dayjs(times[times.length - 1])
|
||||
return dayjs().from(start)
|
||||
}, [metricsInfo])
|
||||
|
||||
const fromLastScanTime = useMemo(() => {
|
||||
const start = dayjs(metricsInfo?.aggregated?.scanner?.current_started)
|
||||
if (!start.isValid()) return "--"
|
||||
return dayjs().from(start)
|
||||
}, [metricsInfo])
|
||||
|
||||
const lastScanTime = useMemo(() => {
|
||||
const currentStart = dayjs(metricsInfo?.aggregated?.scanner?.current_started)
|
||||
const cycleTimes = metricsInfo?.aggregated?.scanner?.cycle_complete_times || []
|
||||
if (!currentStart.isValid()) return "--"
|
||||
const lastComplete = dayjs(cycleTimes[cycleTimes.length - 1])
|
||||
return lastComplete.isValid() && currentStart.isBefore(lastComplete)
|
||||
? lastComplete.from(currentStart)
|
||||
: dayjs().from(currentStart)
|
||||
}, [metricsInfo])
|
||||
const scannerStartedAt = metricsInfo.aggregated?.scanner?.current_started
|
||||
const scannerCycle = metricsInfo.aggregated?.scanner?.current_cycle
|
||||
const scannerCompleteTimes = metricsInfo.aggregated?.scanner?.cycle_complete_times ?? []
|
||||
const scannerCompletedAt = scannerCompleteTimes.at(-1)
|
||||
const scannerActive = (scannerCycle ?? 0) > 0
|
||||
const scannerStatus = scannerActive ? t("Scanning") : scannerCompletedAt ? t("Idle") : t("Never run")
|
||||
const scannerDuration = useMemo(() => {
|
||||
if (!scannerStartedAt) return undefined
|
||||
const started = dayjs(scannerStartedAt)
|
||||
if (!started.isValid()) return undefined
|
||||
if (scannerActive) return Math.max(0, dayjs(metricsUpdatedAt ?? undefined).diff(started, "second"))
|
||||
if (!scannerCompletedAt) return undefined
|
||||
const completed = dayjs(scannerCompletedAt)
|
||||
return completed.isValid() && completed.isAfter(started)
|
||||
? Math.max(0, completed.diff(started, "second"))
|
||||
: undefined
|
||||
}, [metricsUpdatedAt, scannerActive, scannerCompletedAt, scannerStartedAt])
|
||||
|
||||
const usageStats = useMemo(
|
||||
() => [
|
||||
{ label: t("Last Normal Operation"), value: fromLastStartTime },
|
||||
{ label: t("Last Scan Activity"), value: fromLastScanTime },
|
||||
{ label: t("Uptime"), value: lastScanTime },
|
||||
{
|
||||
label: t("Last Completed Scan"),
|
||||
value: scannerCompletedAt
|
||||
? (formatRelativeTime(scannerCompletedAt, i18n.resolvedLanguage, metricsUpdatedAt?.getTime()) ?? t("Unknown"))
|
||||
: t("Unknown"),
|
||||
},
|
||||
{
|
||||
label: t("Scanner Status"),
|
||||
value: scannerStatus,
|
||||
},
|
||||
{ label: t("Scan Duration"), value: formatDuration(scannerDuration, t) },
|
||||
],
|
||||
[t, fromLastStartTime, fromLastScanTime, lastScanTime],
|
||||
[i18n.resolvedLanguage, metricsUpdatedAt, scannerCompletedAt, scannerDuration, scannerStatus, t],
|
||||
)
|
||||
|
||||
const serverCounts = useMemo(() => {
|
||||
const counts: Record<ServerHealthState, number> = {
|
||||
online: 0,
|
||||
offline: 0,
|
||||
unknown: 0,
|
||||
degraded: 0,
|
||||
}
|
||||
|
||||
for (const server of systemInfo?.servers ?? []) {
|
||||
counts[normalizeServerHealthState(server.state)] += 1
|
||||
}
|
||||
|
||||
return counts
|
||||
}, [systemInfo])
|
||||
const serverSummary = useMemo(
|
||||
() => (systemInfo.servers ? summarizeServerStates(systemInfo.servers) : undefined),
|
||||
[systemInfo.servers],
|
||||
)
|
||||
|
||||
const backendInfo = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: RiArchiveDrawerFill,
|
||||
title: t("Backend Type"),
|
||||
value: systemInfo?.backend?.backendType,
|
||||
value: systemInfo.backend?.backendType ?? storageBackend?.BackendType,
|
||||
},
|
||||
{
|
||||
icon: RiSecurePaymentFill,
|
||||
@@ -149,17 +149,43 @@ export default function PerformancePage() {
|
||||
value: storageBackend?.RRSCParity,
|
||||
},
|
||||
],
|
||||
[systemInfo, storageBackend, t],
|
||||
[storageBackend, systemInfo.backend?.backendType, t],
|
||||
)
|
||||
|
||||
if (loading && !Object.keys(systemInfo).length && !Object.keys(datausageinfo).length) {
|
||||
const unavailableSources = Object.keys(sourceErrors) as PerformanceDataSource[]
|
||||
const sourceLabels: Record<PerformanceDataSource, string> = {
|
||||
system: t("Cluster information"),
|
||||
usage: t("Storage Usage Statistics"),
|
||||
storage: t("Storage Configuration"),
|
||||
metrics: t("Scanner metrics"),
|
||||
}
|
||||
|
||||
const refreshAction = (
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
{lastUpdatedAt ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("Last refreshed")}: {formatRelativeTime(lastUpdatedAt, i18n.resolvedLanguage) ?? t("Unknown")}
|
||||
</span>
|
||||
) : null}
|
||||
<Button variant="outline" className="min-h-11 sm:min-h-0" onClick={refetch} disabled={refreshing}>
|
||||
<RiRefreshLine
|
||||
className={cn("me-2 size-4", refreshing && "animate-spin motion-reduce:animate-none")}
|
||||
aria-hidden
|
||||
/>
|
||||
{refreshing ? t("Refreshing…") : t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading && !hasLoaded) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("Running Status")}</h1>
|
||||
</PageHeader>
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
<div className="flex min-h-64 items-center justify-center gap-3 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-6 motion-reduce:animate-none" role="presentation" aria-hidden />
|
||||
<span>{t("Loading…")}</span>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
@@ -168,18 +194,21 @@ export default function PerformancePage() {
|
||||
if (error) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" onClick={refetch}>
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageHeader actions={refreshAction}>
|
||||
<h1 className="text-2xl font-bold">{t("Running Status")}</h1>
|
||||
</PageHeader>
|
||||
<div className="border border-destructive/50 bg-destructive/10 p-6 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<div className="border border-destructive/50 bg-destructive/10 p-6" role="alert">
|
||||
<p className="font-medium text-destructive">{t(error)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("Check your connection, then refresh the status page.")}
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4 min-h-11 sm:min-h-0" onClick={refetch} disabled={refreshing}>
|
||||
<RiRefreshLine
|
||||
className={cn("me-2 size-4", refreshing && "animate-spin motion-reduce:animate-none")}
|
||||
aria-hidden
|
||||
/>
|
||||
{refreshing ? t("Refreshing…") : t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
@@ -187,42 +216,62 @@ export default function PerformancePage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" onClick={refetch} disabled={loading}>
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageHeader actions={refreshAction}>
|
||||
<h1 className="text-2xl font-bold">{t("Running Status")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-8">
|
||||
<PerformanceSummaryCards metrics={summaryMetrics} />
|
||||
<div className="space-y-6" aria-busy={refreshing}>
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||
{refreshing
|
||||
? t("Refreshing…")
|
||||
: !unavailableSources.length && lastUpdatedAt
|
||||
? t("Status refreshed successfully")
|
||||
: ""}
|
||||
</span>
|
||||
|
||||
<PerformanceUsageCard
|
||||
lastUpdatedLabel={lastUpdatedLabel}
|
||||
totalUsedCapacity={datausageinfo.total_used_capacity ?? 0}
|
||||
usedPercent={usedPercent}
|
||||
usageStats={usageStats}
|
||||
t={t}
|
||||
/>
|
||||
{unavailableSources.length ? (
|
||||
<div className="border border-amber-500/40 bg-amber-500/10 p-4" role="status" aria-live="polite">
|
||||
<p className="text-sm font-medium">{t("Some status data is unavailable.")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("Previously loaded values may be stale.")} {t("Unavailable")}:{" "}
|
||||
{unavailableSources.map((source) => sourceLabels[source]).join(", ")}. {t("Refresh to try again.")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PerformanceInfrastructureCard
|
||||
onlineServers={serverCounts.online}
|
||||
offlineServers={serverCounts.offline}
|
||||
unknownServers={serverCounts.unknown}
|
||||
degradedServers={serverCounts.degraded}
|
||||
onlineDisks={systemInfo?.backend?.onlineDisks ?? 0}
|
||||
offlineDisks={systemInfo?.backend?.offlineDisks ?? 0}
|
||||
unknownDisks={systemInfo?.backend?.unknownDisks ?? 0}
|
||||
t={t}
|
||||
/>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="order-1">
|
||||
<PerformanceInfrastructureCard
|
||||
onlineServers={serverSummary?.online}
|
||||
offlineServers={serverSummary?.offline}
|
||||
degradedServers={serverSummary?.degraded}
|
||||
initializingServers={serverSummary?.initializing}
|
||||
unknownServers={serverSummary?.unknown}
|
||||
onlineDisks={systemInfo.backend?.onlineDisks}
|
||||
offlineDisks={systemInfo.backend?.offlineDisks}
|
||||
unknownDisks={systemInfo.backend?.unknownDisks}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="order-3 xl:order-2">
|
||||
<PerformanceUsageCard
|
||||
totalCapacity={totalCapacity}
|
||||
totalUsedCapacity={totalUsedCapacity}
|
||||
usedPercent={usedPercent}
|
||||
usageStats={usageStats}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="order-2 xl:order-3 xl:col-span-2">
|
||||
<PerformanceServerList servers={systemInfo.servers} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PerformanceSummaryCards metrics={summaryMetrics} title={t("Inventory")} />
|
||||
|
||||
<PerformanceBackendCard items={backendInfo} t={t} />
|
||||
|
||||
<PerformanceServerList servers={systemInfo?.servers ?? []} t={t} />
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import dayjs from "dayjs"
|
||||
import { RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { DateTimePicker } from "@/components/datetime-picker"
|
||||
@@ -41,58 +42,95 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
|
||||
const [description, setDescription] = React.useState("")
|
||||
const [status, setStatus] = React.useState<"on" | "off">("on")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [policyError, setPolicyError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [initialName, setInitialName] = React.useState("")
|
||||
const [initialDescription, setInitialDescription] = React.useState("")
|
||||
|
||||
const minExpiry = React.useMemo(() => dayjs().toISOString(), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && row?.accessKey) {
|
||||
getServiceAccount(row.accessKey)
|
||||
.then(
|
||||
(res: {
|
||||
policy?: unknown
|
||||
expiration?: string
|
||||
name?: string
|
||||
description?: string
|
||||
accountStatus?: string
|
||||
}) => {
|
||||
setAccesskey(row.accessKey)
|
||||
let policyValue: string
|
||||
if (typeof res.policy === "string") {
|
||||
try {
|
||||
policyValue = JSON.stringify(JSON.parse(res.policy), null, 2)
|
||||
} catch {
|
||||
policyValue = res.policy
|
||||
}
|
||||
} else {
|
||||
policyValue = JSON.stringify(res.policy ?? {}, null, 2)
|
||||
if (!open || !row?.accessKey) return
|
||||
|
||||
let cancelled = false
|
||||
const currentAccessKey = row.accessKey
|
||||
|
||||
setAccesskey(currentAccessKey)
|
||||
setPolicy("")
|
||||
setExpiry(null)
|
||||
setName("")
|
||||
setDescription("")
|
||||
setStatus("on")
|
||||
setInitialName("")
|
||||
setInitialDescription("")
|
||||
setPolicyError("")
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
|
||||
getServiceAccount(currentAccessKey)
|
||||
.then(
|
||||
(res: {
|
||||
policy?: unknown
|
||||
expiration?: string
|
||||
name?: string
|
||||
description?: string
|
||||
accountStatus?: string
|
||||
}) => {
|
||||
if (cancelled) return
|
||||
let policyValue: string
|
||||
if (typeof res.policy === "string") {
|
||||
try {
|
||||
policyValue = JSON.stringify(JSON.parse(res.policy), null, 2)
|
||||
} catch {
|
||||
policyValue = res.policy
|
||||
}
|
||||
setPolicy(policyValue)
|
||||
setExpiry(
|
||||
res.expiration && res.expiration !== "9999-01-01T00:00:00Z" ? dayjs(res.expiration).toISOString() : null,
|
||||
)
|
||||
const initialNameValue = res.name ?? ""
|
||||
const initialDescriptionValue = res.description ?? ""
|
||||
setName(initialNameValue)
|
||||
setDescription(initialDescriptionValue)
|
||||
setInitialName(initialNameValue)
|
||||
setInitialDescription(initialDescriptionValue)
|
||||
setStatus((res.accountStatus as "on" | "off") ?? "on")
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
message.error(t("Failed to get data"))
|
||||
})
|
||||
} else {
|
||||
policyValue = JSON.stringify(res.policy ?? {}, null, 2)
|
||||
}
|
||||
setPolicy(policyValue)
|
||||
setExpiry(
|
||||
res.expiration && res.expiration !== "9999-01-01T00:00:00Z" ? dayjs(res.expiration).toISOString() : null,
|
||||
)
|
||||
const initialNameValue = res.name ?? ""
|
||||
const initialDescriptionValue = res.description ?? ""
|
||||
setName(initialNameValue)
|
||||
setDescription(initialDescriptionValue)
|
||||
setInitialName(initialNameValue)
|
||||
setInitialDescription(initialDescriptionValue)
|
||||
setStatus((res.accountStatus as "on" | "off") ?? "on")
|
||||
},
|
||||
)
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
const reason = (error as Error)?.message || t("Failed to get data")
|
||||
setLoadError(reason)
|
||||
message.error(reason)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, row?.accessKey, getServiceAccount, message, t])
|
||||
}, [open, row?.accessKey, getServiceAccount, loadVersion, message, t])
|
||||
|
||||
const closeModal = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) {
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!accesskey) return
|
||||
if (!accesskey || loading || loadError) return
|
||||
if (submitting) return
|
||||
setPolicyError("")
|
||||
|
||||
// Validate policy JSON format before submitting
|
||||
if (policy) {
|
||||
@@ -100,15 +138,15 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
|
||||
const parsed = JSON.parse(policy)
|
||||
// Basic structure validation
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
message.error(t("Policy must be a valid JSON object"))
|
||||
setPolicyError(t("Policy must be a valid JSON object"))
|
||||
return
|
||||
}
|
||||
if (parsed.Statement !== undefined && !Array.isArray(parsed.Statement)) {
|
||||
message.error(t("Policy Statement must be an array"))
|
||||
setPolicyError(t("Policy Statement must be an array"))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
message.error(t("Policy format is invalid. Please check the JSON syntax."))
|
||||
setPolicyError(t("Policy format is invalid. Please check the JSON syntax."))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -141,101 +179,162 @@ export function AccessKeysEditItem({ open, onOpenChange, row, onSuccess }: Acces
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className="overflow-x-hidden sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal={submitting}>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-6xl"
|
||||
aria-busy={loading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Edit Key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-2 flex max-h-[80vh] flex-col gap-4 overflow-y-auto overflow-x-hidden px-2 lg:flex-row">
|
||||
<div className="flex w-full flex-col gap-4 lg:w-72 lg:shrink-0">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="edit-access-key" name="edit-access-key" value={accesskey} disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key-expiry">{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker id="edit-access-key-expiry" value={expiry} onChange={setExpiry} min={minExpiry} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="edit-access-key-name"
|
||||
name="edit-access-key-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="edit-access-key-description"
|
||||
name="edit-access-key-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel htmlFor="edit-access-key-status" className="text-sm font-medium">
|
||||
{t("Status")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch
|
||||
id="edit-access-key-status"
|
||||
checked={status === "on"}
|
||||
onCheckedChange={(checked) => setStatus(checked ? "on" : "off")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="edit-access-key-policy">{t("Policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="edit-access-key-policy"
|
||||
name="edit-access-key-policy"
|
||||
value={policy}
|
||||
onChange={(e) => setPolicy(e.target.value)}
|
||||
className="h-full min-h-[200px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-72 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div
|
||||
className="flex min-h-72 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="max-w-md text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={() => setLoadVersion((current) => current + 1)}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
t("Submit")
|
||||
<div className="flex flex-col gap-4 lg:min-h-[32rem] lg:flex-row">
|
||||
<div className="flex w-full flex-col gap-4 lg:w-72 lg:shrink-0">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="edit-access-key" name="edit-access-key" value={accesskey} disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key-expiry">{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="edit-access-key-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="edit-access-key-name"
|
||||
name="edit-access-key-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-access-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="edit-access-key-description"
|
||||
name="edit-access-key-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel htmlFor="edit-access-key-status" className="text-sm font-medium">
|
||||
{t("Status")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch
|
||||
id="edit-access-key-status"
|
||||
checked={status === "on"}
|
||||
onCheckedChange={(checked) => setStatus(checked ? "on" : "off")}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[20rem] flex-1 lg:min-h-0">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="edit-access-key-policy">{t("Policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="edit-access-key-policy"
|
||||
name="edit-access-key-policy"
|
||||
value={policy}
|
||||
onChange={(e) => {
|
||||
setPolicy(e.target.value)
|
||||
setPolicyError("")
|
||||
}}
|
||||
className="min-h-[24rem] font-mono text-xs lg:h-full"
|
||||
spellCheck={false}
|
||||
disabled={loading || submitting}
|
||||
aria-invalid={Boolean(policyError)}
|
||||
aria-describedby={policyError ? "edit-access-key-policy-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="edit-access-key-policy-error">{policyError}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeModal}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={loading || submitting || Boolean(loadError)}
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
+180
-136
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { DateTimePicker } from "@/components/datetime-picker"
|
||||
@@ -88,6 +88,12 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
|
||||
onVisibleChange(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextVisible: boolean) => {
|
||||
if (!submitting || nextVisible) {
|
||||
onVisibleChange(nextVisible)
|
||||
}
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = { accessKey: "", secretKey: "", name: "" }
|
||||
if (!accessKey) {
|
||||
@@ -139,6 +145,7 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (submitting) return
|
||||
setSubmitError("")
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
@@ -186,153 +193,190 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className={cn("overflow-x-hidden sm:max-w-xl", !impliedPolicy && "sm:max-w-6xl")}>
|
||||
<DialogHeader>
|
||||
<Dialog open={visible} onOpenChange={handleOpenChange} disablePointerDismissal={submitting}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
!impliedPolicy && "sm:max-w-6xl",
|
||||
)}
|
||||
aria-busy={submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Create Key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-2 flex max-h-[80vh] flex-col gap-4 overflow-y-auto overflow-x-hidden px-2 lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-access-key"
|
||||
name="create-access-key"
|
||||
value={accessKey}
|
||||
onChange={(e) => setAccessKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.accessKey)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.accessKey}</FieldError>
|
||||
</Field>
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="flex flex-col gap-4 lg:min-h-[32rem] lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-access-key"
|
||||
name="create-access-key"
|
||||
value={accessKey}
|
||||
onChange={(e) => setAccessKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.accessKey)}
|
||||
aria-describedby={errors.accessKey ? "create-access-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="create-access-key-error">{errors.accessKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-secret-key"
|
||||
name="create-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.secretKey)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.secretKey}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-secret-key"
|
||||
name="create-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.secretKey)}
|
||||
aria-describedby={errors.secretKey ? "create-secret-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="create-secret-key-error">{errors.secretKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-expiry">
|
||||
{t("Expiry")}({t("empty is indicates permanent validity")})
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="create-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
placeholder={t("Please select expiry date")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-expiry">{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="create-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
placeholder={t("Please select expiry date")}
|
||||
disabled={submitting}
|
||||
aria-describedby="create-expiry-description"
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription id="create-expiry-description">
|
||||
{t("empty is indicates permanent validity")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-name"
|
||||
name="create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.name}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-name"
|
||||
name="create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? "create-name-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="create-name-error">{errors.name}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="create-description"
|
||||
name="create-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="create-description"
|
||||
name="create-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<FieldLabel htmlFor="create-implied-policy" className="text-sm font-medium">
|
||||
{t("Use main account policy")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch
|
||||
id="create-implied-policy"
|
||||
name="create-implied-policy"
|
||||
checked={impliedPolicy}
|
||||
onCheckedChange={setImpliedPolicy}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<FieldLabel htmlFor="create-implied-policy" className="text-sm font-medium">
|
||||
{t("Use main account policy")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch
|
||||
id="create-implied-policy"
|
||||
name="create-implied-policy"
|
||||
checked={impliedPolicy}
|
||||
onCheckedChange={setImpliedPolicy}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy && (
|
||||
<div className="min-h-[20rem] flex-1 lg:min-h-0">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="create-policy">{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="create-policy"
|
||||
name="create-policy"
|
||||
value={policy}
|
||||
onChange={(e) => setPolicy(e.target.value)}
|
||||
className="min-h-[24rem] font-mono text-xs lg:h-full"
|
||||
spellCheck={false}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy && (
|
||||
<div className="flex-1 max-h-[60vh] overflow-auto">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="create-policy">{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="create-policy"
|
||||
name="create-policy"
|
||||
value={policy}
|
||||
onChange={(e) => setPolicy(e.target.value)}
|
||||
className="h-full min-h-[200px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
{submitError && (
|
||||
<p role="alert" className="px-4 text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<p role="alert" className="px-2 text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeModal}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="default" className="w-full sm:w-auto" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -122,17 +122,24 @@ export function AuditTargetNewForm({ open, onOpenChange, onSuccess }: AuditTarge
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<Dialog
|
||||
open={open}
|
||||
disablePointerDismissal={submitting}
|
||||
onOpenChange={(nextOpen) => {
|
||||
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">
|
||||
<DialogHeader className="border-b px-4 py-4 pe-12 sm:px-6">
|
||||
<DialogTitle>{type ? t("Add {type} Audit Target", { type }) : t("Add Audit Target")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{AUDIT_TARGET_TYPE_OPTIONS.map((option) => (
|
||||
@@ -227,8 +234,8 @@ export function AuditTargetNewForm({ open, onOpenChange, onSuccess }: AuditTarge
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<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}>
|
||||
|
||||
@@ -15,10 +15,10 @@ export function AuthHeroStatic() {
|
||||
const theme = getThemeManifest()
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col justify-center gap-8 overflow-hidden border-e bg-muted/40 p-16">
|
||||
<div className="z-10 flex max-w-7xl flex-col">
|
||||
<div className="relative flex h-full w-full flex-col justify-center gap-10 overflow-hidden border-e bg-muted/40 p-12 xl:p-16">
|
||||
<div className="z-10 flex max-w-lg flex-col">
|
||||
<ThemeLogo width={112} height={24} alt={theme.brand.name} priority />
|
||||
<div className="my-6 px-0 text-4xl font-semibold text-primary">
|
||||
<div className="my-7 px-0 font-heading text-3xl font-semibold leading-tight tracking-tight text-primary xl:text-4xl">
|
||||
<span className={locale !== "zh" ? "pe-1" : undefined}>{t("Rust-based")} </span>
|
||||
<FlipWords
|
||||
words={[
|
||||
@@ -29,14 +29,14 @@ export function AuthHeroStatic() {
|
||||
t("S3 Compatible"),
|
||||
]}
|
||||
duration={3000}
|
||||
className="text-4xl font-semibold text-primary"
|
||||
className="font-heading text-3xl font-semibold leading-tight tracking-tight text-primary xl:text-4xl"
|
||||
/>
|
||||
<div className="mt-2 text-muted-foreground">{t("Reliable distributed file system")}</div>
|
||||
<div className="mt-3 text-pretty text-muted-foreground">{t("Reliable distributed file system")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="https://www.rustfs.com"
|
||||
className="z-10 inline-flex w-max items-center gap-2 rounded-none border border-primary/30 p-2 px-5 leading-none text-primary transition-colors hover:bg-accent"
|
||||
className="z-10 inline-flex h-10 w-max items-center gap-2 rounded-none border border-primary/30 px-5 leading-none text-primary transition-colors hover:bg-accent"
|
||||
>
|
||||
<span>{t("Visit website")}</span>
|
||||
<RiArrowRightLongFill className="me-2 size-4 rtl:-scale-x-100" aria-hidden />
|
||||
@@ -48,7 +48,7 @@ export function AuthHeroStatic() {
|
||||
fill
|
||||
priority
|
||||
sizes="50vw"
|
||||
className="object-cover opacity-45"
|
||||
className="object-cover opacity-55"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,24 +57,24 @@ export function LoginForm({
|
||||
const website = theme.links.website ?? "https://www.rustfs.com"
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-muted p-4 sm:p-6 lg:p-20">
|
||||
<div className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-muted p-4 sm:p-8 lg:p-12">
|
||||
<Image
|
||||
src={buildRoute("/backgrounds/scillate.svg")}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="absolute inset-0 z-0 opacity-45 object-cover"
|
||||
className="absolute inset-0 z-0 object-cover opacity-30"
|
||||
/>
|
||||
<div className="z-10 mx-auto flex w-full max-w-7xl flex-col overflow-hidden border bg-card shadow-none lg:min-h-[560px] lg:flex-row">
|
||||
<div className="hidden w-1/2 lg:block">
|
||||
<div className="z-10 mx-auto flex w-full max-w-6xl flex-col overflow-hidden border bg-card shadow-none lg:min-h-[600px] lg:flex-row">
|
||||
<div className="hidden lg:block lg:w-1/2">
|
||||
<AuthHeroStatic />
|
||||
</div>
|
||||
<div className="relative flex w-full flex-col items-center justify-center bg-card lg:w-1/2">
|
||||
<div className="absolute end-4 top-4 z-10">
|
||||
<Link
|
||||
href="/config"
|
||||
className="inline-flex h-8 w-8 items-center justify-center border bg-background text-muted-foreground shadow-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="inline-flex size-11 items-center justify-center border bg-background text-muted-foreground shadow-none transition-colors hover:bg-accent hover:text-accent-foreground sm:size-9"
|
||||
title={t("Server Configuration")}
|
||||
aria-label={t("Server Configuration")}
|
||||
>
|
||||
@@ -82,22 +82,23 @@ export function LoginForm({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm w-full space-y-6 p-4 sm:p-7">
|
||||
<ThemeLogo width={112} height={24} priority />
|
||||
<div className="w-full max-w-sm space-y-7 p-6 sm:p-8 lg:p-10">
|
||||
<ThemeLogo width={112} height={24} className="lg:hidden" priority />
|
||||
<h1 className="sr-only">{t("Login")}</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Tabs value={method} onValueChange={(v) => setMethod(v as LoginMethod)} className="flex flex-col gap-4">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-1/2" value="accessKeyAndSecretKey">
|
||||
<TabsList className="h-11 w-full border bg-muted/60 sm:h-9">
|
||||
<TabsTrigger className="w-1/2 text-sm sm:text-xs" value="accessKeyAndSecretKey">
|
||||
{t("Key Login")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-1/2" value="sts">
|
||||
<TabsTrigger className="w-1/2 text-sm sm:text-xs" value="sts">
|
||||
{t("STS Login")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<form onSubmit={handleLogin} autoComplete="off">
|
||||
<div className="grid gap-y-6">
|
||||
<div className="grid gap-y-5">
|
||||
{method === "accessKeyAndSecretKey" ? (
|
||||
<>
|
||||
<Field>
|
||||
@@ -116,6 +117,8 @@ export function LoginForm({
|
||||
autoComplete="username"
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="h-11 text-base sm:h-8 sm:text-xs"
|
||||
placeholder={t("Please enter account")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -136,6 +139,8 @@ export function LoginForm({
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="h-11 text-base sm:h-8 sm:text-xs"
|
||||
placeholder={t("Please enter key")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -159,6 +164,8 @@ export function LoginForm({
|
||||
autoComplete="new-password"
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="h-11 text-base sm:h-8 sm:text-xs"
|
||||
placeholder={t("Please enter STS username")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -179,6 +186,8 @@ export function LoginForm({
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="h-11 text-base sm:h-8 sm:text-xs"
|
||||
placeholder={t("Please enter STS key")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -199,6 +208,8 @@ export function LoginForm({
|
||||
autoComplete="new-password"
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="h-11 text-base sm:h-8 sm:text-xs"
|
||||
placeholder={t("Please enter STS session token")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -206,7 +217,11 @@ export function LoginForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="default" className="w-full justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="h-11 w-full justify-center text-sm sm:h-8 sm:text-xs"
|
||||
>
|
||||
{t("Login")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -227,7 +242,7 @@ export function LoginForm({
|
||||
key={provider.provider_id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
className="h-11 w-full justify-center text-sm sm:h-8 sm:text-xs"
|
||||
onClick={() => onOidcLogin(provider.provider_id)}
|
||||
>
|
||||
{t("Login with {name}", { name: provider.display_name })}
|
||||
@@ -237,20 +252,15 @@ export function LoginForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-5">
|
||||
<p className="min-w-0 text-sm text-muted-foreground">
|
||||
{t("Login Problems?")}{" "}
|
||||
<Link href={website} className="text-foreground underline-offset-4 hover:underline">
|
||||
{t("Get Help")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-1/2 items-center justify-around gap-4">
|
||||
<div className="inline-flex">
|
||||
<div className="flex shrink-0 items-center gap-1 [&_[data-slot=button]]:size-10 sm:[&_[data-slot=button]]:size-8">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div className="inline-flex">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
@@ -13,16 +14,18 @@ import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useModuleSwitches } from "@/hooks/use-module-switches"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { canManageNotifyBackedFeature } from "@/lib/notify-module-access"
|
||||
import { createLatestRequestGate } from "@/lib/bucket-configuration"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type { NotificationItem } from "@/lib/events"
|
||||
import { getDisplayEvents, TYPE_BADGE_CLASSES, type NotificationItem } from "@/lib/events"
|
||||
|
||||
interface BucketEventsTabProps {
|
||||
bucketName: string
|
||||
hideTitle?: boolean
|
||||
renderHeader?: (actions: React.ReactNode) => React.ReactNode
|
||||
}
|
||||
|
||||
export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsTabProps) {
|
||||
export function BucketEventsTab({ bucketName, hideTitle = false, renderHeader }: BucketEventsTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -34,12 +37,23 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
|
||||
const [data, setData] = React.useState<NotificationItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [notifyError, setNotifyError] = React.useState("")
|
||||
const [mutatingId, setMutatingId] = React.useState<string | null>(null)
|
||||
const [newFormOpen, setNewFormOpen] = React.useState(false)
|
||||
const [notifyEnabled, setNotifyEnabled] = React.useState<boolean | undefined>(undefined)
|
||||
const [requestGate] = React.useState(createLatestRequestGate)
|
||||
|
||||
const canManageBucketEvents = canEditEvents && canManageNotifyBackedFeature(notifyEnabled)
|
||||
const canManageBucketEvents =
|
||||
canEditEvents &&
|
||||
canManageNotifyBackedFeature(notifyEnabled) &&
|
||||
!loadError &&
|
||||
!notifyError &&
|
||||
!loading &&
|
||||
!mutatingId
|
||||
|
||||
const loadData = React.useCallback(async () => {
|
||||
const requestVersion = requestGate.begin()
|
||||
setLoading(true)
|
||||
try {
|
||||
const [response, switches] = await Promise.allSettled([
|
||||
@@ -47,8 +61,14 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
getModuleSwitches({ suppress403Redirect: true }),
|
||||
])
|
||||
|
||||
if (!requestGate.isCurrent(requestVersion)) return
|
||||
|
||||
if (switches.status === "fulfilled" && switches.value) {
|
||||
setNotifyEnabled(switches.value.notify_enabled)
|
||||
setNotifyError("")
|
||||
} else {
|
||||
setNotifyEnabled(undefined)
|
||||
setNotifyError(t("Unable to verify whether notify is enabled. Refresh before making changes."))
|
||||
}
|
||||
|
||||
if (response.status === "rejected") {
|
||||
@@ -79,8 +99,10 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
const prefix = config.Filter?.Key?.FilterRules?.find((r) => r.Name === "Prefix")?.Value
|
||||
const suffix = config.Filter?.Key?.FilterRules?.find((r) => r.Name === "Suffix")?.Value
|
||||
const arn = (config as Record<string, string>)[arnKey]
|
||||
const sourceId = config.Id?.trim() || undefined
|
||||
notifications.push({
|
||||
id: config.Id ?? "",
|
||||
id: sourceId ?? `missing-${type.toLowerCase()}-${notifications.length}`,
|
||||
sourceId,
|
||||
type,
|
||||
arn: arn ?? "",
|
||||
events: config.Events ?? [],
|
||||
@@ -103,69 +125,74 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
addFromConfig((r?.TopicConfigurations ?? []) as never[], "SNS", "TopicArn")
|
||||
|
||||
setData(notifications)
|
||||
setLoadError("")
|
||||
} catch {
|
||||
setData([])
|
||||
if (!requestGate.isCurrent(requestVersion)) return
|
||||
setLoadError(t("Unable to load event subscriptions. Refresh before making changes."))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestGate.isCurrent(requestVersion)) setLoading(false)
|
||||
}
|
||||
}, [bucketName, getModuleSwitches, listBucketNotifications])
|
||||
}, [bucketName, getModuleSwitches, listBucketNotifications, requestGate, t])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
return () => {
|
||||
requestGate.invalidate()
|
||||
}
|
||||
}, [loadData, requestGate])
|
||||
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (row: NotificationItem) => {
|
||||
(row: NotificationItem) => {
|
||||
if (!canEditEvents) return
|
||||
if (!canManageBucketEvents) {
|
||||
message.warning(t("Notify is disabled. Enable notify before managing bucket event subscriptions."))
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
dialog.warning({
|
||||
title: t("Confirm Delete"),
|
||||
content: t("Are you sure you want to delete this notification configuration?"),
|
||||
positiveText: t("Delete"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
})
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const currentResponse = await listBucketNotifications(bucketName)
|
||||
const currentNotifications = (currentResponse ?? {}) as unknown as Record<string, unknown[]>
|
||||
|
||||
const configKey =
|
||||
row.type === "Lambda"
|
||||
? "LambdaFunctionConfigurations"
|
||||
: row.type === "SQS"
|
||||
? "QueueConfigurations"
|
||||
: "TopicConfigurations"
|
||||
const configs = (currentNotifications as Record<string, Array<{ Id?: string }>>)[configKey]
|
||||
const updated = configs?.filter((c) => c.Id !== row.id) ?? []
|
||||
|
||||
const newConfig = {
|
||||
...currentNotifications,
|
||||
...(row.type === "Lambda"
|
||||
? { LambdaFunctionConfigurations: updated }
|
||||
: row.type === "SQS"
|
||||
? { QueueConfigurations: updated }
|
||||
: { TopicConfigurations: updated }),
|
||||
}
|
||||
|
||||
await putBucketNotifications(bucketName, newConfig)
|
||||
message.success(t("Delete Success"))
|
||||
loadData()
|
||||
} catch (error) {
|
||||
message.error(`${t("Delete Failed")}: ${(error as Error).message ?? error}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!row.sourceId) {
|
||||
message.error(t("This subscription has no stable ID. Refresh before deleting it."))
|
||||
return
|
||||
}
|
||||
|
||||
dialog.warning({
|
||||
title: t("Confirm Delete"),
|
||||
content: `${t("Are you sure you want to delete this notification configuration?")} ${row.sourceId} · ${bucketName}`,
|
||||
positiveText: t("Delete Event Subscription"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: async () => {
|
||||
if (mutatingId) return
|
||||
setMutatingId(row.sourceId ?? row.id)
|
||||
try {
|
||||
const currentResponse = await listBucketNotifications(bucketName)
|
||||
const currentNotifications = (currentResponse ?? {}) as unknown as Record<string, unknown[]>
|
||||
const configKey =
|
||||
row.type === "Lambda"
|
||||
? "LambdaFunctionConfigurations"
|
||||
: row.type === "SQS"
|
||||
? "QueueConfigurations"
|
||||
: "TopicConfigurations"
|
||||
const configs = (currentNotifications as Record<string, Array<{ Id?: string }>>)[configKey] ?? []
|
||||
const matches = configs.filter((config) => config.Id === row.sourceId)
|
||||
if (matches.length !== 1) throw new Error(t("Configuration changed. Refresh and try again."))
|
||||
const updated = configs.filter((config) => config.Id !== row.sourceId)
|
||||
const newConfig = {
|
||||
...currentNotifications,
|
||||
...(row.type === "Lambda"
|
||||
? { LambdaFunctionConfigurations: updated }
|
||||
: row.type === "SQS"
|
||||
? { QueueConfigurations: updated }
|
||||
: { TopicConfigurations: updated }),
|
||||
}
|
||||
|
||||
await putBucketNotifications(bucketName, newConfig)
|
||||
message.success(t("Delete Success"))
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error(`${t("Delete Failed")}: ${(error as Error).message ?? error}`)
|
||||
} finally {
|
||||
setMutatingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
[
|
||||
bucketName,
|
||||
@@ -175,6 +202,7 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
listBucketNotifications,
|
||||
loadData,
|
||||
message,
|
||||
mutatingId,
|
||||
putBucketNotifications,
|
||||
t,
|
||||
],
|
||||
@@ -191,25 +219,33 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
{canEditEvents ? (
|
||||
<Button onClick={() => setNewFormOpen(true)} disabled={!canManageBucketEvents}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Subscription")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData} disabled={loading || mutatingId !== null}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{hideTitle ? null : <h2 className="text-lg font-medium">{t("Events")}</h2>}
|
||||
<div className="flex gap-2">
|
||||
{canEditEvents ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!canManageBucketEvents}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Subscription")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData} disabled={loading}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
{renderHeader ? (
|
||||
renderHeader(actions)
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{hideTitle ? null : <h2 className="text-lg font-medium">{t("Events")}</h2>}
|
||||
<div className="grid w-full gap-2 sm:ms-auto sm:flex sm:w-auto">{actions}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEditEvents && !canManageBucketEvents ? (
|
||||
{canEditEvents && notifyEnabled === false ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Notify is disabled")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -218,12 +254,80 @@ export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsT
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Data")}
|
||||
emptyDescription={t("Add Event Subscription to get started")}
|
||||
/>
|
||||
{notifyError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Notify status unavailable")}</AlertTitle>
|
||||
<AlertDescription>{notifyError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Event subscriptions unavailable")}</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="hidden md:block">
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No event subscriptions")}
|
||||
emptyDescription={t("Add Event Subscription to get started")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:hidden" aria-live="polite">
|
||||
{loading && data.length === 0 ? (
|
||||
<div className="border p-4 text-sm text-muted-foreground">{t("Loading")}</div>
|
||||
) : data.length === 0 && !loadError ? (
|
||||
<div className="border p-4">
|
||||
<p className="text-sm font-medium">{t("No event subscriptions")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("Add Event Subscription to get started")}</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<article key={item.id} className="space-y-4 border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-all text-sm font-medium">{item.sourceId ?? t("Unnamed subscription")}</h3>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{item.arn}</p>
|
||||
</div>
|
||||
<Badge className={TYPE_BADGE_CLASSES[item.type] ?? ""}>{item.type}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getDisplayEvents(item.events).map((event) => (
|
||||
<Badge key={event} variant="secondary">
|
||||
{event}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{item.prefix || item.suffix ? (
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Prefix")}</dt>
|
||||
<dd className="break-all">{item.prefix || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Suffix")}</dt>
|
||||
<dd className="break-all">{item.suffix || "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{canEditEvents ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full whitespace-normal break-all"
|
||||
onClick={() => handleRowDelete(item)}
|
||||
disabled={!canManageBucketEvents || !item.sourceId}
|
||||
>
|
||||
{`${t("Delete Event Subscription")}: ${item.sourceId ?? t("Unnamed subscription")}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EventsNewForm
|
||||
open={newFormOpen}
|
||||
|
||||
+814
-360
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
@@ -12,6 +13,7 @@ import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { LifecycleNewForm } from "@/components/lifecycle/new-form"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { isMissingBucketConfiguration, removeMatchingBucketRule } from "@/lib/bucket-configuration"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface LifecycleRule {
|
||||
@@ -39,9 +41,10 @@ interface LifecycleRule {
|
||||
interface BucketLifecycleTabProps {
|
||||
bucketName: string
|
||||
hideTitle?: boolean
|
||||
renderHeader?: (actions: React.ReactNode) => React.ReactNode
|
||||
}
|
||||
|
||||
export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLifecycleTabProps) {
|
||||
export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader }: BucketLifecycleTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -52,33 +55,54 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLife
|
||||
|
||||
const [data, setData] = React.useState<LifecycleRule[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [mutatingRuleId, setMutatingRuleId] = React.useState<string | null>(null)
|
||||
const [newFormOpen, setNewFormOpen] = React.useState(false)
|
||||
const requestVersionRef = React.useRef(0)
|
||||
|
||||
const loadData = React.useCallback(async () => {
|
||||
const requestVersion = ++requestVersionRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getBucketLifecycleConfiguration(bucketName)
|
||||
const rules = [...(response?.Rules ?? [])]
|
||||
.map((r) => r as LifecycleRule)
|
||||
.sort((a, b) => (a.ID ?? "").localeCompare(b.ID ?? ""))
|
||||
if (requestVersion !== requestVersionRef.current) return
|
||||
setData(rules)
|
||||
} catch {
|
||||
setData([])
|
||||
setLoadError("")
|
||||
} catch (error) {
|
||||
if (requestVersion !== requestVersionRef.current) return
|
||||
if (isMissingBucketConfiguration(error, "lifecycle")) {
|
||||
setData([])
|
||||
setLoadError("")
|
||||
} else {
|
||||
setLoadError(t("Unable to load lifecycle rules. Refresh before making changes."))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestVersion === requestVersionRef.current) setLoading(false)
|
||||
}
|
||||
}, [bucketName, getBucketLifecycleConfiguration])
|
||||
}, [bucketName, getBucketLifecycleConfiguration, t])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData()
|
||||
return () => {
|
||||
requestVersionRef.current += 1
|
||||
}
|
||||
}, [loadData])
|
||||
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (row: LifecycleRule) => {
|
||||
if (!canEditLifecycle) return
|
||||
const remaining = data.filter((item) => item.ID !== row.ID)
|
||||
if (!canEditLifecycle || loadError || loading || mutatingRuleId) return
|
||||
const mutationKey = row.ID ?? JSON.stringify(row)
|
||||
setMutatingRuleId(mutationKey)
|
||||
|
||||
try {
|
||||
const current = await getBucketLifecycleConfiguration(bucketName)
|
||||
const currentRules = (current?.Rules ?? []).map((rule) => rule as LifecycleRule)
|
||||
const remaining = removeMatchingBucketRule(currentRules, row)
|
||||
if (!remaining) throw new Error(t("Configuration changed. Refresh and try again."))
|
||||
|
||||
if (remaining.length === 0) {
|
||||
await deleteBucketLifecycle(bucketName)
|
||||
} else {
|
||||
@@ -87,25 +111,39 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLife
|
||||
})
|
||||
}
|
||||
message.success(t("Delete Success"))
|
||||
loadData()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Delete Failed"))
|
||||
} finally {
|
||||
setMutatingRuleId(null)
|
||||
}
|
||||
},
|
||||
[canEditLifecycle, data, deleteBucketLifecycle, bucketName, putBucketLifecycleConfiguration, message, t, loadData],
|
||||
[
|
||||
bucketName,
|
||||
canEditLifecycle,
|
||||
deleteBucketLifecycle,
|
||||
getBucketLifecycleConfiguration,
|
||||
loadData,
|
||||
loadError,
|
||||
loading,
|
||||
message,
|
||||
mutatingRuleId,
|
||||
putBucketLifecycleConfiguration,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
const confirmDelete = React.useCallback(
|
||||
(row: LifecycleRule) => {
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this rule?"),
|
||||
positiveText: t("Confirm"),
|
||||
content: `${t("Are you sure you want to delete this rule?")} ${row.ID ?? t("Unnamed rule")} · ${bucketName}`,
|
||||
positiveText: t("Delete Rule"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => handleRowDelete(row),
|
||||
})
|
||||
},
|
||||
[dialog, t, handleRowDelete],
|
||||
[bucketName, dialog, t, handleRowDelete],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<LifecycleRule>[] = React.useMemo(
|
||||
@@ -166,7 +204,13 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLife
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditLifecycle ? (
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
|
||||
aria-label={`${t("Delete Rule")} ${row.original.ID ?? t("Unnamed rule")}`}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -175,7 +219,7 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLife
|
||||
),
|
||||
},
|
||||
],
|
||||
[canEditLifecycle, t, confirmDelete],
|
||||
[canEditLifecycle, confirmDelete, loadError, loading, mutatingRuleId, t],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<LifecycleRule>({
|
||||
@@ -184,30 +228,107 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLife
|
||||
getRowId: (row) => row.ID ?? JSON.stringify(row),
|
||||
})
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
{canEditLifecycle ? (
|
||||
<Button
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
|
||||
>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Lifecycle Rule")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData} disabled={loading || mutatingRuleId !== null}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{hideTitle ? null : <h2 className="text-lg font-medium">{t("Lifecycle")}</h2>}
|
||||
<div className="flex gap-2">
|
||||
{canEditLifecycle ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Lifecycle Rule")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
{renderHeader ? (
|
||||
renderHeader(actions)
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{hideTitle ? null : <h2 className="text-lg font-medium">{t("Lifecycle")}</h2>}
|
||||
<div className="grid w-full gap-2 sm:ms-auto sm:flex sm:w-auto">{actions}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Lifecycle rules unavailable")}</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="hidden md:block">
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No lifecycle rules")}
|
||||
emptyDescription={t("Create lifecycle rules to automate object transitions and expiration.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Data")}
|
||||
emptyDescription={t("Create lifecycle rules to automate object transitions and expiration.")}
|
||||
/>
|
||||
<div className="space-y-3 md:hidden" aria-live="polite">
|
||||
{loading && data.length === 0 ? (
|
||||
<div className="border p-4 text-sm text-muted-foreground">{t("Loading")}</div>
|
||||
) : data.length === 0 && !loadError ? (
|
||||
<div className="border p-4">
|
||||
<p className="text-sm font-medium">{t("No lifecycle rules")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("Create lifecycle rules to automate object transitions and expiration.")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((rule) => {
|
||||
const identity = rule.ID ?? JSON.stringify(rule)
|
||||
const type = rule.Transitions || rule.NoncurrentVersionTransitions ? t("Transition") : t("Expire")
|
||||
const cycle =
|
||||
rule.Expiration?.Days ??
|
||||
rule.NoncurrentVersionExpiration?.NoncurrentDays ??
|
||||
rule.Transitions?.[0]?.Days ??
|
||||
rule.NoncurrentVersionTransitions?.[0]?.NoncurrentDays
|
||||
return (
|
||||
<article key={identity} className="space-y-4 border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-all text-sm font-medium">{rule.ID ?? t("Unnamed rule")}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{type}</p>
|
||||
</div>
|
||||
<Badge variant={rule.Status === "Enabled" ? "secondary" : "outline"}>
|
||||
{rule.Status === "Enabled" ? t("Enabled") : t("Disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Prefix")}</dt>
|
||||
<dd className="break-all">{rule.Filter?.Prefix || rule.Filter?.And?.Prefix || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Time Cycle")}</dt>
|
||||
<dd className="tabular-nums">{cycle ? `${cycle} ${t("Days")}` : "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{canEditLifecycle ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full whitespace-normal break-all"
|
||||
onClick={() => confirmDelete(rule)}
|
||||
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
{`${t("Delete Rule")}: ${rule.ID ?? t("Unnamed rule")}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LifecycleNewForm open={newFormOpen} onOpenChange={setNewFormOpen} bucketName={bucketName} onSuccess={loadData} />
|
||||
</div>
|
||||
|
||||
@@ -206,7 +206,8 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
</Field>
|
||||
|
||||
{quotaEnabled && (
|
||||
<div className="space-y-4 border p-4">
|
||||
<fieldset className="space-y-4 border-t pt-4">
|
||||
<legend className="text-sm font-semibold">{t("Bucket Quota")}</legend>
|
||||
<Field>
|
||||
<FieldLabel>{t("Quota Size")}</FieldLabel>
|
||||
<FieldContent>
|
||||
@@ -224,6 +225,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
value={quotaUnit}
|
||||
onValueChange={(v) => setQuotaUnit(v)}
|
||||
className="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
aria-label={t("Quota Unit")}
|
||||
>
|
||||
{["MiB", "GiB", "TiB", "PiB"].map((unit) => (
|
||||
<label
|
||||
@@ -243,11 +245,12 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{objectLock && (
|
||||
<div className="space-y-4 border p-4">
|
||||
<fieldset className="space-y-4 border-t pt-4">
|
||||
<legend className="text-sm font-semibold">{t("Object Lock")}</legend>
|
||||
<Field orientation="responsive" className="items-center">
|
||||
<FieldLabel htmlFor="bucket-new-retention">{t("Retention")}</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
@@ -264,6 +267,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
value={retentionMode}
|
||||
onValueChange={(v) => setRetentionMode(v as "COMPLIANCE" | "GOVERNANCE")}
|
||||
className="grid gap-2 sm:grid-cols-2"
|
||||
aria-label={t("Retention Mode")}
|
||||
>
|
||||
{retentionModeOptions.map((option) => (
|
||||
<label
|
||||
@@ -300,6 +304,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
value={retentionUnit}
|
||||
onValueChange={(v) => setRetentionUnit(v as "day" | "year")}
|
||||
className="grid gap-2 sm:grid-cols-2"
|
||||
aria-label={t("Retention Unit")}
|
||||
>
|
||||
{retentionUnitOptions.map((option) => (
|
||||
<label
|
||||
@@ -321,7 +326,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin7Line } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
@@ -12,6 +13,7 @@ import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { ReplicationNewForm } from "@/components/replication/new-form"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { isMissingBucketConfiguration, removeMatchingBucketRule } from "@/lib/bucket-configuration"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface ReplicationRule {
|
||||
@@ -25,68 +27,114 @@ interface ReplicationRule {
|
||||
interface BucketReplicationTabProps {
|
||||
bucketName: string
|
||||
hideTitle?: boolean
|
||||
renderHeader?: (actions: React.ReactNode) => React.ReactNode
|
||||
}
|
||||
|
||||
export function BucketReplicationTab({ bucketName, hideTitle = false }: BucketReplicationTabProps) {
|
||||
export function BucketReplicationTab({ bucketName, hideTitle = false, renderHeader }: BucketReplicationTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { canCapability } = usePermissions()
|
||||
const { getBucketReplication, putBucketReplication, deleteBucketReplication } = useBucket()
|
||||
const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } =
|
||||
useBucket()
|
||||
const replicationContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
|
||||
const canEditReplication = canCapability("bucket.replication.edit", replicationContext)
|
||||
|
||||
const [data, setData] = React.useState<ReplicationRule[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [mutatingRuleId, setMutatingRuleId] = React.useState<string | null>(null)
|
||||
const [newFormOpen, setNewFormOpen] = React.useState(false)
|
||||
const requestVersionRef = React.useRef(0)
|
||||
|
||||
const loadData = React.useCallback(async () => {
|
||||
const requestVersion = ++requestVersionRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getBucketReplication(bucketName)
|
||||
if (requestVersion !== requestVersionRef.current) return
|
||||
setData(res?.ReplicationConfiguration?.Rules ?? [])
|
||||
} catch {
|
||||
setData([])
|
||||
setLoadError("")
|
||||
} catch (error) {
|
||||
if (requestVersion !== requestVersionRef.current) return
|
||||
if (isMissingBucketConfiguration(error, "replication")) {
|
||||
setData([])
|
||||
setLoadError("")
|
||||
} else {
|
||||
setLoadError(t("Unable to load replication rules. Refresh before making changes."))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestVersion === requestVersionRef.current) setLoading(false)
|
||||
}
|
||||
}, [bucketName, getBucketReplication])
|
||||
}, [bucketName, getBucketReplication, t])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData()
|
||||
return () => {
|
||||
requestVersionRef.current += 1
|
||||
}
|
||||
}, [loadData])
|
||||
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (rule: ReplicationRule) => {
|
||||
if (!canEditReplication) return
|
||||
const remaining = data.filter((item) => item !== rule)
|
||||
if (!canEditReplication || loadError || loading || mutatingRuleId) return
|
||||
const mutationKey = rule.ID ?? JSON.stringify(rule)
|
||||
setMutatingRuleId(mutationKey)
|
||||
|
||||
try {
|
||||
const currentConfig = await getBucketReplication(bucketName)
|
||||
const currentRules = (currentConfig?.ReplicationConfiguration?.Rules ?? []) as ReplicationRule[]
|
||||
const matches = rule.ID
|
||||
? currentRules.filter((item) => item.ID === rule.ID)
|
||||
: currentRules.filter((item) => JSON.stringify(item) === JSON.stringify(rule))
|
||||
const remaining = removeMatchingBucketRule(currentRules, rule)
|
||||
if (!remaining) throw new Error(t("Configuration changed. Refresh and try again."))
|
||||
const role = currentConfig?.ReplicationConfiguration?.Role?.trim() ?? ""
|
||||
const targetArn = matches[0]?.Destination?.Bucket
|
||||
let targetCleanupFailed = false
|
||||
|
||||
if (remaining.length === 0) {
|
||||
await deleteBucketReplication(bucketName)
|
||||
} else {
|
||||
const currentConfig = await getBucketReplication(bucketName)
|
||||
const role = currentConfig?.ReplicationConfiguration?.Role
|
||||
if (!role) {
|
||||
throw new Error("Replication configuration Role is missing")
|
||||
}
|
||||
await putBucketReplication(bucketName, {
|
||||
Role: role,
|
||||
Rules: remaining,
|
||||
})
|
||||
}
|
||||
message.success(t("Delete Success"))
|
||||
loadData()
|
||||
if (
|
||||
remaining.length > 0 &&
|
||||
!role &&
|
||||
targetArn &&
|
||||
!remaining.some((item) => item.Destination?.Bucket === targetArn)
|
||||
) {
|
||||
try {
|
||||
await deleteRemoteReplicationTarget(bucketName, targetArn)
|
||||
} catch (cleanupError) {
|
||||
console.error(cleanupError)
|
||||
targetCleanupFailed = true
|
||||
}
|
||||
}
|
||||
if (targetCleanupFailed) {
|
||||
message.warning(t("The rule was deleted, but the remote target could not be removed."))
|
||||
} else {
|
||||
message.success(t("Delete Success"))
|
||||
}
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Delete Failed"))
|
||||
} finally {
|
||||
setMutatingRuleId(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
canEditReplication,
|
||||
data,
|
||||
deleteBucketReplication,
|
||||
deleteRemoteReplicationTarget,
|
||||
bucketName,
|
||||
getBucketReplication,
|
||||
loadError,
|
||||
loading,
|
||||
mutatingRuleId,
|
||||
putBucketReplication,
|
||||
message,
|
||||
t,
|
||||
@@ -98,13 +146,13 @@ export function BucketReplicationTab({ bucketName, hideTitle = false }: BucketRe
|
||||
(rule: ReplicationRule) => {
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this replication rule?"),
|
||||
positiveText: t("Confirm"),
|
||||
content: `${t("Are you sure you want to delete this replication rule?")} ${rule.ID ?? t("Unnamed rule")} · ${bucketName}`,
|
||||
positiveText: t("Delete Rule"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => handleRowDelete(rule),
|
||||
})
|
||||
},
|
||||
[dialog, t, handleRowDelete],
|
||||
[bucketName, dialog, t, handleRowDelete],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<ReplicationRule>[] = React.useMemo(
|
||||
@@ -153,7 +201,13 @@ export function BucketReplicationTab({ bucketName, hideTitle = false }: BucketRe
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditReplication ? (
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
|
||||
aria-label={`${t("Delete Rule")} ${row.original.ID ?? t("Unnamed rule")}`}
|
||||
>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -162,7 +216,7 @@ export function BucketReplicationTab({ bucketName, hideTitle = false }: BucketRe
|
||||
),
|
||||
},
|
||||
],
|
||||
[canEditReplication, t, confirmDelete],
|
||||
[canEditReplication, confirmDelete, loadError, loading, mutatingRuleId, t],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<ReplicationRule>({
|
||||
@@ -171,30 +225,102 @@ export function BucketReplicationTab({ bucketName, hideTitle = false }: BucketRe
|
||||
getRowId: (row) => row.ID ?? JSON.stringify(row),
|
||||
})
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
{canEditReplication ? (
|
||||
<Button
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
|
||||
>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Replication Rule")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData} disabled={loading || mutatingRuleId !== null}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{hideTitle ? null : <h2 className="text-lg font-medium">{t("Bucket Replication")}</h2>}
|
||||
<div className="flex gap-2">
|
||||
{canEditReplication ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Replication Rule")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
{renderHeader ? (
|
||||
renderHeader(actions)
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{hideTitle ? null : <h2 className="text-lg font-medium">{t("Bucket Replication")}</h2>}
|
||||
<div className="grid w-full gap-2 sm:ms-auto sm:flex sm:w-auto">{actions}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Replication rules unavailable")}</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="hidden md:block">
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No replication rules")}
|
||||
emptyDescription={t("Add replication rules to sync objects across buckets.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Data")}
|
||||
emptyDescription={t("Add replication rules to sync objects across buckets.")}
|
||||
/>
|
||||
<div className="space-y-3 md:hidden" aria-live="polite">
|
||||
{loading && data.length === 0 ? (
|
||||
<div className="border p-4 text-sm text-muted-foreground">{t("Loading")}</div>
|
||||
) : data.length === 0 && !loadError ? (
|
||||
<div className="border p-4">
|
||||
<p className="text-sm font-medium">{t("No replication rules")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("Add replication rules to sync objects across buckets.")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((rule) => {
|
||||
const identity = rule.ID ?? JSON.stringify(rule)
|
||||
const destination = (rule.Destination?.Bucket ?? "").replace(/^arn:aws:s3:::/, "") || "-"
|
||||
return (
|
||||
<article key={identity} className="space-y-4 border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-all text-sm font-medium">{rule.ID ?? t("Unnamed rule")}</h3>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{destination}</p>
|
||||
</div>
|
||||
<Badge variant={rule.Status === "Enabled" ? "secondary" : "outline"}>
|
||||
{rule.Status === "Enabled" ? t("Enabled") : t("Disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Priority")}</dt>
|
||||
<dd className="tabular-nums">{String(rule.Priority ?? "-")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{t("Prefix")}</dt>
|
||||
<dd className="break-all">{rule.Filter?.Prefix || "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{canEditReplication ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full whitespace-normal break-all"
|
||||
onClick={() => confirmDelete(rule)}
|
||||
disabled={Boolean(loadError) || loading || mutatingRuleId !== null}
|
||||
>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
{`${t("Delete Rule")}: ${rule.ID ?? t("Unnamed rule")}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReplicationNewForm
|
||||
open={newFormOpen}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type * as React from "react"
|
||||
import { RiRefreshLine } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BucketSettingsSection({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section id={id} aria-labelledby={`${id}-heading`} className="scroll-mt-24 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h2 id={`${id}-heading`} className="font-heading text-base font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? <p className="max-w-2xl text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<div className="divide-y border bg-card">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BucketSettingRow({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusVariant = "outline",
|
||||
action,
|
||||
children,
|
||||
error,
|
||||
errorTitle,
|
||||
retryLabel,
|
||||
onRetry,
|
||||
className,
|
||||
}: {
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
status?: React.ReactNode
|
||||
statusVariant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link"
|
||||
action?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
error?: React.ReactNode
|
||||
errorTitle?: React.ReactNode
|
||||
retryLabel?: React.ReactNode
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid gap-4 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start", className)}>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 text-sm font-medium text-foreground">{title}</h3>
|
||||
{status ? <Badge variant={statusVariant}>{status}</Badge> : null}
|
||||
</div>
|
||||
{description ? <p className="max-w-2xl text-sm leading-5 text-muted-foreground">{description}</p> : null}
|
||||
{children}
|
||||
</div>
|
||||
{action ? (
|
||||
<div className="flex w-full items-center sm:w-auto [&_button]:w-full sm:[&_button]:w-auto">{action}</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="sm:col-span-2">
|
||||
<AlertTitle>{errorTitle}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
{onRetry ? (
|
||||
<div className="col-span-full mt-2">
|
||||
<Button variant="outline" size="sm" onClick={onRetry} className="min-h-9">
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface CopyInputProps extends Omit<React.ComponentProps<typeof Input>, "value
|
||||
onChange?: (value: string) => void
|
||||
readonly?: boolean
|
||||
copyIcon?: boolean
|
||||
copyLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -22,11 +23,13 @@ export function CopyInput({
|
||||
onChange,
|
||||
readonly = false,
|
||||
copyIcon = false,
|
||||
copyLabel,
|
||||
className,
|
||||
...props
|
||||
}: CopyInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const resolvedCopyLabel = copyLabel ?? t("Copy")
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
@@ -49,7 +52,7 @@ export function CopyInput({
|
||||
/>
|
||||
{!copyIcon ? (
|
||||
<Button type="button" variant="default" onClick={handleCopy}>
|
||||
{t("Copy")}
|
||||
{resolvedCopyLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -57,11 +60,12 @@ export function CopyInput({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 outline"
|
||||
title={t("Copy")}
|
||||
title={resolvedCopyLabel}
|
||||
aria-label={resolvedCopyLabel}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<RiFileCopyLine className="size-4" aria-hidden />
|
||||
<span className="sr-only">{t("Copy")}</span>
|
||||
<span className="sr-only">{resolvedCopyLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,11 +33,11 @@ export function DataTablePagination<TData>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", className)}>
|
||||
<div className={cn("flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", className)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{t("Rows per page")}</span>
|
||||
<Select value={String(pagination.pageSize)} onValueChange={(value) => handlePageSizeChange(value ?? "")}>
|
||||
<SelectTrigger className="w-24" aria-label={t("Rows per page")}>
|
||||
<SelectTrigger className="h-10 w-24 sm:h-8" aria-label={t("Rows per page")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -50,26 +50,45 @@ export function DataTablePagination<TData>({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span className="text-sm text-muted-foreground" role="status" aria-live="polite">
|
||||
{t("Page {current} of {total}", {
|
||||
current: pageCount === 0 ? 0 : currentPage,
|
||||
total: pageCount,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={!canPrevious} onClick={() => table.setPageIndex(0)}>
|
||||
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-7 sm:w-auto"
|
||||
disabled={!canPrevious}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
>
|
||||
{t("First")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!canPrevious} onClick={() => table.previousPage()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-7 sm:w-auto"
|
||||
disabled={!canPrevious}
|
||||
onClick={() => table.previousPage()}
|
||||
>
|
||||
{t("Prev")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!canNext} onClick={() => table.nextPage()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-7 sm:w-auto"
|
||||
disabled={!canNext}
|
||||
onClick={() => table.nextPage()}
|
||||
>
|
||||
{t("Next")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-7 sm:w-auto"
|
||||
disabled={!canNext}
|
||||
onClick={() => table.setPageIndex(Math.max(pageCount - 1, 0))}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface DataTableProps<TData> {
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
caption?: string
|
||||
className?: string
|
||||
tableClass?: string
|
||||
stickyHeader?: boolean
|
||||
@@ -55,11 +56,20 @@ function canSort<TData>(column: Column<TData, unknown>) {
|
||||
return hasAccessorKey || hasAccessorFn
|
||||
}
|
||||
|
||||
function getAriaSort<TData>(column: Column<TData, unknown>): React.AriaAttributes["aria-sort"] {
|
||||
if (!canSort(column)) return undefined
|
||||
const direction = column.getIsSorted()
|
||||
if (direction === "asc") return "ascending"
|
||||
if (direction === "desc") return "descending"
|
||||
return "none"
|
||||
}
|
||||
|
||||
export function DataTable<TData>({
|
||||
table,
|
||||
isLoading = false,
|
||||
emptyTitle = "No data",
|
||||
emptyDescription = "There is nothing to display yet.",
|
||||
caption,
|
||||
className,
|
||||
tableClass,
|
||||
stickyHeader = false,
|
||||
@@ -69,13 +79,19 @@ export function DataTable<TData>({
|
||||
const hasRows = table.getRowModel().rows.length > 0
|
||||
|
||||
const tableContent = (
|
||||
<UiTable className={cn("min-w-full tabular-nums", tableClass)}>
|
||||
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted/40 backdrop-blur" : ""}>
|
||||
<UiTable className={cn("min-w-full tabular-nums", tableClass)} aria-busy={isLoading}>
|
||||
{caption ? <caption className="sr-only">{caption}</caption> : null}
|
||||
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted/40 backdrop-blur" : "bg-muted/20"}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className={stickyHeader ? "bg-muted/40 backdrop-blur" : undefined}>
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className={stickyHeader ? "bg-muted/40 backdrop-blur" : "bg-muted/20 hover:bg-muted/30"}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
scope="col"
|
||||
aria-sort={getAriaSort(header.column)}
|
||||
className={bodyHeight ? undefined : "py-2"}
|
||||
style={getColumnStyles(header.column)}
|
||||
>
|
||||
@@ -150,7 +166,7 @@ export function DataTable<TData>({
|
||||
{bodyHeight ? (
|
||||
<ScrollArea className={cn("border", bodyHeight)}>{tableContent}</ScrollArea>
|
||||
) : (
|
||||
<div className="w-full overflow-x-auto border">{tableContent}</div>
|
||||
<div className="w-full border">{tableContent}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -123,17 +123,24 @@ export function EventsTargetNewForm({ open, onOpenChange, onSuccess }: EventsTar
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<Dialog
|
||||
open={open}
|
||||
disablePointerDismissal={submitting}
|
||||
onOpenChange={(nextOpen) => {
|
||||
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">
|
||||
<DialogHeader className="border-b px-4 py-4 pe-12 sm:px-6">
|
||||
<DialogTitle>{type ? t("Add {type} Destination", { type }) : t("Add Event Destination")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{EVENT_TARGET_TYPE_OPTIONS.map((option) => (
|
||||
@@ -228,11 +235,11 @@ export function EventsTargetNewForm({ open, onOpenChange, onSuccess }: EventsTar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<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} aria-disabled={submitting}>
|
||||
<Button onClick={handleSave} disabled={!type || submitting}>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -64,6 +64,8 @@ export function getEventsColumns(
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={!row.original.sourceId}
|
||||
aria-label={`${t("Delete Event Subscription")} ${row.original.sourceId ?? t("Unnamed subscription")}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(row.original)
|
||||
|
||||
+188
-109
@@ -4,12 +4,12 @@ import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useEventTarget } from "@/hooks/use-event-target"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
@@ -55,21 +55,31 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
const [suffix, setSuffix] = useState("")
|
||||
const [events, setEvents] = useState<string[]>([])
|
||||
const [arnList, setArnList] = useState<Array<{ label: string; value: string }>>([])
|
||||
const [arnLoading, setArnLoading] = useState(false)
|
||||
const [arnError, setArnError] = useState("")
|
||||
const [arnReloadVersion, setArnReloadVersion] = useState(0)
|
||||
const [resourceNameError, setResourceNameError] = useState("")
|
||||
const [eventsError, setEventsError] = useState("")
|
||||
const [saveError, setSaveError] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const loadArnList = useCallback(async () => {
|
||||
setArnLoading(true)
|
||||
setArnError("")
|
||||
try {
|
||||
const res = (await getEventTargetArnList()) as string[]
|
||||
setArnList((res ?? []).map((item) => ({ label: item, value: item })))
|
||||
} catch {
|
||||
setArnList([])
|
||||
setArnError(t("Unable to load event targets."))
|
||||
} finally {
|
||||
setArnLoading(false)
|
||||
}
|
||||
}, [getEventTargetArnList])
|
||||
}, [getEventTargetArnList, t])
|
||||
|
||||
useEffect(() => {
|
||||
scheduleMicrotask(() => loadArnList())
|
||||
}, [loadArnList])
|
||||
if (open) scheduleMicrotask(() => loadArnList())
|
||||
}, [arnReloadVersion, loadArnList, open])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setResourceName("")
|
||||
@@ -78,6 +88,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
setEvents([])
|
||||
setResourceNameError("")
|
||||
setEventsError("")
|
||||
setSaveError("")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,6 +99,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
|
||||
const handleEventChecked = (eventValue: string, checked: boolean | "indeterminate") => {
|
||||
const isChecked = checked === true || checked === "indeterminate"
|
||||
if (isChecked) setEventsError("")
|
||||
setEvents((prev) =>
|
||||
isChecked ? (prev.includes(eventValue) ? prev : [...prev, eventValue]) : prev.filter((e) => e !== eventValue),
|
||||
)
|
||||
@@ -98,17 +110,23 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
const evErr = events.length === 0 ? t("Please select at least one event") : ""
|
||||
setResourceNameError(rnErr)
|
||||
setEventsError(evErr)
|
||||
if (rnErr) document.getElementById("event-resource-name")?.focus()
|
||||
else if (evErr) document.getElementById("event-option-put")?.focus()
|
||||
return !rnErr && !evErr
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (disabled) {
|
||||
message.warning(t("Notify is disabled. Enable notify before managing bucket event subscriptions."))
|
||||
if (disabled || submitting || arnLoading || arnError) {
|
||||
if (disabled) {
|
||||
message.warning(t("Notify is disabled. Enable notify before managing bucket event subscriptions."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
setSubmitting(true)
|
||||
setSaveError("")
|
||||
try {
|
||||
const s3Events: string[] = []
|
||||
events.forEach((event) => {
|
||||
@@ -125,13 +143,8 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
return
|
||||
}
|
||||
|
||||
let currentNotifications: Record<string, unknown> = {}
|
||||
try {
|
||||
const currentResponse = await listBucketNotifications(bucketName)
|
||||
currentNotifications = (currentResponse ?? {}) as unknown as Record<string, unknown>
|
||||
} catch {
|
||||
currentNotifications = {}
|
||||
}
|
||||
const currentResponse = await listBucketNotifications(bucketName)
|
||||
const currentNotifications = (currentResponse ?? {}) as unknown as Record<string, unknown>
|
||||
|
||||
const arn = resourceName
|
||||
const baseConfig = {
|
||||
@@ -172,11 +185,15 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
} catch (error) {
|
||||
console.error("Failed to create bucket notification:", error)
|
||||
const msg = (error as Error)?.message || t("Create Failed")
|
||||
setSaveError(msg)
|
||||
message.error(msg)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
@@ -184,17 +201,17 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (!nextOpen && details.reason === "escape-key") {
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
onOpenChange(nextOpen)
|
||||
onOpenChange(true)
|
||||
}}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto overflow-x-hidden">
|
||||
<DialogHeader className="text-start">
|
||||
<DialogContent className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12 text-start sm:px-6">
|
||||
<DialogTitle>
|
||||
{t("Subscribe to event notification")}
|
||||
<span className="mt-1 block text-sm font-normal text-muted-foreground">
|
||||
@@ -203,98 +220,160 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-resource-name">{t("Amazon Resource Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
value={resourceName}
|
||||
onValueChange={(value) => setResourceName(value ?? "")}
|
||||
disabled={disabled || !arnList.length}
|
||||
<form
|
||||
className="contents"
|
||||
aria-busy={submitting}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-6 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
{saveError ? (
|
||||
<div role="alert" className="border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
{arnError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Unable to load event targets.")}</AlertTitle>
|
||||
<AlertDescription>{t("Refresh to try again.")}</AlertDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setArnReloadVersion((value) => value + 1)}
|
||||
>
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-resource-name">{t("Amazon Resource Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
value={resourceName}
|
||||
onValueChange={(value) => {
|
||||
setResourceName(value ?? "")
|
||||
if (value) setResourceNameError("")
|
||||
}}
|
||||
disabled={disabled || submitting || arnLoading || Boolean(arnError) || !arnList.length}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="event-resource-name"
|
||||
aria-label={t("Amazon Resource Name")}
|
||||
aria-invalid={Boolean(resourceNameError)}
|
||||
aria-describedby={resourceNameError ? "event-resource-name-error" : undefined}
|
||||
>
|
||||
<SelectValue placeholder={t("Please select resource name")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{arnList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
{arnLoading ? <FieldDescription>{t("Loading")}</FieldDescription> : null}
|
||||
{!arnLoading && !arnError && arnList.length === 0 ? (
|
||||
<FieldDescription>{t("No Data")}</FieldDescription>
|
||||
) : null}
|
||||
{resourceNameError ? (
|
||||
<FieldDescription id="event-resource-name-error" role="alert" className="text-destructive">
|
||||
{resourceNameError}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="event-prefix"
|
||||
name="event-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={disabled || submitting}
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-suffix">{t("Suffix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="event-suffix"
|
||||
name="event-suffix"
|
||||
value={suffix}
|
||||
onChange={(e) => setSuffix(e.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={disabled || submitting}
|
||||
placeholder={t("Please enter suffix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<fieldset
|
||||
aria-invalid={Boolean(eventsError)}
|
||||
aria-describedby={eventsError ? "event-selection-error" : undefined}
|
||||
>
|
||||
<SelectTrigger id="event-resource-name" aria-label={t("Amazon Resource Name")}>
|
||||
<SelectValue placeholder={t("Please select resource name")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{arnList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
{resourceNameError && <FieldDescription className="text-destructive">{resourceNameError}</FieldDescription>}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="event-prefix"
|
||||
name="event-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-suffix">{t("Suffix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="event-suffix"
|
||||
name="event-suffix"
|
||||
value={suffix}
|
||||
onChange={(e) => setSuffix(e.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
placeholder={t("Please enter suffix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Select events")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<ScrollArea className="max-h-64 border">
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label
|
||||
key={event.value}
|
||||
htmlFor={`event-option-${event.value.toLowerCase()}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`event-option-${event.value.toLowerCase()}`}
|
||||
checked={events.includes(event.value)}
|
||||
onCheckedChange={(v) => handleEventChecked(event.value, v)}
|
||||
disabled={disabled}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>{t(event.labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
<legend className="mb-2 text-sm font-medium">{t("Select events")}</legend>
|
||||
<div className="bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label
|
||||
key={event.value}
|
||||
htmlFor={`event-option-${event.value.toLowerCase()}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`event-option-${event.value.toLowerCase()}`}
|
||||
checked={events.includes(event.value)}
|
||||
onCheckedChange={(v) => handleEventChecked(event.value, v)}
|
||||
disabled={disabled || submitting}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>{t(event.labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</FieldContent>
|
||||
{eventsError && <FieldDescription className="text-destructive">{eventsError}</FieldDescription>}
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
{eventsError ? (
|
||||
<FieldDescription id="event-selection-error" role="alert" className="text-destructive">
|
||||
{eventsError}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-6 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={disabled}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={disabled || submitting || arnLoading || Boolean(arnError)}
|
||||
>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export function LicenseArticle() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Page className="max-w-5xl">
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("License")}</h1>
|
||||
</PageHeader>
|
||||
@@ -31,8 +31,8 @@ export function LicenseArticle() {
|
||||
<h2 className="text-xl font-semibold">{t("Apache License")}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("Version 2.0, January 2004")}</p>
|
||||
</div>
|
||||
<ScrollArea className="h-[70vh] border">
|
||||
<pre className="whitespace-pre-wrap p-6 text-sm leading-6 text-muted-foreground">
|
||||
<ScrollArea className="h-[min(70dvh,48rem)] min-h-[28rem] border">
|
||||
<pre className="whitespace-pre-line break-words p-4 text-start text-sm leading-6 text-foreground/80 sm:p-6">
|
||||
{licenseContent || t("Loading…")}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
|
||||
+426
-322
@@ -3,17 +3,20 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useTiers, type TierRow } from "@/hooks/use-tiers"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { randomUUID } from "@/lib/functions"
|
||||
import { isMissingBucketConfiguration } from "@/lib/bucket-configuration"
|
||||
import { buildCurrentVersionExpiration, buildLifecycleFilter } from "@/lib/bucket-lifecycle"
|
||||
|
||||
interface LifecycleNewFormProps {
|
||||
open: boolean
|
||||
@@ -41,8 +44,16 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
const [expiredDeleteMark, setExpiredDeleteMark] = useState(false)
|
||||
const [tags, setTags] = useState<Tag[]>([{ key: "", value: "" }])
|
||||
const [tiers, setTiers] = useState<Array<{ label: string; value: string }>>([])
|
||||
const [tiersLoading, setTiersLoading] = useState(false)
|
||||
const [tiersError, setTiersError] = useState("")
|
||||
const [tiersReloadVersion, setTiersReloadVersion] = useState(0)
|
||||
const [versioningStatus, setVersioningStatus] = useState(false)
|
||||
const [versioningLoading, setVersioningLoading] = useState(false)
|
||||
const [versioningError, setVersioningError] = useState("")
|
||||
const [versioningReloadVersion, setVersioningReloadVersion] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [saveError, setSaveError] = useState("")
|
||||
const [fieldErrors, setFieldErrors] = useState<{ days?: string; storageType?: string }>({})
|
||||
|
||||
const versionOptions = useMemo(
|
||||
() => [
|
||||
@@ -53,6 +64,8 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
)
|
||||
|
||||
const loadTiers = useCallback(async () => {
|
||||
setTiersLoading(true)
|
||||
setTiersError("")
|
||||
try {
|
||||
const res = await listTiers()
|
||||
if (res) {
|
||||
@@ -64,44 +77,56 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
}
|
||||
})
|
||||
setTiers(tierOptions)
|
||||
if (!storageType && tierOptions.length > 0) {
|
||||
setStorageType(tierOptions[0].value)
|
||||
}
|
||||
if (tierOptions.length > 0) setStorageType((current) => current || tierOptions[0].value)
|
||||
}
|
||||
} catch {
|
||||
setTiers([])
|
||||
setTiersError(t("Unable to load storage tiers."))
|
||||
} finally {
|
||||
setTiersLoading(false)
|
||||
}
|
||||
}, [listTiers, storageType])
|
||||
}, [listTiers, t])
|
||||
|
||||
const loadVersioningStatus = useCallback(async () => {
|
||||
if (!bucketName) {
|
||||
setVersioningStatus(false)
|
||||
return
|
||||
}
|
||||
setVersioningLoading(true)
|
||||
setVersioningError("")
|
||||
try {
|
||||
const resp = await getBucketVersioning(bucketName)
|
||||
setVersioningStatus(resp.Status === "Enabled")
|
||||
} catch {
|
||||
setVersioningStatus(false)
|
||||
setVersioningError(t("Unable to load versioning status."))
|
||||
} finally {
|
||||
setVersioningLoading(false)
|
||||
}
|
||||
}, [bucketName, getBucketVersioning])
|
||||
}, [bucketName, getBucketVersioning, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadTiers()
|
||||
loadVersioningStatus()
|
||||
}
|
||||
}, [open, loadTiers, loadVersioningStatus])
|
||||
}, [open, loadTiers, loadVersioningStatus, tiersReloadVersion, versioningReloadVersion])
|
||||
|
||||
useEffect(() => {
|
||||
if (versionType !== "current") setExpiredDeleteMark(false)
|
||||
}, [versionType])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setActiveTab("expire")
|
||||
setVersionType("current")
|
||||
setDays(1)
|
||||
setStorageType(tiers[0]?.value ?? "")
|
||||
setStorageType("")
|
||||
setPrefix("")
|
||||
setExpiredDeleteMark(false)
|
||||
setTags([{ key: "", value: "" }])
|
||||
}, [tiers])
|
||||
setSaveError("")
|
||||
setFieldErrors({})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -123,61 +148,51 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const errors: { days?: string; storageType?: string } = {}
|
||||
if (!days || days < 1) {
|
||||
message.error(t("Please enter valid days"))
|
||||
return false
|
||||
errors.days = t("Please enter valid days")
|
||||
}
|
||||
if (activeTab === "transition" && !storageType) {
|
||||
message.error(t("Please select storage type"))
|
||||
return false
|
||||
errors.storageType = t("Please select storage type")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const buildFilter = () => {
|
||||
const validTags = tags.filter((tag) => tag.key && tag.value)
|
||||
if (!prefix && validTags.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const filter: Record<string, unknown> = {}
|
||||
const [firstTag] = validTags
|
||||
|
||||
if (firstTag && validTags.length === 1 && !prefix) {
|
||||
filter.Tag = { Key: firstTag.key, Value: firstTag.value }
|
||||
} else if (validTags.length > 0) {
|
||||
filter.And = {
|
||||
Tags: validTags.map((tag) => ({ Key: tag.key, Value: tag.value })),
|
||||
...(prefix ? { Prefix: prefix } : {}),
|
||||
}
|
||||
} else if (prefix) {
|
||||
filter.Prefix = prefix
|
||||
}
|
||||
|
||||
return Object.keys(filter).length ? filter : undefined
|
||||
setFieldErrors(errors)
|
||||
const firstErrorId = errors.days
|
||||
? activeTab === "transition"
|
||||
? "lifecycle-transition-days"
|
||||
: "lifecycle-expire-days"
|
||||
: errors.storageType
|
||||
? "lifecycle-storage-type"
|
||||
: null
|
||||
if (firstErrorId) document.getElementById(firstErrorId)?.focus()
|
||||
return !firstErrorId
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (submitting) return
|
||||
if (versioningLoading || versioningError || (activeTab === "transition" && (tiersLoading || tiersError))) return
|
||||
if (!validate()) return
|
||||
if (!bucketName) {
|
||||
message.error(t("Bucket name is required"))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setSaveError("")
|
||||
try {
|
||||
let currentConfig: { Rules?: unknown[] } | null = null
|
||||
try {
|
||||
currentConfig = (await getBucketLifecycleConfiguration(bucketName)) as {
|
||||
Rules?: unknown[]
|
||||
}
|
||||
} catch {
|
||||
currentConfig = null
|
||||
} catch (error) {
|
||||
if (!isMissingBucketConfiguration(error, "lifecycle")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const newRule: Record<string, unknown> = {
|
||||
ID: randomUUID(),
|
||||
Status: "Enabled",
|
||||
Filter: buildFilter(),
|
||||
Filter: buildLifecycleFilter(prefix, tags),
|
||||
}
|
||||
|
||||
const daysValue = Number(days)
|
||||
@@ -185,14 +200,8 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
if (activeTab === "expire") {
|
||||
if (versionType === "non-current") {
|
||||
newRule.NoncurrentVersionExpiration = { NoncurrentDays: daysValue }
|
||||
if (expiredDeleteMark) {
|
||||
newRule.ExpiredObjectDeleteMarker = true
|
||||
}
|
||||
} else {
|
||||
newRule.Expiration = {
|
||||
Days: daysValue,
|
||||
...(expiredDeleteMark ? { ExpiredObjectDeleteMarker: true } : {}),
|
||||
}
|
||||
newRule.Expiration = buildCurrentVersionExpiration(daysValue, expiredDeleteMark)
|
||||
}
|
||||
} else {
|
||||
if (versionType === "non-current") {
|
||||
@@ -211,13 +220,16 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Failed to create rule"))
|
||||
const errorMessage = (error as Error).message || t("Failed to create rule")
|
||||
setSaveError(errorMessage)
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
@@ -225,319 +237,411 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (!nextOpen && details.reason === "escape-key") {
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
onOpenChange(nextOpen)
|
||||
onOpenChange(true)
|
||||
}}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12 sm:px-6">
|
||||
<DialogTitle>
|
||||
{t("Add Lifecycle Rule")} ({t("Bucket")}: {bucketName || ""})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "expire" | "transition")}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<TabsList className="justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="expire">{t("Expiration")}</TabsTrigger>
|
||||
<TabsTrigger value="transition">{t("Transition")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="expire" className="mt-0 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{versioningStatus && (
|
||||
<Field>
|
||||
<FieldLabel>{t("Object Version")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={versionType} onValueChange={(value) => setVersionType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Object Version")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Time Cycle")}</FieldLabel>
|
||||
<FieldDescription>{t("Set the time cycle for the rule")}</FieldDescription>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
name="lifecycle-expire-days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
aria-label={t("Days")}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="w-32"
|
||||
placeholder={t("Days")}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("Days After")}</span>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<form
|
||||
className="contents"
|
||||
aria-busy={submitting}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleSave()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-6 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
{saveError ? (
|
||||
<div role="alert" className="border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
{versioningError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Unable to load versioning status.")}</AlertTitle>
|
||||
<AlertDescription>{t("Refresh to try again.")}</AlertDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setVersioningReloadVersion((value) => value + 1)}
|
||||
>
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
{tiersError && activeTab === "transition" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("Unable to load storage tiers.")}</AlertTitle>
|
||||
<AlertDescription>{t("Refresh to try again.")}</AlertDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setTiersReloadVersion((value) => value + 1)}
|
||||
>
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "expire" | "transition")}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<TabsList className="justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="expire">{t("Expiration")}</TabsTrigger>
|
||||
<TabsTrigger value="transition">{t("Transition")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="expire" className="mt-0 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{versioningStatus && (
|
||||
<Field>
|
||||
<FieldLabel>{t("Object Version")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={versionType} onValueChange={(value) => setVersionType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Object Version")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-primary">
|
||||
{t("More Configurations")}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lifecycle-expire-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldLabel>{t("Time Cycle")}</FieldLabel>
|
||||
<FieldDescription>{t("Set the time cycle for the rule")}</FieldDescription>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="lifecycle-expire-prefix"
|
||||
name="lifecycle-expire-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
|
||||
<Button variant="outline" size="sm" onClick={addTag}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Add Tag")}
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="grid gap-2 md:grid-cols-2 md:items-center md:gap-4">
|
||||
<Input
|
||||
id={`lifecycle-expire-tag-key-${index}`}
|
||||
name={`lifecycle-expire-tag-key-${index}`}
|
||||
value={tag.key}
|
||||
onChange={(e) => updateTag(index, "key", e.target.value)}
|
||||
aria-label={`${t("Tag Name")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Name")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`lifecycle-expire-tag-value-${index}`}
|
||||
name={`lifecycle-expire-tag-value-${index}`}
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(index, "value", e.target.value)}
|
||||
aria-label={`${t("Tag Value")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Value")}
|
||||
className="flex-1"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={tags.length === 1}
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="lifecycle-expire-days"
|
||||
name="lifecycle-expire-days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
aria-label={t("Days")}
|
||||
aria-invalid={Boolean(fieldErrors.days)}
|
||||
aria-describedby={fieldErrors.days ? "lifecycle-expire-days-error" : undefined}
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(Number(e.target.value))
|
||||
setFieldErrors((current) => ({ ...current, days: undefined }))
|
||||
}}
|
||||
className="w-32"
|
||||
placeholder={t("Days")}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("Days After")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldError id="lifecycle-expire-days-error">{fieldErrors.days}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{versionType === "current" && (
|
||||
<details>
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-primary">
|
||||
{t("Advanced Settings")}
|
||||
{t("More Configurations")}
|
||||
</summary>
|
||||
<Field className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<FieldLabel htmlFor="lifecycle-expired-delete-marker" className="text-sm font-medium">
|
||||
{t("Delete Marker Handling")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{t("If no versions remain, delete references to this object")}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Switch
|
||||
id="lifecycle-expired-delete-marker"
|
||||
checked={expiredDeleteMark}
|
||||
onCheckedChange={setExpiredDeleteMark}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</details>
|
||||
)}
|
||||
</TabsContent>
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lifecycle-expire-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="lifecycle-expire-prefix"
|
||||
name="lifecycle-expire-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addTag} disabled={submitting}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Add Tag")}
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="grid gap-2 md:grid-cols-2 md:items-center md:gap-4">
|
||||
<Input
|
||||
id={`lifecycle-expire-tag-key-${index}`}
|
||||
name={`lifecycle-expire-tag-key-${index}`}
|
||||
value={tag.key}
|
||||
onChange={(e) => updateTag(index, "key", e.target.value)}
|
||||
aria-label={`${t("Tag Name")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Name")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`lifecycle-expire-tag-value-${index}`}
|
||||
name={`lifecycle-expire-tag-value-${index}`}
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(index, "value", e.target.value)}
|
||||
aria-label={`${t("Tag Value")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Value")}
|
||||
className="flex-1"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={tags.length === 1 || submitting}
|
||||
aria-label={`${t("Delete")} ${t("Tag Name")} ${index + 1}`}
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{versionType === "current" && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium text-primary">
|
||||
{t("Advanced Settings")}
|
||||
</summary>
|
||||
<Field className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<FieldLabel htmlFor="lifecycle-expired-delete-marker" className="text-sm font-medium">
|
||||
{t("Delete Marker Handling")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{t("If no versions remain, delete references to this object")}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Switch
|
||||
id="lifecycle-expired-delete-marker"
|
||||
checked={expiredDeleteMark}
|
||||
onCheckedChange={setExpiredDeleteMark}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</details>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transition" className="mt-0 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{versioningStatus && (
|
||||
<Field>
|
||||
<FieldLabel>{t("Object Version")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={versionType} onValueChange={(value) => setVersionType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Object Version")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<TabsContent value="transition" className="mt-0 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{versioningStatus && (
|
||||
<Field>
|
||||
<FieldLabel>{t("Object Version")}</FieldLabel>
|
||||
<FieldLabel>{t("Time Cycle")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={versionType} onValueChange={(value) => setVersionType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Object Version")}>
|
||||
<SelectValue />
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="lifecycle-transition-days"
|
||||
name="lifecycle-transition-days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
aria-label={t("Days")}
|
||||
aria-invalid={Boolean(fieldErrors.days)}
|
||||
aria-describedby={fieldErrors.days ? "lifecycle-transition-days-error" : undefined}
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
setDays(Number(e.target.value))
|
||||
setFieldErrors((current) => ({ ...current, days: undefined }))
|
||||
}}
|
||||
className="w-32"
|
||||
placeholder={t("Days")}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("Days After")}</span>
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldError id="lifecycle-transition-days-error">{fieldErrors.days}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Storage Type")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
value={storageType}
|
||||
onValueChange={(value) => {
|
||||
setStorageType(value ?? "")
|
||||
setFieldErrors((current) => ({ ...current, storageType: undefined }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="lifecycle-storage-type"
|
||||
className="w-full"
|
||||
aria-label={t("Storage Type")}
|
||||
aria-invalid={Boolean(fieldErrors.storageType)}
|
||||
aria-describedby={fieldErrors.storageType ? "lifecycle-storage-type-error" : undefined}
|
||||
disabled={tiersLoading || Boolean(tiersError)}
|
||||
>
|
||||
<SelectValue placeholder={t("Please select storage type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{tiers.map((tier) => (
|
||||
<SelectItem key={tier.value} value={tier.value}>
|
||||
{tier.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
{tiersLoading ? <FieldDescription>{t("Loading")}</FieldDescription> : null}
|
||||
<FieldError id="lifecycle-storage-type-error">{fieldErrors.storageType}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Time Cycle")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
name="lifecycle-transition-days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
aria-label={t("Days")}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="w-32"
|
||||
placeholder={t("Days")}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("Days After")}</span>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-primary">
|
||||
{t("More Configurations")}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lifecycle-transition-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="lifecycle-transition-prefix"
|
||||
name="lifecycle-transition-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Storage Type")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={storageType} onValueChange={(value) => setStorageType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Storage Type")}>
|
||||
<SelectValue placeholder={t("Please select storage type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tiers.map((tier) => (
|
||||
<SelectItem key={tier.value} value={tier.value}>
|
||||
{tier.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-primary">
|
||||
{t("More Configurations")}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lifecycle-transition-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="lifecycle-transition-prefix"
|
||||
name="lifecycle-transition-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
|
||||
<Button variant="outline" size="sm" onClick={addTag}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Add Tag")}
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="grid gap-2 border p-3 md:grid-cols-2 md:items-center md:gap-4">
|
||||
<Input
|
||||
id={`lifecycle-transition-tag-key-${index}`}
|
||||
name={`lifecycle-transition-tag-key-${index}`}
|
||||
value={tag.key}
|
||||
onChange={(e) => updateTag(index, "key", e.target.value)}
|
||||
aria-label={`${t("Tag Name")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Name")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addTag} disabled={submitting}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Add Tag")}
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="grid gap-2 border p-3 md:grid-cols-2 md:items-center md:gap-4">
|
||||
<Input
|
||||
id={`lifecycle-transition-tag-value-${index}`}
|
||||
name={`lifecycle-transition-tag-value-${index}`}
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(index, "value", e.target.value)}
|
||||
aria-label={`${t("Tag Value")} ${index + 1}`}
|
||||
id={`lifecycle-transition-tag-key-${index}`}
|
||||
name={`lifecycle-transition-tag-key-${index}`}
|
||||
value={tag.key}
|
||||
onChange={(e) => updateTag(index, "key", e.target.value)}
|
||||
aria-label={`${t("Tag Name")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Value")}
|
||||
className="flex-1"
|
||||
placeholder={t("Tag Name")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={tags.length === 1}
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`lifecycle-transition-tag-value-${index}`}
|
||||
name={`lifecycle-transition-tag-value-${index}`}
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(index, "value", e.target.value)}
|
||||
aria-label={`${t("Tag Value")} ${index + 1}`}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Value")}
|
||||
className="flex-1"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={tags.length === 1 || submitting}
|
||||
aria-label={`${t("Delete")} ${t("Tag Name")} ${index + 1}`}
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</details>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={submitting}>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={
|
||||
submitting ||
|
||||
versioningLoading ||
|
||||
Boolean(versioningError) ||
|
||||
(activeTab === "transition" && (tiersLoading || Boolean(tiersError)))
|
||||
}
|
||||
>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
+83
-13
@@ -126,6 +126,8 @@ export function ObjectList({
|
||||
const [nextToken, setNextToken] = React.useState<string | undefined>()
|
||||
const [showScrollShortcuts, setShowScrollShortcuts] = React.useState(false)
|
||||
const [bucketVersioningState, setBucketVersioningState] = React.useState<BucketVersioningState>("unknown")
|
||||
const [versioningError, setVersioningError] = React.useState("")
|
||||
const [versioningReload, setVersioningReload] = React.useState(0)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
|
||||
const [deleteDialogKeys, setDeleteDialogKeys] = React.useState<string[]>([])
|
||||
const [deleteAllVersions, setDeleteAllVersions] = React.useState(false)
|
||||
@@ -324,15 +326,18 @@ export function ObjectList({
|
||||
|
||||
const loadBucketVersioningStatus = async () => {
|
||||
setBucketVersioningState("unknown")
|
||||
setVersioningError("")
|
||||
|
||||
try {
|
||||
const resp = await getBucketVersioning(bucket)
|
||||
if (!cancelled) {
|
||||
setBucketVersioningState(resolveBucketVersioningState((resp as { Status?: string })?.Status))
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Failed to load bucket versioning status:", error)
|
||||
if (!cancelled) {
|
||||
setBucketVersioningState("disabled")
|
||||
setBucketVersioningState("unknown")
|
||||
setVersioningError(t("Failed to get data"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,7 +347,7 @@ export function ObjectList({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bucket, getBucketVersioning])
|
||||
}, [bucket, getBucketVersioning, t, versioningReload])
|
||||
|
||||
const displayKey = React.useCallback(
|
||||
(key: string) => {
|
||||
@@ -402,6 +407,20 @@ export function ObjectList({
|
||||
|
||||
const renameValidation = renameSourceKey ? validateObjectRename(renameSourceKey, renameName) : "empty"
|
||||
|
||||
const openDeleteDialog = React.useCallback(
|
||||
(keys: string[]) => {
|
||||
if (bucketVersioningState === "unknown") {
|
||||
message.error(t("Failed to get data"))
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteDialogKeys(keys)
|
||||
setDeleteAllVersions(false)
|
||||
setDeleteDialogOpen(true)
|
||||
},
|
||||
[bucketVersioningState, message, t],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<ObjectRow>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -487,7 +506,13 @@ export function ObjectList({
|
||||
</>
|
||||
) : null}
|
||||
{canCapability("objects.delete", { bucket, objectKey: row.original.Key }) ? (
|
||||
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog([row.original.Key])}
|
||||
disabled={bucketVersioningState === "unknown"}
|
||||
aria-describedby={bucketVersioningState === "unknown" ? "object-versioning-status" : undefined}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -496,7 +521,19 @@ export function ObjectList({
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview, prefix],
|
||||
[
|
||||
t,
|
||||
displayKey,
|
||||
bucketPath,
|
||||
onOpenInfo,
|
||||
bucket,
|
||||
downloadFile,
|
||||
canCapability,
|
||||
onPreview,
|
||||
prefix,
|
||||
bucketVersioningState,
|
||||
openDeleteDialog,
|
||||
],
|
||||
)
|
||||
|
||||
const { table, selectedRowIds } = useDataTable<ObjectRow>({
|
||||
@@ -545,12 +582,6 @@ export function ObjectList({
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteDialog = (keys: string[]) => {
|
||||
setDeleteDialogKeys(keys)
|
||||
setDeleteAllVersions(false)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const openRenameDialog = (key: string) => {
|
||||
setRenameSourceKey(key)
|
||||
setRenameName(getObjectBaseName(key))
|
||||
@@ -596,6 +627,11 @@ export function ObjectList({
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (bucketVersioningState === "unknown") {
|
||||
message.error(t("Failed to get data"))
|
||||
return
|
||||
}
|
||||
|
||||
const keys = [...deleteDialogKeys]
|
||||
setDeleteDialogOpen(false)
|
||||
if (!keys.length) return
|
||||
@@ -720,7 +756,13 @@ export function ObjectList({
|
||||
{checkedKeys.length > 0 ? (
|
||||
<>
|
||||
{canBulkDelete ? (
|
||||
<Button variant="outline" className="text-destructive border-destructive" onClick={handleBatchDelete}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive text-destructive"
|
||||
onClick={handleBatchDelete}
|
||||
disabled={bucketVersioningState === "unknown"}
|
||||
aria-describedby={bucketVersioningState === "unknown" ? "object-versioning-status" : undefined}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete Selected")}</span>
|
||||
</Button>
|
||||
@@ -762,6 +804,29 @@ export function ObjectList({
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{bucketVersioningState === "unknown" ? (
|
||||
<div
|
||||
id="object-versioning-status"
|
||||
role={versioningError ? "alert" : "status"}
|
||||
className="flex flex-col gap-3 border border-border bg-muted/30 p-3 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span className={versioningError ? "text-destructive" : "text-muted-foreground"}>
|
||||
{versioningError || t("Loading…")}
|
||||
</span>
|
||||
{versioningError ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setVersioningReload((value) => value + 1)}
|
||||
>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading && data.length === 0}
|
||||
@@ -846,7 +911,12 @@ export function ObjectList({
|
||||
/>
|
||||
<AlertDialogAction
|
||||
render={
|
||||
<Button variant="destructive" className="w-full sm:w-auto text-white" onClick={handleConfirmDelete}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full text-white sm:w-auto"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={bucketVersioningState === "unknown"}
|
||||
>
|
||||
{t("Confirm")}
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -578,22 +578,22 @@ export function ObjectUploadPicker({
|
||||
)}
|
||||
|
||||
{isFolderLoading && (
|
||||
<div className="space-y-2 border border-dashed p-3">
|
||||
<div className="space-y-2 border-t bg-muted/30 p-3" role="status" aria-live="polite">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{t("Reading Folder Files")}</span>
|
||||
<span>{folderLoadingProgress}%</span>
|
||||
</div>
|
||||
<Progress value={folderLoadingProgress} className="h-2" />
|
||||
<Progress value={folderLoadingProgress} className="h-2" aria-label={t("Reading Folder Files")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<div className="space-y-2 border border-dashed p-3">
|
||||
<div className="space-y-2 border-t bg-muted/30 p-3" role="status" aria-live="polite">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{t("Adding to Upload Queue")}</span>
|
||||
<span>{addProgress}%</span>
|
||||
</div>
|
||||
<Progress value={addProgress} className="h-2" />
|
||||
<Progress value={addProgress} className="h-2" aria-label={t("Adding to Upload Queue")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+127
-116
@@ -58,11 +58,11 @@ export function OidcForm({
|
||||
const showRedirectUri = !values.redirect_uri_dynamic
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border p-4 md:p-6">
|
||||
<section className="space-y-4 border p-4 md:p-6" aria-labelledby="oidc-provider-form-title">
|
||||
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<h2 id="oidc-provider-form-title" className="text-lg font-semibold">
|
||||
{isCreateMode
|
||||
? t("Add Provider")
|
||||
: values.display_name.trim() || values.provider_id || t("OIDC Provider")}
|
||||
@@ -155,7 +155,7 @@ export function OidcForm({
|
||||
<FieldError>{errors.provider_id}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<Field orientation="responsive" className="items-start gap-3 py-2">
|
||||
<FieldLabel htmlFor="enabled">{t("Enabled")}</FieldLabel>
|
||||
<FieldContent className="gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -283,7 +283,7 @@ export function OidcForm({
|
||||
<FieldError>{errors.other_audiences}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<Field orientation="responsive" className="items-start gap-3 py-2">
|
||||
<FieldLabel htmlFor="redirect_uri_dynamic">{t("Use Dynamic Redirect URI")}</FieldLabel>
|
||||
<FieldContent className="gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -329,124 +329,135 @@ export function OidcForm({
|
||||
<FieldError>{errors.redirect_uri}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="claim_name">{t("Claim Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="claim_name"
|
||||
name="claim_name"
|
||||
value={values.claim_name}
|
||||
onChange={(event) => onChange("claim_name", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Claim Name")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<details className="space-y-4 border-t pt-4 md:col-span-2">
|
||||
<summary className="cursor-pointer text-sm font-semibold">
|
||||
{t("Advanced Settings")}
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
{t("Claim Name")} · {t("Groups Claim")} · {t("Roles Claim")}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="claim_prefix">{t("Claim Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="claim_prefix"
|
||||
name="claim_prefix"
|
||||
value={values.claim_prefix}
|
||||
onChange={(event) => onChange("claim_prefix", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Claim Prefix")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="claim_name">{t("Claim Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="claim_name"
|
||||
name="claim_name"
|
||||
value={values.claim_name}
|
||||
onChange={(event) => onChange("claim_name", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Claim Name")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="role_policy">{t("Role Policy")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="role_policy"
|
||||
name="role_policy"
|
||||
value={values.role_policy}
|
||||
onChange={(event) => onChange("role_policy", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Role Policy")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t("Optional. Leave empty to let the backend apply its default role mapping.")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="claim_prefix">{t("Claim Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="claim_prefix"
|
||||
name="claim_prefix"
|
||||
value={values.claim_prefix}
|
||||
onChange={(event) => onChange("claim_prefix", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Claim Prefix")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="groups_claim">{t("Groups Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="groups_claim"
|
||||
name="groups_claim"
|
||||
value={values.groups_claim}
|
||||
onChange={(event) => onChange("groups_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Groups Claim")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="role_policy">{t("Role Policy")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="role_policy"
|
||||
name="role_policy"
|
||||
value={values.role_policy}
|
||||
onChange={(event) => onChange("role_policy", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Role Policy")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t("Optional. Leave empty to let the backend apply its default role mapping.")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="roles_claim">{t("Roles Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="roles_claim"
|
||||
name="roles_claim"
|
||||
value={values.roles_claim}
|
||||
onChange={(event) => onChange("roles_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="roles"
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t("Optional. Separate claim for role values (e.g. roles). Leave empty to use groups claim only.")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="groups_claim">{t("Groups Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="groups_claim"
|
||||
name="groups_claim"
|
||||
value={values.groups_claim}
|
||||
onChange={(event) => onChange("groups_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Groups Claim")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email_claim">{t("Email Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="email_claim"
|
||||
name="email_claim"
|
||||
value={values.email_claim}
|
||||
onChange={(event) => onChange("email_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Email Claim")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="roles_claim">{t("Roles Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="roles_claim"
|
||||
name="roles_claim"
|
||||
value={values.roles_claim}
|
||||
onChange={(event) => onChange("roles_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="roles"
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t("Optional. Separate claim for role values (e.g. roles). Leave empty to use groups claim only.")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username_claim">{t("Username Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="username_claim"
|
||||
name="username_claim"
|
||||
value={values.username_claim}
|
||||
onChange={(event) => onChange("username_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Username Claim")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email_claim">{t("Email Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="email_claim"
|
||||
name="email_claim"
|
||||
value={values.email_claim}
|
||||
onChange={(event) => onChange("email_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Email Claim")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username_claim">{t("Username Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="username_claim"
|
||||
name="username_claim"
|
||||
value={values.username_claim}
|
||||
onChange={(event) => onChange("username_claim", event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Username Claim")}
|
||||
spellCheck={false}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ProviderList({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[28rem] flex-col border">
|
||||
<div className="flex max-h-80 min-h-0 flex-col overflow-hidden border lg:max-h-none lg:min-h-[28rem]">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">{t("OIDC Providers")}</h2>
|
||||
@@ -46,7 +46,7 @@ export function ProviderList({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
|
||||
@@ -16,16 +16,18 @@ export function PageHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-10 flex flex-col justify-between gap-3 bg-background/95 py-2 backdrop-blur lg:flex-row lg:items-start",
|
||||
"flex flex-col justify-between gap-4 bg-background py-1 lg:sticky lg:top-0 lg:z-10 lg:flex-row lg:items-start lg:bg-background/95 lg:py-2 lg:backdrop-blur",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-2 [&_h1]:text-pretty [&_h1]:break-words [&_h2]:text-pretty [&_h2]:break-words">
|
||||
<div className="min-w-0 space-y-2 [&_h1]:break-words [&_h1]:font-heading [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:text-pretty [&_h2]:break-words [&_h2]:font-heading [&_h2]:font-semibold [&_h2]:tracking-tight [&_h2]:text-pretty">
|
||||
{children}
|
||||
{description && <div className="max-w-3xl text-pretty break-words">{description}</div>}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 lg:w-auto lg:flex-1 lg:justify-end">{actions}</div>
|
||||
<div className="flex w-full flex-nowrap items-center gap-2 overflow-x-auto pb-1 lg:w-auto lg:flex-1 lg:flex-wrap lg:justify-end lg:overflow-visible lg:pb-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { usePolicies } from "@/hooks/use-policies"
|
||||
@@ -32,6 +32,7 @@ export function PolicyForm({ show, onShowChange, policy, onSaved }: PolicyFormPr
|
||||
const [content, setContent] = React.useState("")
|
||||
const [errors, setErrors] = React.useState({ name: "", content: "" })
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const isEditing = Boolean(policy?.name)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (show && policy) {
|
||||
@@ -50,6 +51,12 @@ export function PolicyForm({ show, onShowChange, policy, onSaved }: PolicyFormPr
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextShow: boolean) => {
|
||||
if (!submitting || nextShow) {
|
||||
onShowChange(nextShow)
|
||||
}
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = { name: "", content: "" }
|
||||
if (!name.trim()) {
|
||||
@@ -59,7 +66,12 @@ export function PolicyForm({ show, onShowChange, policy, onSaved }: PolicyFormPr
|
||||
newErrors.content = t("Please enter policy content")
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(content)
|
||||
const parsed = JSON.parse(content)
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
newErrors.content = t("Policy must be a valid JSON object")
|
||||
} else if (parsed.Statement !== undefined && !Array.isArray(parsed.Statement)) {
|
||||
newErrors.content = t("Policy Statement must be an array")
|
||||
}
|
||||
} catch {
|
||||
newErrors.content = t("Policy format invalid")
|
||||
}
|
||||
@@ -82,6 +94,7 @@ export function PolicyForm({ show, onShowChange, policy, onSaved }: PolicyFormPr
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (submitting) return
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
@@ -104,61 +117,87 @@ export function PolicyForm({ show, onShowChange, policy, onSaved }: PolicyFormPr
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Policy Original")}</DialogTitle>
|
||||
<Dialog open={show} onOpenChange={handleOpenChange} disablePointerDismissal={submitting}>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-3xl"
|
||||
aria-busy={submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{isEditing ? t("Edit Policy") : t("New Policy")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 max-h-[80vh] overflow-auto px-2 -mx-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="policy-name">{t("Policy Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="policy-name"
|
||||
name="policy-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
{errors.name && <FieldDescription className="text-destructive">{errors.name}</FieldDescription>}
|
||||
</Field>
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-4 overflow-hidden p-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="policy-name">{t("Policy Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="policy-name"
|
||||
name="policy-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={isEditing || submitting}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? "policy-name-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="policy-name-error">{errors.name}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="policy-content">{t("Policy Original")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="max-h-[60vh] overflow-auto border">
|
||||
<Field className="flex min-h-0 flex-1 flex-col">
|
||||
<FieldLabel htmlFor="policy-content">{t("Policy")}</FieldLabel>
|
||||
<FieldContent className="flex min-h-0 flex-1">
|
||||
<Textarea
|
||||
id="policy-content"
|
||||
name="policy-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value)
|
||||
if (errors.content) setErrors((current) => ({ ...current, content: "" }))
|
||||
}}
|
||||
className="min-h-[12rem] flex-1 resize-none overflow-y-auto font-mono text-xs"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.content)}
|
||||
aria-describedby={errors.content ? "policy-content-error" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</FieldContent>
|
||||
{errors.content && <FieldDescription className="text-destructive">{errors.content}</FieldDescription>}
|
||||
</Field>
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldError id="policy-content-error">{errors.content}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeModal}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="default" className="w-full sm:w-auto" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Save")}
|
||||
</span>
|
||||
) : (
|
||||
t("Save")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -4,36 +4,19 @@ import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
import type { PoolsOverview } from "@/lib/pool-operations"
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium">{value}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytesValue(value?: number) {
|
||||
return value === undefined ? "--" : niceBytes(String(value))
|
||||
}
|
||||
|
||||
function formatPercentValue(value?: number) {
|
||||
return value === undefined ? "--" : `${value.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function PoolsOverviewCard({
|
||||
overview,
|
||||
operationLabel,
|
||||
showDecommissionColumns = false,
|
||||
}: {
|
||||
overview: PoolsOverview
|
||||
operationLabel: string
|
||||
showDecommissionColumns?: boolean
|
||||
}) {
|
||||
export function PoolsOverviewCard({ overview, operationLabel }: { overview: PoolsOverview; operationLabel: string }) {
|
||||
const { t } = useTranslation()
|
||||
const usedPercent = useMemo(() => {
|
||||
if (!overview.totalCapacity) return 0
|
||||
@@ -42,7 +25,7 @@ export function PoolsOverviewCard({
|
||||
|
||||
return (
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{operationLabel}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("Pool overview for the current cluster state.")}</p>
|
||||
@@ -51,83 +34,11 @@ export function PoolsOverviewCard({
|
||||
{overview.supportState === "supported" ? t("Supported") : t("Unsupported")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-4">
|
||||
<CardContent className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Stat label={t("Pool Count")} value={String(overview.poolCount)} />
|
||||
<Stat
|
||||
label={t("Total Capacity")}
|
||||
value={overview.totalCapacity ? niceBytes(String(overview.totalCapacity)) : "--"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("Used Capacity")}
|
||||
value={overview.totalUsedCapacity ? niceBytes(String(overview.totalUsedCapacity)) : "--"}
|
||||
/>
|
||||
<Stat label={t("Total Capacity")} value={niceBytes(String(overview.totalCapacity))} />
|
||||
<Stat label={t("Used Capacity")} value={niceBytes(String(overview.totalUsedCapacity))} />
|
||||
<Stat label={t("Usage")} value={`${usedPercent}%`} />
|
||||
<div className="md:col-span-4">
|
||||
<Table className="min-w-[1120px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ID")}</TableHead>
|
||||
<TableHead>{t("Pool")}</TableHead>
|
||||
<TableHead>{t("Status")}</TableHead>
|
||||
<TableHead>{t("Total Capacity")}</TableHead>
|
||||
<TableHead>{t("Used Capacity")}</TableHead>
|
||||
<TableHead>{t("Available")}</TableHead>
|
||||
<TableHead>{t("Usage")}</TableHead>
|
||||
<TableHead>{t("Updated At")}</TableHead>
|
||||
{showDecommissionColumns ? (
|
||||
<>
|
||||
<TableHead>{t("Start Time")}</TableHead>
|
||||
<TableHead>{t("Start Size")}</TableHead>
|
||||
<TableHead>{t("Complete")}</TableHead>
|
||||
<TableHead>{t("Failed Status")}</TableHead>
|
||||
<TableHead>{t("Canceled")}</TableHead>
|
||||
<TableHead>{t("Objects")}</TableHead>
|
||||
<TableHead>{t("Objects Failed")}</TableHead>
|
||||
<TableHead>{t("Bytes Moved")}</TableHead>
|
||||
<TableHead>{t("Bytes Failed")}</TableHead>
|
||||
</>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={showDecommissionColumns ? 17 : 8} className="text-center text-muted-foreground">
|
||||
{t("No Data")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
overview.pools.map((pool) => (
|
||||
<TableRow key={pool.id}>
|
||||
<TableCell>{pool.id}</TableCell>
|
||||
<TableCell className="max-w-[360px] truncate" title={pool.name}>
|
||||
{pool.name}
|
||||
</TableCell>
|
||||
<TableCell>{pool.status || "--"}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.total)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.used)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.available)}</TableCell>
|
||||
<TableCell>{formatPercentValue(pool.usagePercent)}</TableCell>
|
||||
<TableCell>{formatDateTime(pool.lastUpdate)}</TableCell>
|
||||
{showDecommissionColumns ? (
|
||||
<>
|
||||
<TableCell>{formatDateTime(pool.decommission.startTime)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.decommission.startSize)}</TableCell>
|
||||
<TableCell>{pool.decommission.complete ? t("Yes") : t("No")}</TableCell>
|
||||
<TableCell>{pool.decommission.failed ? t("Yes") : t("No")}</TableCell>
|
||||
<TableCell>{pool.decommission.canceled ? t("Yes") : t("No")}</TableCell>
|
||||
<TableCell>{formatInteger(pool.decommission.objects)}</TableCell>
|
||||
<TableCell>{formatInteger(pool.decommission.objectsFailed)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.decommission.bytes)}</TableCell>
|
||||
<TableCell>{formatBytesValue(pool.decommission.bytesFailed)}</TableCell>
|
||||
</>
|
||||
) : null}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
+442
-342
@@ -9,10 +9,11 @@ import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { getBytes } from "@/lib/functions"
|
||||
import { getBytes, randomUUID } from "@/lib/functions"
|
||||
import { isMissingBucketConfiguration, normalizeReplicationRulesForRolelessConfig } from "@/lib/bucket-configuration"
|
||||
|
||||
interface ReplicationNewFormProps {
|
||||
open: boolean
|
||||
@@ -26,6 +27,10 @@ interface Tag {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ReplicationRulePayload extends Record<string, unknown> {
|
||||
Destination?: { Bucket?: string; StorageClass?: string }
|
||||
}
|
||||
|
||||
export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }: ReplicationNewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
@@ -49,6 +54,14 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }
|
||||
const [expiredDeleteMark, setExpiredDeleteMark] = useState(true)
|
||||
const [replicateDelete, setReplicateDelete] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [saveError, setSaveError] = useState("")
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
endpoint?: string
|
||||
bucket?: string
|
||||
accessKey?: string
|
||||
secretKey?: string
|
||||
timecheck?: string
|
||||
}>({})
|
||||
|
||||
const modeOptions = useMemo(
|
||||
() => [
|
||||
@@ -85,6 +98,8 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }
|
||||
setExistingObject(true)
|
||||
setExpiredDeleteMark(true)
|
||||
setReplicateDelete(true)
|
||||
setSaveError("")
|
||||
setFieldErrors({})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,32 +122,44 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const errors: typeof fieldErrors = {}
|
||||
if (!endpoint) {
|
||||
message.error(t("Please enter endpoint"))
|
||||
return false
|
||||
errors.endpoint = t("Please enter endpoint")
|
||||
}
|
||||
if (!bucket) {
|
||||
message.error(t("Please enter bucket"))
|
||||
return false
|
||||
}
|
||||
if (!accessKey || !secretKey) {
|
||||
message.error(t("Please provide credentials"))
|
||||
return false
|
||||
errors.bucket = t("Please enter bucket")
|
||||
}
|
||||
if (!accessKey) errors.accessKey = t("Please enter Access Key")
|
||||
if (!secretKey) errors.secretKey = t("Please enter Secret Key")
|
||||
if (modeType === "async" && Number(timecheck) < 1) {
|
||||
message.error(t("Please enter valid health check interval"))
|
||||
return false
|
||||
errors.timecheck = t("Please enter valid health check interval")
|
||||
}
|
||||
return true
|
||||
setFieldErrors(errors)
|
||||
const firstErrorId = errors.endpoint
|
||||
? "replication-endpoint"
|
||||
: errors.bucket
|
||||
? "replication-bucket"
|
||||
: errors.accessKey
|
||||
? "replication-access-key"
|
||||
: errors.secretKey
|
||||
? "replication-secret-key"
|
||||
: errors.timecheck
|
||||
? "replication-health-check-interval"
|
||||
: null
|
||||
if (firstErrorId) document.getElementById(firstErrorId)?.focus()
|
||||
return !firstErrorId
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (submitting) return
|
||||
if (!validate()) return
|
||||
if (!bucketName) {
|
||||
message.error(t("Bucket name is required"))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setSaveError("")
|
||||
let remoteTargetSaved = false
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
sourcebucket: bucketName,
|
||||
@@ -164,24 +191,27 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }
|
||||
config.bandwidth = Number(getBytes(String(bandwidth), unit, true)) || 0
|
||||
}
|
||||
|
||||
const targetResponse = (await setRemoteReplicationTarget(bucketName, config)) as string
|
||||
if (!targetResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
let oldConfig: {
|
||||
ReplicationConfiguration?: { Rules?: unknown[] }
|
||||
ReplicationConfiguration?: { Role?: string; Rules?: ReplicationRulePayload[] }
|
||||
} | null = null
|
||||
try {
|
||||
oldConfig = (await getBucketReplication(bucketName)) as {
|
||||
ReplicationConfiguration?: { Rules?: unknown[] }
|
||||
ReplicationConfiguration?: { Role?: string; Rules?: ReplicationRulePayload[] }
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingBucketConfiguration(error, "replication")) {
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
oldConfig = null
|
||||
}
|
||||
|
||||
const newRule: Record<string, unknown> = {
|
||||
ID: `replication-rule-${Date.now()}`,
|
||||
const targetResponse = (await setRemoteReplicationTarget(bucketName, config)) as string
|
||||
if (!targetResponse) {
|
||||
throw new Error(t("Save failed"))
|
||||
}
|
||||
remoteTargetSaved = true
|
||||
|
||||
const newRule: ReplicationRulePayload = {
|
||||
ID: randomUUID(),
|
||||
Status: "Enabled",
|
||||
Priority: parseInt(level) || 1,
|
||||
SourceSelectionCriteria: {
|
||||
@@ -226,31 +256,50 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }
|
||||
newRule.Filter = filter
|
||||
}
|
||||
|
||||
const existingRules = oldConfig?.ReplicationConfiguration?.Rules ?? []
|
||||
let latestConfig = oldConfig
|
||||
try {
|
||||
latestConfig = (await getBucketReplication(bucketName)) as {
|
||||
ReplicationConfiguration?: { Role?: string; Rules?: ReplicationRulePayload[] }
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingBucketConfiguration(error, "replication")) {
|
||||
latestConfig = null
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const existingRules = normalizeReplicationRulesForRolelessConfig(
|
||||
latestConfig?.ReplicationConfiguration?.Rules ?? [],
|
||||
latestConfig?.ReplicationConfiguration?.Role,
|
||||
)
|
||||
const nextRules = [...existingRules, newRule]
|
||||
const payload = {
|
||||
Role: targetResponse,
|
||||
Rules: [
|
||||
...(existingRules as Array<{ Destination?: { Bucket?: string } }>).filter(
|
||||
(rule) => rule.Destination?.Bucket !== targetResponse,
|
||||
),
|
||||
newRule,
|
||||
],
|
||||
Role: "",
|
||||
Rules: nextRules,
|
||||
}
|
||||
|
||||
await putBucketReplication(bucketName, payload)
|
||||
remoteTargetSaved = false
|
||||
message.success(t("Create Success"))
|
||||
onSuccess?.()
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error((error as Error).message || t("Save failed"))
|
||||
let errorMessage = (error as Error).message || t("Save failed")
|
||||
if (remoteTargetSaved) {
|
||||
errorMessage = `${errorMessage}. ${t("The remote target may have been saved. Review the replication configuration before retrying.")}`
|
||||
}
|
||||
setSaveError(errorMessage)
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
@@ -258,340 +307,391 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess }
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (!nextOpen && details.reason === "escape-key") {
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
onOpenChange(nextOpen)
|
||||
onOpenChange(true)
|
||||
}}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<DialogContent className="flex max-h-[90vh] flex-col gap-4 sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogContent className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12 sm:px-6">
|
||||
<DialogTitle>
|
||||
{t("Add Replication Rule")} ({t("Bucket")}: {bucketName || ""})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto pe-1">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-priority">{t("Priority")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-priority"
|
||||
name="replication-priority"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
autoComplete="off"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("Mode")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={modeType} onValueChange={(value) => setModeType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Mode")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-endpoint">{t("Endpoint")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 items-center whitespace-nowrap border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{tls ? "https://" : "http://"}
|
||||
</div>
|
||||
<Input
|
||||
id="replication-endpoint"
|
||||
name="replication-endpoint"
|
||||
className="flex-1"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter endpoint")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-bucket">{t("Bucket")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-bucket"
|
||||
name="replication-bucket"
|
||||
value={bucket}
|
||||
onChange={(e) => setBucket(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter bucket")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-access-key"
|
||||
name="replication-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="replication-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-secret-key"
|
||||
name="replication-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
placeholder={t("Please enter Secret Key")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-region">{t("Region")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-region"
|
||||
name="replication-region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter region")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-storage-class">{t("Storage Class")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-storage-class"
|
||||
name="replication-storage-class"
|
||||
value={storageType}
|
||||
onChange={(e) => setStorageType(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter storage class")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-prefix"
|
||||
name="replication-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
|
||||
<Button variant="outline" size="sm" onClick={addTag}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Add Tag")}
|
||||
</Button>
|
||||
<form
|
||||
className="contents"
|
||||
aria-busy={submitting}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleSave()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-6 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
{saveError ? (
|
||||
<div role="alert" className="border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{saveError}
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="grid gap-2 border p-3 md:grid-cols-2 md:items-center md:gap-4">
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-priority">{t("Priority")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-priority"
|
||||
name="replication-priority"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
autoComplete="off"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("Mode")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={modeType} onValueChange={(value) => setModeType(value ?? "")}>
|
||||
<SelectTrigger className="w-full" aria-label={t("Mode")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-endpoint">{t("Endpoint")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 items-center whitespace-nowrap border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{tls ? "https://" : "http://"}
|
||||
</div>
|
||||
<Input
|
||||
id={`replication-tag-key-${index}`}
|
||||
name={`replication-tag-key-${index}`}
|
||||
aria-label={t("Tag Name")}
|
||||
value={tag.key}
|
||||
onChange={(e) => updateTag(index, "key", e.target.value)}
|
||||
id="replication-endpoint"
|
||||
name="replication-endpoint"
|
||||
className="flex-1"
|
||||
value={endpoint}
|
||||
onChange={(e) => {
|
||||
setEndpoint(e.target.value)
|
||||
setFieldErrors((current) => ({ ...current, endpoint: undefined }))
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.endpoint)}
|
||||
aria-describedby={fieldErrors.endpoint ? "replication-endpoint-error" : undefined}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Name")}
|
||||
placeholder={t("Please enter endpoint")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldError id="replication-endpoint-error">{fieldErrors.endpoint}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-bucket">{t("Bucket")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-bucket"
|
||||
name="replication-bucket"
|
||||
value={bucket}
|
||||
onChange={(e) => {
|
||||
setBucket(e.target.value)
|
||||
setFieldErrors((current) => ({ ...current, bucket: undefined }))
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.bucket)}
|
||||
aria-describedby={fieldErrors.bucket ? "replication-bucket-error" : undefined}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter bucket")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="replication-bucket-error">{fieldErrors.bucket}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-access-key"
|
||||
name="replication-access-key"
|
||||
value={accessKey}
|
||||
onChange={(e) => {
|
||||
setAccessKey(e.target.value)
|
||||
setFieldErrors((current) => ({ ...current, accessKey: undefined }))
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.accessKey)}
|
||||
aria-describedby={fieldErrors.accessKey ? "replication-access-key-error" : undefined}
|
||||
placeholder={t("Please enter Access Key")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="replication-access-key-error">{fieldErrors.accessKey}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-secret-key"
|
||||
name="replication-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => {
|
||||
setSecretKey(e.target.value)
|
||||
setFieldErrors((current) => ({ ...current, secretKey: undefined }))
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.secretKey)}
|
||||
aria-describedby={fieldErrors.secretKey ? "replication-secret-key-error" : undefined}
|
||||
placeholder={t("Please enter Secret Key")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="replication-secret-key-error">{fieldErrors.secretKey}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-region">{t("Region")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-region"
|
||||
name="replication-region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter region")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-storage-class">{t("Storage Class")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-storage-class"
|
||||
name="replication-storage-class"
|
||||
value={storageType}
|
||||
onChange={(e) => setStorageType(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter storage class")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-prefix">{t("Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-prefix"
|
||||
name="replication-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Please enter prefix")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addTag} disabled={submitting}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Add Tag")}
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="grid gap-2 border p-3 md:grid-cols-2 md:items-center md:gap-4">
|
||||
<Input
|
||||
id={`replication-tag-value-${index}`}
|
||||
name={`replication-tag-value-${index}`}
|
||||
aria-label={t("Tag Value")}
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(index, "value", e.target.value)}
|
||||
id={`replication-tag-key-${index}`}
|
||||
name={`replication-tag-key-${index}`}
|
||||
aria-label={t("Tag Name")}
|
||||
value={tag.key}
|
||||
onChange={(e) => updateTag(index, "key", e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Value")}
|
||||
className="flex-1"
|
||||
placeholder={t("Tag Name")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
aria-label={t("Delete")}
|
||||
disabled={tags.length === 1}
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`replication-tag-value-${index}`}
|
||||
name={`replication-tag-value-${index}`}
|
||||
aria-label={t("Tag Value")}
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(index, "value", e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("Tag Value")}
|
||||
className="flex-1"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
aria-label={`${t("Delete")} ${t("Tag Name")} ${index + 1}`}
|
||||
disabled={tags.length === 1 || submitting}
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-use-tls" className="text-sm font-medium">
|
||||
{t("Use TLS")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Enable secure transport when connecting to endpoint.")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="replication-use-tls" name="replication-use-tls" checked={tls} onCheckedChange={setTls} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-existing-object" className="text-sm font-medium">
|
||||
{t("Replicate Existing Objects")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Include objects that already exist in the source bucket.")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="replication-existing-object"
|
||||
name="replication-existing-object"
|
||||
checked={existingObject}
|
||||
onCheckedChange={setExistingObject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-expired-delete-marker" className="text-sm font-medium">
|
||||
{t("Replicate Delete Markers")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t("Sync delete markers to destination bucket.")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="replication-expired-delete-marker"
|
||||
name="replication-expired-delete-marker"
|
||||
checked={expiredDeleteMark}
|
||||
onCheckedChange={setExpiredDeleteMark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-delete" className="text-sm font-medium">
|
||||
{t("Replicate Delete")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t("Sync delete to destination bucket.")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="replication-delete"
|
||||
name="replication-delete"
|
||||
checked={replicateDelete}
|
||||
onCheckedChange={setReplicateDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modeType === "async" && (
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-health-check-interval">
|
||||
{t("Health Check Interval (seconds)")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-health-check-interval"
|
||||
name="replication-health-check-interval"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
autoComplete="off"
|
||||
value={timecheck}
|
||||
onChange={(e) => {
|
||||
setTimecheck(e.target.value)
|
||||
setFieldErrors((current) => ({ ...current, timecheck: undefined }))
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.timecheck)}
|
||||
aria-describedby={fieldErrors.timecheck ? "replication-health-check-interval-error" : undefined}
|
||||
className="w-32"
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="replication-health-check-interval-error">{fieldErrors.timecheck}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-bandwidth-limit">{t("Bandwidth Limit")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="replication-bandwidth-limit"
|
||||
name="replication-bandwidth-limit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
autoComplete="off"
|
||||
value={bandwidth}
|
||||
onChange={(e) => setBandwidth(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<Select value={unit} onValueChange={(value) => setUnit(value ?? "")}>
|
||||
<SelectTrigger className="w-28" aria-label={t("Bandwidth Unit")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-use-tls" className="text-sm font-medium">
|
||||
{t("Use TLS")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Enable secure transport when connecting to endpoint.")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="replication-use-tls" name="replication-use-tls" checked={tls} onCheckedChange={setTls} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-existing-object" className="text-sm font-medium">
|
||||
{t("Replicate Existing Objects")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Include objects that already exist in the source bucket.")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="replication-existing-object"
|
||||
name="replication-existing-object"
|
||||
checked={existingObject}
|
||||
onCheckedChange={setExistingObject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-expired-delete-marker" className="text-sm font-medium">
|
||||
{t("Replicate Delete Markers")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t("Sync delete markers to destination bucket.")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="replication-expired-delete-marker"
|
||||
name="replication-expired-delete-marker"
|
||||
checked={expiredDeleteMark}
|
||||
onCheckedChange={setExpiredDeleteMark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="replication-delete" className="text-sm font-medium">
|
||||
{t("Replicate Delete")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t("Sync delete to destination bucket.")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="replication-delete"
|
||||
name="replication-delete"
|
||||
checked={replicateDelete}
|
||||
onCheckedChange={setReplicateDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modeType === "async" && (
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-health-check-interval">
|
||||
{t("Health Check Interval (seconds)")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="replication-health-check-interval"
|
||||
name="replication-health-check-interval"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
autoComplete="off"
|
||||
value={timecheck}
|
||||
onChange={(e) => setTimecheck(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="replication-bandwidth-limit">{t("Bandwidth Limit")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="replication-bandwidth-limit"
|
||||
name="replication-bandwidth-limit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
autoComplete="off"
|
||||
value={bandwidth}
|
||||
onChange={(e) => setBandwidth(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<Select value={unit} onValueChange={(value) => setUnit(value ?? "")}>
|
||||
<SelectTrigger className="w-28" aria-label={t("Bandwidth Unit")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={submitting}>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" className="w-full sm:w-auto" disabled={submitting}>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useSiteReplication } from "@/hooks/use-site-replication"
|
||||
@@ -186,14 +185,25 @@ export function SiteReplicationNewForm({
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-6xl gap-0 border-0 bg-transparent p-0 ring-0 shadow-none sm:max-w-6xl"
|
||||
className="max-h-[min(94dvh,56rem)] max-w-6xl grid-rows-[minmax(0,1fr)] gap-0 overflow-hidden border-0 bg-transparent p-0 ring-0 shadow-none sm:max-w-6xl"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="grid overflow-hidden border bg-background lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.35fr)]">
|
||||
<section className="relative overflow-hidden border-b bg-muted/20 px-6 py-6 lg:border-e lg:border-b-0">
|
||||
<div className="grid min-h-0 overflow-y-auto overscroll-contain border bg-background lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.35fr)] lg:overflow-hidden">
|
||||
<section className="relative border-b bg-muted/20 px-4 py-5 sm:px-6 sm:py-6 lg:min-h-0 lg:overflow-y-auto lg:border-e lg:border-b-0">
|
||||
<div className="absolute inset-y-0 end-0 hidden w-px bg-border/70 lg:block" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-3 end-3 lg:hidden"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
<RiCloseLine className="size-4" aria-hidden />
|
||||
<span className="sr-only">{t("Cancel")}</span>
|
||||
</Button>
|
||||
<div className="relative space-y-6">
|
||||
<DialogHeader className="space-y-3">
|
||||
<DialogHeader className="space-y-3 pe-10 lg:pe-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="bg-background/70">
|
||||
{isConfigured ? t("Expand replication group") : t("First-time setup")}
|
||||
@@ -208,8 +218,8 @@ export function SiteReplicationNewForm({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<div className="border bg-background/70 p-4">
|
||||
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<div className="p-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<RiStackLine className="size-4" aria-hidden />
|
||||
<span className="text-xs font-medium uppercase tracking-[0.18em]">{t("Topology")}</span>
|
||||
@@ -220,7 +230,7 @@ export function SiteReplicationNewForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border bg-background/70 p-4">
|
||||
<div className="p-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<RiShieldCheckLine className="size-4" aria-hidden />
|
||||
<span className="text-xs font-medium uppercase tracking-[0.18em]">{t("Handshake")}</span>
|
||||
@@ -233,7 +243,7 @@ export function SiteReplicationNewForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border bg-background/70 p-4">
|
||||
<div className="p-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<RiKeyLine className="size-4" aria-hidden />
|
||||
<span className="text-xs font-medium uppercase tracking-[0.18em]">{t("Credentials")}</span>
|
||||
@@ -245,7 +255,7 @@ export function SiteReplicationNewForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border bg-background/75 p-5">
|
||||
<div className="border-t pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="site-replication-ilm-expiry" className="text-sm font-medium">
|
||||
@@ -274,8 +284,8 @@ export function SiteReplicationNewForm({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 bg-background">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b px-6 py-5">
|
||||
<section className="min-w-0 bg-background lg:grid lg:min-h-0 lg:grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-start justify-between gap-4 border-b bg-background px-4 py-5 sm:px-6 lg:static">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("Remote Sites")}
|
||||
@@ -299,11 +309,14 @@ export function SiteReplicationNewForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[72vh]">
|
||||
<div className="lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="space-y-5 px-6 py-6">
|
||||
{sites.map((site, index) => (
|
||||
<div key={index} className="overflow-hidden border bg-muted/20">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b bg-background px-5 py-4">
|
||||
<fieldset key={index} className="overflow-hidden bg-muted/20">
|
||||
<legend className="sr-only">
|
||||
{t("Remote Site")} {index + 1}
|
||||
</legend>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 bg-background/60 px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
@@ -413,12 +426,12 @@ export function SiteReplicationNewForm({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<DialogFooter className="sticky bottom-0 z-10 border-t bg-background px-4 py-4 sm:px-6 lg:static">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={saving}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -131,18 +131,24 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return
|
||||
onOpenChange(false)
|
||||
setSubmitting(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<Dialog
|
||||
open={open}
|
||||
disablePointerDismissal={submitting}
|
||||
onOpenChange={(nextOpen) => {
|
||||
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">
|
||||
<DialogHeader className="border-b px-4 py-4 pe-12 sm:px-6">
|
||||
<DialogTitle>{t("Add Tier")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<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) => (
|
||||
@@ -328,11 +334,11 @@ export function TiersNewForm({ open, onOpenChange, onSuccess }: TiersNewFormProp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<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} aria-disabled={submitting}>
|
||||
<Button onClick={handleSave} disabled={!type || submitting}>
|
||||
{submitting ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { useGroups } from "@/hooks/use-groups"
|
||||
import { UserGroupMembers } from "./members"
|
||||
import { UserGroupPolicies } from "./policies"
|
||||
@@ -34,70 +37,143 @@ export function UserGroupEditForm({ open, onOpenChange, row, onSuccess }: UserGr
|
||||
})
|
||||
const [activeTab, setActiveTab] = React.useState("users")
|
||||
const [statusBoolean, setStatusBoolean] = React.useState(true)
|
||||
|
||||
const getGroupData = React.useCallback(
|
||||
async (name: string) => {
|
||||
const data = (await getGroup(name)) as GroupInfo
|
||||
setGroup(data)
|
||||
},
|
||||
[getGroup],
|
||||
)
|
||||
const [loadedName, setLoadedName] = React.useState("")
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [statusUpdating, setStatusUpdating] = React.useState(false)
|
||||
const activeNameRef = React.useRef("")
|
||||
const isCurrentGroup = loadedName === group.name && group.name === row?.name
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && row?.name) {
|
||||
getGroupData(row.name)
|
||||
setActiveTab("users")
|
||||
const targetName = open ? (row?.name ?? "") : ""
|
||||
activeNameRef.current = targetName
|
||||
if (!targetName) return
|
||||
|
||||
let cancelled = false
|
||||
setGroup({ name: targetName, members: [], status: "enabled" })
|
||||
setLoadedName("")
|
||||
setStatusBoolean(false)
|
||||
setActiveTab("users")
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
|
||||
getGroup(targetName)
|
||||
.then((data) => {
|
||||
if (cancelled || activeNameRef.current !== targetName) return
|
||||
const nextGroup = data as GroupInfo
|
||||
setGroup(nextGroup)
|
||||
setStatusBoolean(nextGroup.status === "enabled")
|
||||
setLoadedName(targetName)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled || activeNameRef.current !== targetName) return
|
||||
setLoadError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && activeNameRef.current === targetName) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, row?.name, getGroupData])
|
||||
|
||||
React.useEffect(() => {
|
||||
setStatusBoolean(group.status === "enabled")
|
||||
}, [group.status])
|
||||
|
||||
const handleGroupStatusChange = async (status: string) => {
|
||||
if (status === group.status) return
|
||||
await updateGroupStatus(group.name, { ...group, status })
|
||||
await getGroupData(group.name)
|
||||
onSuccess()
|
||||
}
|
||||
}, [getGroup, loadVersion, open, row?.name, t])
|
||||
|
||||
const handleStatusToggle = async (checked: boolean) => {
|
||||
if (!isCurrentGroup || loading || loadError || statusUpdating) return
|
||||
const nextStatus = checked ? "enabled" : "disabled"
|
||||
if (nextStatus === group.status) return
|
||||
const targetName = group.name
|
||||
const previousStatus = group.status
|
||||
setStatusBoolean(checked)
|
||||
setStatusUpdating(true)
|
||||
|
||||
try {
|
||||
await handleGroupStatusChange(nextStatus)
|
||||
await updateGroupStatus(targetName, { ...group, status: nextStatus })
|
||||
if (activeNameRef.current !== targetName) return
|
||||
setGroup((current) => ({ ...current, status: nextStatus }))
|
||||
onSuccess()
|
||||
} catch {
|
||||
setStatusBoolean(group.status === "enabled")
|
||||
if (activeNameRef.current === targetName) {
|
||||
setStatusBoolean(previousStatus === "enabled")
|
||||
}
|
||||
} finally {
|
||||
if (activeNameRef.current === targetName) setStatusUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => onOpenChange(false)
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!statusUpdating || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
const refreshGroup = () => setLoadVersion((current) => current + 1)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group.name || t("Members")}</DialogTitle>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||
aria-busy={loading || statusUpdating}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{row?.name || t("Members")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border px-3 py-2">
|
||||
<label htmlFor="user-group-status" className="text-sm text-muted-foreground">
|
||||
{t("Status")}
|
||||
</label>
|
||||
<Switch id="user-group-status" checked={statusBoolean} onCheckedChange={handleStatusToggle} />
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-4">
|
||||
<TabsList className="justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="users">{t("Members")}</TabsTrigger>
|
||||
<TabsTrigger value="policy">{t("Policies")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-64 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div
|
||||
className="flex min-h-64 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="max-w-md text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={refreshGroup}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : isCurrentGroup ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border px-3 py-2">
|
||||
<label htmlFor="user-group-status" className="text-sm text-muted-foreground">
|
||||
{t("Status")}
|
||||
</label>
|
||||
<Switch
|
||||
id="user-group-status"
|
||||
checked={statusBoolean}
|
||||
onCheckedChange={handleStatusToggle}
|
||||
disabled={loading || Boolean(loadError) || statusUpdating || !isCurrentGroup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="users" className="mt-0">
|
||||
<UserGroupMembers group={group} onSearch={() => getGroupData(group.name)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="policy" className="mt-0">
|
||||
<UserGroupPolicies group={group} onSearch={() => getGroupData(group.name)} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-4">
|
||||
<TabsList className="justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="users">{t("Members")}</TabsTrigger>
|
||||
<TabsTrigger value="policy">{t("Policies")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="mt-0">
|
||||
<UserGroupMembers group={group} onSearch={refreshGroup} />
|
||||
</TabsContent>
|
||||
<TabsContent value="policy" className="mt-0">
|
||||
<UserGroupPolicies group={group} onSearch={refreshGroup} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={closeModal} disabled={statusUpdating}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
@@ -31,10 +31,14 @@ export function UserGroupMembers({ group, onSearch }: UserGroupMembersProps) {
|
||||
const [editStatus, setEditStatus] = React.useState(false)
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const [members, setMembers] = React.useState<string[]>([])
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const activeGroupRef = React.useRef(group.name)
|
||||
const editButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const editHeadingRef = React.useRef<HTMLHeadingElement>(null)
|
||||
|
||||
const membersData = React.useMemo<MemberItem[]>(
|
||||
() => (group?.members ?? []).map((name) => ({ name })),
|
||||
[group?.members],
|
||||
() => (group.members ?? []).map((name) => ({ name })),
|
||||
[group.members],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<MemberItem>[] = React.useMemo(
|
||||
@@ -42,7 +46,7 @@ export function UserGroupMembers({ group, onSearch }: UserGroupMembersProps) {
|
||||
{
|
||||
id: "name",
|
||||
header: () => t("Name"),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
cell: ({ row }) => <span className="break-all font-medium">{row.original.name}</span>,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
@@ -60,96 +64,146 @@ export function UserGroupMembers({ group, onSearch }: UserGroupMembersProps) {
|
||||
}, [searchTerm, table])
|
||||
|
||||
React.useEffect(() => {
|
||||
setMembers([...(group?.members ?? [])])
|
||||
}, [group?.members])
|
||||
activeGroupRef.current = group.name
|
||||
setSearchTerm("")
|
||||
setEditStatus(false)
|
||||
setSubmitting(false)
|
||||
setMembers([...(group.members ?? [])])
|
||||
}, [group.members, group.name])
|
||||
|
||||
const previousEditStatusRef = React.useRef<boolean | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditStatus(false)
|
||||
}, [group?.name])
|
||||
const previousEditStatus = previousEditStatusRef.current
|
||||
previousEditStatusRef.current = editStatus
|
||||
if (previousEditStatus === null || previousEditStatus === editStatus) return
|
||||
if (editStatus) {
|
||||
editHeadingRef.current?.focus()
|
||||
} else {
|
||||
editButtonRef.current?.focus()
|
||||
}
|
||||
}, [editStatus])
|
||||
|
||||
const startEditing = () => {
|
||||
setMembers([...(group?.members ?? [])])
|
||||
setEditStatus(true)
|
||||
}
|
||||
|
||||
const changeMembers = async () => {
|
||||
try {
|
||||
const currentMembers = group?.members ?? []
|
||||
const nowRemoveMembers = currentMembers.filter((item) => !members.includes(item))
|
||||
const cancelEditing = () => {
|
||||
setMembers([...(group?.members ?? [])])
|
||||
setEditStatus(false)
|
||||
}
|
||||
|
||||
if (nowRemoveMembers.length) {
|
||||
const changeMembers = async () => {
|
||||
if (submitting) return
|
||||
const targetName = group.name
|
||||
const currentMembers = group?.members ?? []
|
||||
const addedMembers = members.filter((item) => !currentMembers.includes(item))
|
||||
const removedMembers = currentMembers.filter((item) => !members.includes(item))
|
||||
if (!addedMembers.length && !removedMembers.length) {
|
||||
setEditStatus(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (addedMembers.length) {
|
||||
await updateGroupMembers({
|
||||
group: group.name,
|
||||
members: nowRemoveMembers,
|
||||
group: targetName,
|
||||
members: addedMembers,
|
||||
isRemove: false,
|
||||
groupStatus: "enabled",
|
||||
})
|
||||
}
|
||||
|
||||
if (removedMembers.length) {
|
||||
await updateGroupMembers({
|
||||
group: targetName,
|
||||
members: removedMembers,
|
||||
isRemove: true,
|
||||
groupStatus: "enabled",
|
||||
})
|
||||
}
|
||||
|
||||
await updateGroupMembers({
|
||||
group: group.name,
|
||||
members,
|
||||
isRemove: false,
|
||||
groupStatus: "enabled",
|
||||
})
|
||||
|
||||
if (activeGroupRef.current !== targetName) return
|
||||
message.success(t("Edit Success"))
|
||||
setEditStatus(false)
|
||||
onSearch()
|
||||
} catch {
|
||||
message.error(t("Edit Failed"))
|
||||
if (activeGroupRef.current === targetName) {
|
||||
message.error(t("Edit Failed"))
|
||||
setEditStatus(false)
|
||||
onSearch()
|
||||
}
|
||||
} finally {
|
||||
if (activeGroupRef.current === targetName) setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
{!editStatus ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search User")}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="inline-flex items-center gap-2" onClick={startEditing}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Edit User")}
|
||||
</Button>
|
||||
{!editStatus ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search User")}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
ref={editButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="inline-flex items-center gap-2"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Edit User")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 border bg-muted/20 p-3" aria-busy={submitting}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 ref={editHeadingRef} tabIndex={-1} className="text-sm font-medium outline-none">
|
||||
{t("Edit")} {t("Members")}
|
||||
</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{members.length}</span>
|
||||
</div>
|
||||
|
||||
<UserSelector
|
||||
value={members}
|
||||
onChange={setMembers}
|
||||
label={t("Select user group members")}
|
||||
placeholder={t("Select user group members")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
{members.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{members.slice(0, 5).map((member) => (
|
||||
<Badge key={member} variant="secondary" className="h-auto max-w-full whitespace-normal">
|
||||
<span className="break-all">{member}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{members.length > 5 ? <Badge variant="outline">+{members.length - 5}</Badge> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<UserSelector
|
||||
value={members}
|
||||
onChange={setMembers}
|
||||
label={t("Select user group members")}
|
||||
placeholder={t("Select user group members")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:self-end">
|
||||
<Button type="button" variant="outline" onClick={changeMembers}>
|
||||
{t("Submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{editStatus && members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{members.map((v) => (
|
||||
<Badge key={v} variant="secondary">
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={cancelEditing} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={changeMembers} disabled={submitting}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DataTable table={table} />
|
||||
{!editStatus ? <DataTable table={table} caption={t("Members")} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiArrowDownSLine, RiCheckLine } from "@remixicon/react"
|
||||
import { RiAddLine, RiArrowDownSLine, RiCheckLine, RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandList } from "@/components/ui/command"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { usePolicies } from "@/hooks/use-policies"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MultiSelectCommandItem } from "@/components/user/multi-select-command-item"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface PolicyItem {
|
||||
@@ -35,6 +37,14 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
const [policySelectorOpen, setPolicySelectorOpen] = React.useState(false)
|
||||
const [policies, setPolicies] = React.useState<{ label: string; value: string }[]>([])
|
||||
const [selectedPolicies, setSelectedPolicies] = React.useState<string[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const activeGroupRef = React.useRef(group.name)
|
||||
const triggerId = React.useId()
|
||||
const editButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const editHeadingRef = React.useRef<HTMLHeadingElement>(null)
|
||||
|
||||
const policyStr = group?.policy
|
||||
const currentPolicies = React.useMemo(() => {
|
||||
@@ -52,7 +62,7 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
{
|
||||
id: "name",
|
||||
header: () => t("Name"),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
cell: ({ row }) => <span className="break-all font-medium">{row.original.name}</span>,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
@@ -76,16 +86,59 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
}, [group?.policy, editStatus, currentPolicies])
|
||||
|
||||
React.useEffect(() => {
|
||||
activeGroupRef.current = group.name
|
||||
setSearchTerm("")
|
||||
setEditStatus(false)
|
||||
setPolicySelectorOpen(false)
|
||||
setPolicies([])
|
||||
setSelectedPolicies([...currentPolicies])
|
||||
setLoadError("")
|
||||
setLoading(false)
|
||||
setSubmitting(false)
|
||||
}, [currentPolicies, group.name])
|
||||
|
||||
const previousEditStatusRef = React.useRef<boolean | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const previousEditStatus = previousEditStatusRef.current
|
||||
previousEditStatusRef.current = editStatus
|
||||
if (previousEditStatus === null || previousEditStatus === editStatus) return
|
||||
if (editStatus) {
|
||||
editHeadingRef.current?.focus()
|
||||
} else {
|
||||
editButtonRef.current?.focus()
|
||||
}
|
||||
}, [editStatus])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editStatus) return
|
||||
let cancelled = false
|
||||
const targetName = group.name
|
||||
|
||||
setPolicies([])
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
listPolicies()
|
||||
.then((res: Record<string, unknown>) => {
|
||||
if (cancelled || activeGroupRef.current !== targetName) return
|
||||
setPolicies(
|
||||
Object.keys(res ?? {})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((key) => ({ label: key, value: key })),
|
||||
)
|
||||
})
|
||||
.catch(() => message.error(t("Failed to get data")))
|
||||
}, [listPolicies, message, t])
|
||||
.catch((error) => {
|
||||
if (cancelled || activeGroupRef.current !== targetName) return
|
||||
setLoadError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && activeGroupRef.current === targetName) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [editStatus, group.name, listPolicies, loadVersion, t])
|
||||
|
||||
const startEditing = () => {
|
||||
setSelectedPolicies([...currentPolicies])
|
||||
@@ -94,28 +147,36 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
if (submitting) return
|
||||
setSelectedPolicies([...currentPolicies])
|
||||
setPolicySelectorOpen(false)
|
||||
setEditStatus(false)
|
||||
}
|
||||
|
||||
const togglePolicy = (value: string) => {
|
||||
if (loading || loadError || submitting) return
|
||||
setSelectedPolicies((prev) => (prev.includes(value) ? prev.filter((p) => p !== value) : [...prev, value]))
|
||||
}
|
||||
|
||||
const changePolicies = async () => {
|
||||
if (loading || loadError || submitting || !group.name) return
|
||||
const targetName = group.name
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await setUserOrGroupPolicy({
|
||||
policyName: selectedPolicies,
|
||||
userOrGroup: group?.name ?? "",
|
||||
userOrGroup: targetName,
|
||||
isGroup: true,
|
||||
})
|
||||
if (activeGroupRef.current !== targetName) return
|
||||
message.success(t("Edit Success"))
|
||||
setEditStatus(false)
|
||||
setPolicySelectorOpen(false)
|
||||
onSearch()
|
||||
} catch {
|
||||
message.error(t("Edit Failed"))
|
||||
if (activeGroupRef.current === targetName) message.error(t("Edit Failed"))
|
||||
} finally {
|
||||
if (activeGroupRef.current === targetName) setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,24 +193,45 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="inline-flex items-center gap-2" onClick={startEditing}>
|
||||
<Button
|
||||
ref={editButtonRef}
|
||||
variant="outline"
|
||||
className="inline-flex items-center gap-2"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
{t("Edit Policy")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-4 border bg-muted/20 p-3" aria-busy={loading || submitting}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 ref={editHeadingRef} tabIndex={-1} className="text-sm font-medium outline-none">
|
||||
{t("Edit")} {t("Policies")}
|
||||
</h3>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{selectedPolicies.length}</span>
|
||||
</div>
|
||||
|
||||
<Field className="flex w-full flex-col gap-2">
|
||||
<FieldLabel className="text-sm font-medium">{t("Select user group policies")}</FieldLabel>
|
||||
<FieldLabel htmlFor={triggerId} className="text-sm font-medium">
|
||||
{t("Select user group policies")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="space-y-2">
|
||||
<Popover open={policySelectorOpen} onOpenChange={setPolicySelectorOpen}>
|
||||
<Popover
|
||||
open={policySelectorOpen}
|
||||
onOpenChange={(nextOpen) => !loading && !loadError && !submitting && setPolicySelectorOpen(nextOpen)}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
id={triggerId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 justify-between gap-2"
|
||||
aria-label={t("Select user group policies")}
|
||||
className="min-h-10 w-full min-w-0 justify-between gap-2"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={policySelectorOpen}
|
||||
aria-label={`${t("Select user group policies")}: ${selectedPolicies.length ? selectedPolicies.join(", ") : t("No Data")}`}
|
||||
disabled={loading || Boolean(loadError) || submitting}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedPolicies.length ? selectedPolicies.join(", ") : t("Select user group policies")}
|
||||
@@ -158,16 +240,17 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<PopoverContent className="w-(--anchor-width) max-w-(--available-width) p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search Policy")} />
|
||||
<CommandList>
|
||||
<CommandInput aria-label={t("Search Policy")} placeholder={t("Search Policy")} />
|
||||
<CommandList role="listbox" aria-label={t("Policies")} aria-multiselectable="true">
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{policies.map((option) => (
|
||||
<CommandItem
|
||||
<MultiSelectCommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
selected={selectedPolicies.includes(option.value)}
|
||||
onSelect={() => togglePolicy(option.value)}
|
||||
>
|
||||
<RiCheckLine
|
||||
@@ -176,8 +259,8 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
selectedPolicies.includes(option.value) ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
<span className="min-w-0 break-all">{option.label}</span>
|
||||
</MultiSelectCommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
@@ -186,26 +269,49 @@ export function UserGroupPolicies({ group, onSearch }: UserGroupPoliciesProps) {
|
||||
</Popover>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2 sm:self-end">
|
||||
<Button variant="ghost" onClick={cancelEditing}>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" aria-hidden />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex items-center justify-between gap-3 border border-dashed p-3" role="alert">
|
||||
<p className="min-w-0 break-words text-sm text-destructive">{loadError}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setLoadVersion((current) => current + 1)}
|
||||
>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : selectedPolicies.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{selectedPolicies.slice(0, 5).map((value) => (
|
||||
<Badge key={value} variant="secondary" className="h-auto max-w-full whitespace-normal">
|
||||
<span className="break-all">{value}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedPolicies.length > 5 ? <Badge variant="outline">+{selectedPolicies.length - 5}</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={cancelEditing} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={changePolicies}>
|
||||
{t("Submit")}
|
||||
<Button type="button" onClick={changePolicies} disabled={loading || Boolean(loadError) || submitting}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editStatus && selectedPolicies.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPolicies.map((value) => (
|
||||
<Badge key={value} variant="secondary">
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DataTable table={table} />
|
||||
{!editStatus ? <DataTable table={table} caption={t("Policies")} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
@@ -36,6 +38,9 @@ export function UserGroupSetPoliciesMultiple({
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [policies, setPolicies] = React.useState<PolicyItem[]>([])
|
||||
const [checked, setChecked] = React.useState<string[]>([])
|
||||
|
||||
@@ -74,6 +79,7 @@ export function UserGroupSetPoliciesMultiple({
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={!allVisibleSelected && checked.length > 0}
|
||||
onCheckedChange={(v) => toggleSelectAll(v === true)}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -82,6 +88,7 @@ export function UserGroupSetPoliciesMultiple({
|
||||
aria-label={`${t("Select")} ${row.original.name}`}
|
||||
checked={isSelected(row.original.name)}
|
||||
onCheckedChange={(v) => toggleSelection(row.original.name, v === true)}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
),
|
||||
meta: { maxWidth: "3rem" },
|
||||
@@ -96,7 +103,7 @@ export function UserGroupSetPoliciesMultiple({
|
||||
},
|
||||
},
|
||||
],
|
||||
[t, allVisibleSelected, checked, isSelected, toggleSelectAll, toggleSelection],
|
||||
[t, allVisibleSelected, checked, isSelected, loading, submitting, toggleSelectAll, toggleSelection],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<PolicyItem>({
|
||||
@@ -111,24 +118,44 @@ export function UserGroupSetPoliciesMultiple({
|
||||
}, [searchTerm, table])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setChecked([])
|
||||
setSearchTerm("")
|
||||
listPolicies()
|
||||
.then((res: Record<string, unknown>) => {
|
||||
setPolicies(
|
||||
Object.entries(res ?? {}).map(([key, content]) => ({
|
||||
name: key,
|
||||
content,
|
||||
})),
|
||||
)
|
||||
})
|
||||
.catch(() => message.error(t("Failed to get data")))
|
||||
if (!open) return
|
||||
|
||||
setChecked([])
|
||||
setSearchTerm("")
|
||||
}, [open])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
|
||||
setPolicies([])
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
listPolicies()
|
||||
.then((res: Record<string, unknown>) => {
|
||||
if (cancelled) return
|
||||
setPolicies(
|
||||
Object.entries(res ?? {}).map(([key, content]) => ({
|
||||
name: key,
|
||||
content,
|
||||
})),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
setLoadError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, listPolicies, message, t])
|
||||
}, [listPolicies, loadVersion, open, t])
|
||||
|
||||
const changePolicies = async () => {
|
||||
if (!checked.length || !checkedKeys.length) return
|
||||
if (!checked.length || !checkedKeys.length || loading || loadError || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await Promise.all(
|
||||
@@ -150,14 +177,29 @@ export function UserGroupSetPoliciesMultiple({
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => onOpenChange(false)
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
aria-busy={loading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Batch allocation policies")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void changePolicies()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
@@ -165,14 +207,39 @@ export function UserGroupSetPoliciesMultiple({
|
||||
placeholder={t("Search Policy")}
|
||||
clearable
|
||||
className="w-full"
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" disabled={!checkedKeys.length || submitting} onClick={changePolicies}>
|
||||
|
||||
{loadError ? (
|
||||
<div
|
||||
className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="max-w-md text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={() => setLoadVersion((current) => current + 1)}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable table={table} isLoading={loading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!checked.length || !checkedKeys.length || loading || Boolean(loadError) || submitting}
|
||||
>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
{t("Submit")}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiRefreshLine } from "@remixicon/react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -25,26 +26,50 @@ export function UserAddToGroupForm({ open, onOpenChange, selectedUsers, onSucces
|
||||
const [groups, setGroups] = React.useState<string[]>([])
|
||||
const [groupsList, setGroupsList] = React.useState<{ label: string; value: string }[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [submitError, setSubmitError] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
|
||||
setGroups([])
|
||||
setGroupsList([])
|
||||
setLoadError("")
|
||||
setSubmitError("")
|
||||
setLoading(true)
|
||||
listGroup()
|
||||
.then((res) => {
|
||||
setGroupsList((res as string[] | undefined)?.map((group) => ({ label: group, value: group })) ?? [])
|
||||
if (cancelled) return
|
||||
setGroupsList(
|
||||
((res as string[] | undefined) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((group) => ({ label: group, value: group })),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
message.error(t("Failed to get data"))
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
setLoadError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [listGroup, message, open, t])
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [listGroup, loadVersion, open, t])
|
||||
|
||||
const closeModal = () => onOpenChange(false)
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (loading || loadError || submitting) return
|
||||
const requests = buildAddUsersToGroupsRequests(selectedUsers, groups)
|
||||
if (!requests.length) {
|
||||
message.error(t("Please select at least one item"))
|
||||
@@ -52,51 +77,121 @@ export function UserAddToGroupForm({ open, onOpenChange, selectedUsers, onSucces
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setSubmitError("")
|
||||
try {
|
||||
await Promise.all(requests.map((request) => updateGroupMembers(request)))
|
||||
const results = await Promise.allSettled(requests.map((request) => updateGroupMembers(request)))
|
||||
const failedGroups = results.flatMap((result, index) =>
|
||||
result.status === "rejected" ? [requests[index].group] : [],
|
||||
)
|
||||
const succeeded = results.length - failedGroups.length
|
||||
|
||||
if (succeeded > 0) onSuccess()
|
||||
if (failedGroups.length) {
|
||||
const firstFailure = results.find((result) => result.status === "rejected")
|
||||
const reason = firstFailure?.status === "rejected" ? (firstFailure.reason as Error)?.message : ""
|
||||
const errorMessage = reason || t("Edit Failed")
|
||||
setGroups(failedGroups)
|
||||
setSubmitError(errorMessage)
|
||||
message.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
message.success(t("Edit Success"))
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error((error as Error)?.message || t("Edit Failed"))
|
||||
const errorMessage = (error as Error)?.message || t("Edit Failed")
|
||||
setSubmitError(errorMessage)
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className="overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,44rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-lg"
|
||||
aria-busy={loading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Add to Group")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("Users")}</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{selectedUsers.map((user) => (
|
||||
<Badge key={user} variant="secondary" className="h-auto max-w-full justify-start whitespace-normal">
|
||||
<span className="min-w-0 break-all" title={user}>
|
||||
{user}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-5 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">{t("Users")}</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{selectedUsers.length}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{selectedUsers.slice(0, 5).map((user) => (
|
||||
<Badge key={user} variant="secondary" className="h-auto max-w-full justify-start whitespace-normal">
|
||||
<span className="min-w-0 break-all" title={user}>
|
||||
{user}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedUsers.length > 5 ? <Badge variant="outline">+{selectedUsers.length - 5}</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserEditGroups value={groups} options={groupsList} disabled={loading || submitting} onChange={setGroups} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={submitForm} disabled={loading || submitting || !selectedUsers.length}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Submit")}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<UserEditGroups value={groups} options={groupsList} disabled={loading || submitting} onChange={setGroups} />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" aria-hidden />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex items-center justify-between gap-3 border border-dashed p-3" role="alert">
|
||||
<p className="min-w-0 break-words text-sm text-destructive">{loadError}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setLoadVersion((current) => current + 1)}
|
||||
>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={closeModal}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={loading || Boolean(loadError) || submitting || !selectedUsers.length || !groups.length}
|
||||
>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Save")}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -16,27 +16,27 @@ interface ChangePasswordProps {
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
const PASSWORD_MAX_LENGTH = 40
|
||||
|
||||
export function ChangePassword({ visible, onVisibleChange }: ChangePasswordProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const api = useApiOptional()
|
||||
const { createUser } = useUsers()
|
||||
|
||||
const [currentSecretKey, setCurrentSecretKey] = useState("")
|
||||
const [newSecretKey, setNewSecretKey] = useState("")
|
||||
const [reNewSecretKey, setReNewSecretKey] = useState("")
|
||||
const [errors, setErrors] = useState({
|
||||
current: "",
|
||||
new: "",
|
||||
reNew: "",
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const clearForm = () => {
|
||||
setCurrentSecretKey("")
|
||||
setNewSecretKey("")
|
||||
setReNewSecretKey("")
|
||||
setErrors({ current: "", new: "", reNew: "" })
|
||||
setErrors({ new: "", reNew: "" })
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -45,10 +45,17 @@ export function ChangePassword({ visible, onVisibleChange }: ChangePasswordProps
|
||||
if (!open) clearForm()
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!submitting || open) closeModal(open)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {
|
||||
current: !currentSecretKey ? t("Please enter current password") : "",
|
||||
new: !newSecretKey ? t("Please enter new password") : "",
|
||||
new: !newSecretKey
|
||||
? t("Please enter new password")
|
||||
: newSecretKey.length < PASSWORD_MIN_LENGTH || newSecretKey.length > PASSWORD_MAX_LENGTH
|
||||
? t("password length cannot be less than 8 characters and greater than 40 characters")
|
||||
: "",
|
||||
reNew: !reNewSecretKey
|
||||
? t("Please enter new password again")
|
||||
: reNewSecretKey !== newSecretKey
|
||||
@@ -56,10 +63,11 @@ export function ChangePassword({ visible, onVisibleChange }: ChangePasswordProps
|
||||
: "",
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return !newErrors.current && !newErrors.new && !newErrors.reNew
|
||||
return !newErrors.new && !newErrors.reNew
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (submitting) return
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
@@ -72,9 +80,13 @@ export function ChangePassword({ visible, onVisibleChange }: ChangePasswordProps
|
||||
return
|
||||
}
|
||||
const userInfo = (await api.get("/accountinfo")) as { account_name?: string }
|
||||
if (!userInfo?.account_name) {
|
||||
message.error(t("Failed to get data"))
|
||||
return
|
||||
}
|
||||
await createUser(
|
||||
{
|
||||
accessKey: userInfo?.account_name ?? "",
|
||||
accessKey: userInfo.account_name,
|
||||
secretKey: newSecretKey,
|
||||
status: "enabled",
|
||||
},
|
||||
@@ -91,75 +103,78 @@ export function ChangePassword({ visible, onVisibleChange }: ChangePasswordProps
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={closeModal}>
|
||||
<DialogContent className="overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Change current account password")}</DialogTitle>
|
||||
<Dialog open={visible} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,40rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-lg"
|
||||
aria-busy={submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Change Password")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password-current">{t("Current Password")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-current"
|
||||
name="password-current"
|
||||
value={currentSecretKey}
|
||||
onChange={(e) => setCurrentSecretKey(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.current)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.current}</FieldError>
|
||||
</Field>
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain p-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password-new">{t("New Password")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-new"
|
||||
name="password-new"
|
||||
value={newSecretKey}
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
maxLength={PASSWORD_MAX_LENGTH}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.new)}
|
||||
aria-describedby={errors.new ? "password-new-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="password-new-error">{errors.new}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password-new">{t("New Password")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-new"
|
||||
name="password-new"
|
||||
value={newSecretKey}
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.new)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.new}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password-new-confirm">{t("Confirm New Password")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-new-confirm"
|
||||
name="password-new-confirm"
|
||||
value={reNewSecretKey}
|
||||
onChange={(e) => setReNewSecretKey(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
maxLength={PASSWORD_MAX_LENGTH}
|
||||
required
|
||||
disabled={!newSecretKey || submitting}
|
||||
aria-invalid={Boolean(errors.reNew)}
|
||||
aria-describedby={errors.reNew ? "password-new-confirm-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="password-new-confirm-error">{errors.reNew}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password-new-confirm">{t("Confirm New Password")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-new-confirm"
|
||||
name="password-new-confirm"
|
||||
value={reNewSecretKey}
|
||||
onChange={(e) => setReNewSecretKey(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
disabled={!newSecretKey}
|
||||
aria-invalid={Boolean(errors.reNew)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.reNew}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => closeModal()}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={submitForm} disabled={submitting}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Submit")}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={() => closeModal()} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Submit")}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -73,13 +73,13 @@ export function UserDropdown() {
|
||||
render={
|
||||
<Button variant="ghost" size={isCollapsed ? "icon" : "default"} aria-label={t("User menu")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full border bg-muted">
|
||||
<span className="flex size-6 items-center justify-center overflow-hidden rounded-full border bg-muted">
|
||||
<Image
|
||||
src={avatar}
|
||||
alt={theme.brand.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full object-cover"
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6 rounded-full object-cover"
|
||||
onError={() => {
|
||||
const fallback = resolveAvatarPath(baseAvatarPath)
|
||||
setAvatar((current) => (current === fallback ? current : fallback))
|
||||
|
||||
+158
-89
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiRefreshLine } from "@remixicon/react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
@@ -72,9 +73,14 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
const [policiesList, setPoliciesList] = React.useState<{ label: string; value: string }[]>([])
|
||||
const [activeTab, setActiveTab] = React.useState("account")
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [loadedAccessKey, setLoadedAccessKey] = React.useState("")
|
||||
const [originalStatus, setOriginalStatus] = React.useState("enabled")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
const statusBoolean = user.status === "enabled"
|
||||
const isCurrentUserLoaded = loadedAccessKey === user.accessKey && user.accessKey === row?.accessKey
|
||||
const availableTabs = React.useMemo(() => {
|
||||
return getAvailableUserEditTabs({
|
||||
canEditAccount,
|
||||
@@ -87,35 +93,51 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
React.useEffect(() => {
|
||||
if (!open || !row?.accessKey) return
|
||||
|
||||
let cancelled = false
|
||||
const targetAccessKey = row.accessKey
|
||||
const initialPolicy = Array.isArray(row.policy)
|
||||
? row.policy
|
||||
: ((row.policyName as string | undefined)?.split(",").filter(Boolean) ?? [])
|
||||
const initialMemberOf = (row.memberOf as string[] | undefined) ?? []
|
||||
const initialStatus = (row.status as string) ?? "enabled"
|
||||
|
||||
setUser({
|
||||
accessKey: row.accessKey,
|
||||
accessKey: targetAccessKey,
|
||||
memberOf: initialMemberOf,
|
||||
policy: initialPolicy,
|
||||
status: (row.status as string) ?? "enabled",
|
||||
status: initialStatus,
|
||||
})
|
||||
setOriginalMemberOf(initialMemberOf)
|
||||
setOriginalStatus(initialStatus)
|
||||
setSecretKey("")
|
||||
setErrors({ secretKey: "" })
|
||||
setActiveTab(availableTabs[0] ?? "account")
|
||||
setGroupsList([])
|
||||
setPoliciesList([])
|
||||
setLoadedAccessKey("")
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
|
||||
Promise.all([getUser(row.accessKey), listGroup(), listPolicies()])
|
||||
const needsUserDetail = canEditAccount || canAssignGroups || canEditPolicies
|
||||
Promise.all([
|
||||
needsUserDetail ? getUser(targetAccessKey) : Promise.resolve(null),
|
||||
canAssignGroups ? listGroup() : Promise.resolve([]),
|
||||
canEditPolicies ? listPolicies() : Promise.resolve({}),
|
||||
])
|
||||
.then(([latest, groups, policies]) => {
|
||||
if (cancelled) return
|
||||
const latestInfo = (latest ?? {}) as UserDetail
|
||||
const latestMemberOf = latestInfo.memberOf ?? initialMemberOf
|
||||
const latestStatus = latestInfo.status ?? initialStatus
|
||||
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
setUser({
|
||||
accessKey: targetAccessKey,
|
||||
memberOf: latestMemberOf,
|
||||
policy: latestInfo.policyName?.split(",").filter(Boolean) ?? initialPolicy,
|
||||
status: latestInfo.status ?? prev.status,
|
||||
}))
|
||||
status: latestStatus,
|
||||
})
|
||||
setOriginalMemberOf(latestMemberOf)
|
||||
setOriginalStatus(latestStatus)
|
||||
setGroupsList((groups as string[] | undefined)?.map((group) => ({ label: group, value: group })) ?? [])
|
||||
setPoliciesList(
|
||||
Object.keys((policies as Record<string, unknown> | undefined) ?? {}).map((policy) => ({
|
||||
@@ -123,12 +145,35 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
value: policy,
|
||||
})),
|
||||
)
|
||||
setLoadedAccessKey(targetAccessKey)
|
||||
})
|
||||
.catch(() => {
|
||||
message.error(t("Failed to get data"))
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
const reason = (error as Error)?.message || t("Failed to get data")
|
||||
setLoadError(reason)
|
||||
message.error(reason)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [availableTabs, open, row, getUser, listGroup, listPolicies, message, t])
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
availableTabs,
|
||||
canAssignGroups,
|
||||
canEditAccount,
|
||||
canEditPolicies,
|
||||
getUser,
|
||||
listGroup,
|
||||
listPolicies,
|
||||
loadVersion,
|
||||
message,
|
||||
open,
|
||||
row,
|
||||
t,
|
||||
])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = { secretKey: "" }
|
||||
@@ -139,24 +184,13 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
return !newErrors.secretKey
|
||||
}
|
||||
|
||||
const handleStatusChange = async (checked: boolean) => {
|
||||
if (!canEditAccount) return
|
||||
const nextStatus = checked ? "enabled" : "disabled"
|
||||
if (!user.accessKey) return
|
||||
|
||||
const previousStatus = user.status
|
||||
try {
|
||||
await changeUserStatus(user.accessKey, { status: nextStatus })
|
||||
setUser((prev) => ({ ...prev, status: nextStatus }))
|
||||
onSuccess()
|
||||
} catch {
|
||||
setUser((prev) => ({ ...prev, status: previousStatus }))
|
||||
message.error(t("Edit Failed"))
|
||||
}
|
||||
const handleStatusChange = (checked: boolean) => {
|
||||
if (!canEditAccount || !isCurrentUserLoaded || loading || loadError || submitting) return
|
||||
setUser((current) => ({ ...current, status: checked ? "enabled" : "disabled" }))
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!user.accessKey) return
|
||||
if (!user.accessKey || !isCurrentUserLoaded || loading || loadError || submitting) return
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
@@ -173,6 +207,8 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
},
|
||||
{ suppress403Redirect: true },
|
||||
)
|
||||
} else if (canEditAccount && user.status !== originalStatus) {
|
||||
await changeUserStatus(user.accessKey, { status: user.status })
|
||||
}
|
||||
|
||||
if (canEditPolicies) {
|
||||
@@ -224,83 +260,116 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
}
|
||||
|
||||
const closeModal = () => onOpenChange(false)
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
const isAccessKeysTab = activeTab === "access-keys"
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className="overflow-x-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{user.accessKey || row?.accessKey || t("Account")}</DialogTitle>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
aria-busy={loading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{row?.accessKey || t("Account")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-2 max-h-[80vh] space-y-4 overflow-y-auto overflow-x-hidden px-2">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-4">
|
||||
<TabsList className="justify-start overflow-x-auto overflow-y-hidden">
|
||||
{canEditAccount ? <TabsTrigger value="account">{t("Account")}</TabsTrigger> : null}
|
||||
{canAssignGroups ? <TabsTrigger value="groups">{t("Groups")}</TabsTrigger> : null}
|
||||
{canEditPolicies ? <TabsTrigger value="policy">{t("Policies")}</TabsTrigger> : null}
|
||||
{canManageAccessKeys ? <TabsTrigger value="access-keys">{t("Access Keys")}</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-72 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div
|
||||
className="flex min-h-72 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="max-w-md text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={() => setLoadVersion((current) => current + 1)}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : isCurrentUserLoaded ? (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-4">
|
||||
<TabsList className="justify-start overflow-x-auto overflow-y-hidden">
|
||||
{canEditAccount ? <TabsTrigger value="account">{t("Account")}</TabsTrigger> : null}
|
||||
{canAssignGroups ? <TabsTrigger value="groups">{t("Groups")}</TabsTrigger> : null}
|
||||
{canEditPolicies ? <TabsTrigger value="policy">{t("Policies")}</TabsTrigger> : null}
|
||||
{canManageAccessKeys ? <TabsTrigger value="access-keys">{t("Access Keys")}</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
{canEditAccount ? (
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="flex items-center justify-start gap-2 border px-3 py-2">
|
||||
<label htmlFor="edit-user-status" className="text-sm text-muted-foreground">
|
||||
{t("Status")}
|
||||
</label>
|
||||
<Switch
|
||||
id="edit-user-status"
|
||||
checked={statusBoolean}
|
||||
onCheckedChange={handleStatusChange}
|
||||
{canEditAccount ? (
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="flex items-center justify-start gap-2 border px-3 py-2">
|
||||
<label htmlFor="edit-user-status" className="text-sm text-muted-foreground">
|
||||
{t("Status")}
|
||||
</label>
|
||||
<Switch
|
||||
id="edit-user-status"
|
||||
checked={statusBoolean}
|
||||
onCheckedChange={handleStatusChange}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</div>
|
||||
<UserEditSecretKey
|
||||
value={secretKey}
|
||||
error={errors.secretKey}
|
||||
disabled={loading || submitting}
|
||||
onChange={setSecretKey}
|
||||
/>
|
||||
</div>
|
||||
<UserEditSecretKey
|
||||
value={secretKey}
|
||||
error={errors.secretKey}
|
||||
disabled={loading || submitting}
|
||||
onChange={setSecretKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canAssignGroups ? (
|
||||
<TabsContent value="groups" className="mt-0">
|
||||
<UserEditGroups
|
||||
value={user.memberOf}
|
||||
options={groupsList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(memberOf) => setUser((prev) => ({ ...prev, memberOf }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{canAssignGroups ? (
|
||||
<TabsContent value="groups" className="mt-0">
|
||||
<UserEditGroups
|
||||
value={user.memberOf}
|
||||
options={groupsList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(memberOf) => setUser((prev) => ({ ...prev, memberOf }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canEditPolicies ? (
|
||||
<TabsContent value="policy" className="mt-0">
|
||||
<UserEditPolicies
|
||||
value={user.policy}
|
||||
options={policiesList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(policy) => setUser((prev) => ({ ...prev, policy }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{canEditPolicies ? (
|
||||
<TabsContent value="policy" className="mt-0">
|
||||
<UserEditPolicies
|
||||
value={user.policy}
|
||||
options={policiesList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(policy) => setUser((prev) => ({ ...prev, policy }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canManageAccessKeys ? (
|
||||
<TabsContent value="access-keys" className="mt-0">
|
||||
<UserEditAccessKeys userName={user.accessKey} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
{canManageAccessKeys ? (
|
||||
<TabsContent value="access-keys" className="mt-0">
|
||||
<UserEditAccessKeys userName={user.accessKey} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={submitForm} disabled={loading || submitting || availableTabs.length === 0}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Submit")}</span>
|
||||
{isAccessKeysTab ? t("Close") : t("Cancel")}
|
||||
</Button>
|
||||
{!isAccessKeysTab && !loadError ? (
|
||||
<Button
|
||||
onClick={submitForm}
|
||||
disabled={loading || submitting || availableTabs.length === 0 || !isCurrentUserLoaded}
|
||||
>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Submit")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import * as React from "react"
|
||||
import dayjs from "dayjs"
|
||||
import { RiAddLine, RiDeleteBin5Line, RiEdit2Line } from "@remixicon/react"
|
||||
import { RiAddLine, RiDeleteBin5Line, RiEdit2Line, RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { DateTimePicker } from "@/components/datetime-picker"
|
||||
@@ -84,9 +84,12 @@ function UserAccessKeysNewDialog({ open, onOpenChange, userName, onSuccess, onNo
|
||||
const [description, setDescription] = React.useState("")
|
||||
const [expiry, setExpiry] = React.useState<string | null>(null)
|
||||
const [policy, setPolicy] = React.useState("{}")
|
||||
const [parentPolicy, setParentPolicy] = React.useState("{}")
|
||||
const [impliedPolicy, setImpliedPolicy] = React.useState(true)
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [parentPolicyLoading, setParentPolicyLoading] = React.useState(false)
|
||||
const [parentPolicyError, setParentPolicyError] = React.useState("")
|
||||
const [parentPolicyVersion, setParentPolicyVersion] = React.useState(0)
|
||||
const [formOwner, setFormOwner] = React.useState("")
|
||||
const [errors, setErrors] = React.useState({
|
||||
accessKey: "",
|
||||
secretKey: "",
|
||||
@@ -94,9 +97,15 @@ function UserAccessKeysNewDialog({ open, onOpenChange, userName, onSuccess, onNo
|
||||
})
|
||||
|
||||
const minExpiry = React.useMemo(() => new Date().toISOString(), [])
|
||||
const ownerMismatch = open && formOwner !== userName
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
setFormOwner("")
|
||||
return
|
||||
}
|
||||
const targetUser = userName
|
||||
setFormOwner(targetUser)
|
||||
|
||||
try {
|
||||
setAccessKey(makeRandomString(20))
|
||||
@@ -110,31 +119,45 @@ function UserAccessKeysNewDialog({ open, onOpenChange, userName, onSuccess, onNo
|
||||
setName("")
|
||||
setDescription("")
|
||||
setExpiry(null)
|
||||
setPolicy("{}")
|
||||
setImpliedPolicy(true)
|
||||
setErrors({ accessKey: "", secretKey: "", name: "" })
|
||||
|
||||
getPolicyByUserName(userName)
|
||||
.then((result) => {
|
||||
const value = JSON.stringify(result ?? {}, null, 2)
|
||||
setParentPolicy(value)
|
||||
setPolicy(value)
|
||||
})
|
||||
.catch(() => {
|
||||
setParentPolicy("{}")
|
||||
setPolicy("{}")
|
||||
})
|
||||
}, [getPolicyByUserName, message, open, t, userName])
|
||||
}, [message, open, t, userName])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (impliedPolicy) {
|
||||
setPolicy(parentPolicy)
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
const targetUser = userName
|
||||
setParentPolicyError("")
|
||||
setParentPolicyLoading(true)
|
||||
|
||||
getPolicyByUserName(targetUser)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
const value = JSON.stringify(result ?? {}, null, 2)
|
||||
setPolicy(value)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
setParentPolicyError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setParentPolicyLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [impliedPolicy, parentPolicy])
|
||||
}, [getPolicyByUserName, open, parentPolicyVersion, t, userName])
|
||||
|
||||
const closeModal = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const nextErrors = { accessKey: "", secretKey: "", name: "" }
|
||||
|
||||
@@ -159,6 +182,7 @@ function UserAccessKeysNewDialog({ open, onOpenChange, userName, onSuccess, onNo
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (ownerMismatch || submitting || (!impliedPolicy && (parentPolicyLoading || parentPolicyError))) return
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
@@ -200,146 +224,212 @@ function UserAccessKeysNewDialog({ open, onOpenChange, userName, onSuccess, onNo
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className={cn("overflow-x-hidden sm:max-w-xl", !impliedPolicy && "sm:max-w-6xl")}>
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal={submitting}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
!impliedPolicy && "sm:max-w-6xl",
|
||||
)}
|
||||
aria-busy={ownerMismatch || parentPolicyLoading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Create Key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-2 flex max-h-[80vh] flex-col gap-4 overflow-y-auto overflow-x-hidden px-2 lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4">
|
||||
{ownerMismatch ? (
|
||||
<div
|
||||
className="flex min-h-72 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 lg:min-h-[32rem] lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-access-key"
|
||||
name="create-user-access-key"
|
||||
value={accessKey}
|
||||
onChange={(event) => setAccessKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.accessKey)}
|
||||
aria-describedby={errors.accessKey ? "create-user-access-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="create-user-access-key-error">{errors.accessKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-secret-key"
|
||||
name="create-user-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(event) => setSecretKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.secretKey)}
|
||||
aria-describedby={errors.secretKey ? "create-user-secret-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="create-user-secret-key-error">{errors.secretKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-expiry">{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="create-user-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
placeholder={t("Please select expiry date")}
|
||||
disabled={submitting}
|
||||
aria-describedby="create-user-expiry-description"
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription id="create-user-expiry-description">
|
||||
{t("empty is indicates permanent validity")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-key-name"
|
||||
name="create-user-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? "create-user-key-name-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="create-user-key-name-error">{errors.name}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="create-user-key-description"
|
||||
name="create-user-key-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={3}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<FieldLabel htmlFor="create-user-access-key-implied-policy" className="text-sm font-medium">
|
||||
{t("Use main account policy")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch
|
||||
id="create-user-access-key-implied-policy"
|
||||
checked={impliedPolicy}
|
||||
onCheckedChange={setImpliedPolicy}
|
||||
disabled={parentPolicyLoading || Boolean(parentPolicyError) || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
{parentPolicyLoading ? (
|
||||
<FieldDescription className="flex items-center gap-2" role="status">
|
||||
<Spinner className="size-3.5" />
|
||||
{t("Loading…")}
|
||||
</FieldDescription>
|
||||
) : parentPolicyError ? (
|
||||
<div className="flex flex-wrap items-center gap-2" role="alert">
|
||||
<FieldError className="flex-1">{parentPolicyError}</FieldError>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setParentPolicyVersion((current) => current + 1)}
|
||||
>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy ? (
|
||||
<div className="min-h-[20rem] flex-1 lg:min-h-0">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="create-user-policy">{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="create-user-policy"
|
||||
name="create-user-policy"
|
||||
value={policy}
|
||||
onChange={(event) => setPolicy(event.target.value)}
|
||||
className="min-h-[24rem] font-mono text-xs lg:h-full"
|
||||
spellCheck={false}
|
||||
disabled={parentPolicyLoading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-access-key"
|
||||
name="create-user-access-key"
|
||||
value={accessKey}
|
||||
onChange={(event) => setAccessKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.accessKey)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.accessKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-secret-key"
|
||||
name="create-user-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(event) => setSecretKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.secretKey)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.secretKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-expiry">
|
||||
{t("Expiry")}({t("empty is indicates permanent validity")})
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="create-user-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
placeholder={t("Please select expiry date")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-key-name"
|
||||
name="create-user-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.name}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="create-user-key-description"
|
||||
name="create-user-key-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<FieldLabel htmlFor="create-user-access-key-implied-policy" className="text-sm font-medium">
|
||||
{t("Use main account policy")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch
|
||||
id="create-user-access-key-implied-policy"
|
||||
checked={impliedPolicy}
|
||||
onCheckedChange={setImpliedPolicy}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy ? (
|
||||
<div className="max-h-[60vh] flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="create-user-policy">{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="create-user-policy"
|
||||
name="create-user-policy"
|
||||
value={policy}
|
||||
onChange={(event) => setPolicy(event.target.value)}
|
||||
className="h-full min-h-[200px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={
|
||||
ownerMismatch || submitting || (!impliedPolicy && (parentPolicyLoading || Boolean(parentPolicyError)))
|
||||
}
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -353,21 +443,38 @@ function UserAccessKeysEditDialog({ open, onOpenChange, userName, row, onSuccess
|
||||
|
||||
const [accessKey, setAccessKey] = React.useState("")
|
||||
const [policy, setPolicy] = React.useState("{}")
|
||||
const [parentPolicy, setParentPolicy] = React.useState("{}")
|
||||
const [impliedPolicy, setImpliedPolicy] = React.useState(true)
|
||||
const [expiry, setExpiry] = React.useState<string | null>(null)
|
||||
const [name, setName] = React.useState("")
|
||||
const [description, setDescription] = React.useState("")
|
||||
const [status, setStatus] = React.useState<"on" | "off">("on")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [policyError, setPolicyError] = React.useState("")
|
||||
|
||||
const minExpiry = React.useMemo(() => dayjs().toISOString(), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !row?.accessKey) return
|
||||
let cancelled = false
|
||||
const targetAccessKey = row.accessKey
|
||||
|
||||
Promise.all([getServiceAccount(row.accessKey), getPolicyByUserName(userName)])
|
||||
setAccessKey(targetAccessKey)
|
||||
setPolicy("{}")
|
||||
setImpliedPolicy(true)
|
||||
setExpiry(null)
|
||||
setName("")
|
||||
setDescription("")
|
||||
setStatus("on")
|
||||
setPolicyError("")
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
|
||||
Promise.all([getServiceAccount(targetAccessKey), getPolicyByUserName(userName)])
|
||||
.then(([result, parentUserPolicy]) => {
|
||||
if (cancelled) return
|
||||
const response = result as {
|
||||
impliedPolicy?: boolean
|
||||
policy?: unknown
|
||||
@@ -381,8 +488,6 @@ function UserAccessKeysEditDialog({ open, onOpenChange, userName, row, onSuccess
|
||||
const nextImpliedPolicy =
|
||||
typeof response.impliedPolicy === "boolean" ? response.impliedPolicy : response.policy == null
|
||||
|
||||
setAccessKey(row.accessKey)
|
||||
setParentPolicy(parentPolicyValue)
|
||||
setImpliedPolicy(nextImpliedPolicy)
|
||||
setPolicy(nextImpliedPolicy ? parentPolicyValue : formatPolicy(response.policy))
|
||||
setExpiry(
|
||||
@@ -394,37 +499,50 @@ function UserAccessKeysEditDialog({ open, onOpenChange, userName, row, onSuccess
|
||||
setDescription(response.description ?? "")
|
||||
setStatus((response.accountStatus as "on" | "off") ?? "on")
|
||||
})
|
||||
.catch(() => {
|
||||
message.error(t("Failed to get data"))
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
const reason = (error as Error)?.message || t("Failed to get data")
|
||||
setLoadError(reason)
|
||||
message.error(reason)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
}, [getPolicyByUserName, getServiceAccount, message, open, row?.accessKey, t, userName])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (impliedPolicy) {
|
||||
setPolicy(parentPolicy)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [impliedPolicy, parentPolicy])
|
||||
}, [getPolicyByUserName, getServiceAccount, loadVersion, message, open, row?.accessKey, t, userName])
|
||||
|
||||
const closeModal = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!accessKey) return
|
||||
if (!accessKey || accessKey !== row?.accessKey || loading || loadError || submitting) return
|
||||
setPolicyError("")
|
||||
|
||||
let customPolicy: string | null = null
|
||||
if (!impliedPolicy) {
|
||||
try {
|
||||
const parsed = JSON.parse(policy || "{}") as unknown
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
setPolicyError(t("Policy must be a valid JSON object"))
|
||||
return
|
||||
}
|
||||
customPolicy = JSON.stringify(parsed)
|
||||
} catch {
|
||||
setPolicyError(t("Policy format invalid"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let customPolicy: string | null = null
|
||||
if (!impliedPolicy) {
|
||||
try {
|
||||
customPolicy = JSON.stringify(JSON.parse(policy || "{}") as Record<string, unknown>)
|
||||
} catch {
|
||||
message.error(t("Policy format invalid"))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updateServiceAccount(accessKey, {
|
||||
newPolicy: customPolicy,
|
||||
newStatus: status,
|
||||
@@ -444,123 +562,181 @@ function UserAccessKeysEditDialog({ open, onOpenChange, userName, row, onSuccess
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className={cn("overflow-x-hidden sm:max-w-xl", !impliedPolicy && "sm:max-w-6xl")}>
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal={submitting}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
!impliedPolicy && "sm:max-w-6xl",
|
||||
)}
|
||||
aria-busy={loading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Edit Key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-2 flex max-h-[80vh] flex-col gap-4 overflow-y-auto overflow-x-hidden px-2 lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4">
|
||||
{loading || accessKey !== row?.accessKey ? (
|
||||
<div
|
||||
className="flex min-h-72 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div
|
||||
className="flex min-h-72 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="max-w-md text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={() => setLoadVersion((current) => current + 1)}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 lg:min-h-[32rem] lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="edit-user-access-key" name="edit-user-access-key" value={accessKey} disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-expiry">{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="edit-user-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="edit-user-key-name"
|
||||
name="edit-user-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="edit-user-key-description"
|
||||
name="edit-user-key-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={2}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<FieldLabel htmlFor="edit-user-access-key-implied-policy" className="text-sm font-medium">
|
||||
{t("Use main account policy")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch
|
||||
id="edit-user-access-key-implied-policy"
|
||||
checked={impliedPolicy}
|
||||
onCheckedChange={setImpliedPolicy}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel htmlFor="edit-user-access-key-status" className="text-sm font-medium">
|
||||
{t("Status")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch
|
||||
id="edit-user-access-key-status"
|
||||
checked={status === "on"}
|
||||
onCheckedChange={(checked) => setStatus(checked ? "on" : "off")}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy ? (
|
||||
<div className="min-h-[20rem] flex-1 lg:min-h-0">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="edit-user-policy">{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="edit-user-policy"
|
||||
name="edit-user-policy"
|
||||
value={policy}
|
||||
onChange={(event) => {
|
||||
setPolicy(event.target.value)
|
||||
setPolicyError("")
|
||||
}}
|
||||
className="min-h-[24rem] font-mono text-xs lg:h-full"
|
||||
spellCheck={false}
|
||||
disabled={loading || submitting}
|
||||
aria-invalid={Boolean(policyError)}
|
||||
aria-describedby={policyError ? "edit-user-policy-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="edit-user-policy-error">{policyError}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="edit-user-access-key" name="edit-user-access-key" value={accessKey} disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-expiry">{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker id="edit-user-expiry" value={expiry} onChange={setExpiry} min={minExpiry} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="edit-user-key-name"
|
||||
name="edit-user-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-user-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="edit-user-key-description"
|
||||
name="edit-user-key-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 border p-3">
|
||||
<FieldLabel htmlFor="edit-user-access-key-implied-policy" className="text-sm font-medium">
|
||||
{t("Use main account policy")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch
|
||||
id="edit-user-access-key-implied-policy"
|
||||
checked={impliedPolicy}
|
||||
onCheckedChange={setImpliedPolicy}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel htmlFor="edit-user-access-key-status" className="text-sm font-medium">
|
||||
{t("Status")}
|
||||
</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch
|
||||
id="edit-user-access-key-status"
|
||||
checked={status === "on"}
|
||||
onCheckedChange={(checked) => setStatus(checked ? "on" : "off")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy ? (
|
||||
<div className="max-h-[60vh] flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Field className="h-full">
|
||||
<FieldLabel htmlFor="edit-user-policy">{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
id="edit-user-policy"
|
||||
name="edit-user-policy"
|
||||
value={policy}
|
||||
onChange={(event) => setPolicy(event.target.value)}
|
||||
className="h-full min-h-[200px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={loading || submitting || Boolean(loadError) || accessKey !== row?.accessKey}
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -580,46 +756,80 @@ export function UserEditAccessKeys({ userName }: UserEditAccessKeysProps) {
|
||||
|
||||
const [data, setData] = React.useState<AccessKeyRow[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadedUserName, setLoadedUserName] = React.useState("")
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const [newDialogOpen, setNewDialogOpen] = React.useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = React.useState(false)
|
||||
const [editRow, setEditRow] = React.useState<AccessKeyRow | null>(null)
|
||||
const [noticeOpen, setNoticeOpen] = React.useState(false)
|
||||
const [noticeData, setNoticeData] = React.useState<CredentialsData | null>(null)
|
||||
const requestVersionRef = React.useRef(0)
|
||||
const activeUserRef = React.useRef(userName)
|
||||
|
||||
const loadAccounts = React.useCallback(async () => {
|
||||
if (!userName) return
|
||||
const targetUser = userName
|
||||
const requestId = ++requestVersionRef.current
|
||||
if (!targetUser) {
|
||||
setData([])
|
||||
setLoadError("")
|
||||
setLoadedUserName("")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = (await listAllUserServiceAccounts(userName)) as { accounts?: AccessKeyRow[] }
|
||||
const response = (await listAllUserServiceAccounts(targetUser)) as { accounts?: AccessKeyRow[] }
|
||||
if (requestId !== requestVersionRef.current || activeUserRef.current !== targetUser) return
|
||||
setData(response?.accounts ?? [])
|
||||
setLoadedUserName(targetUser)
|
||||
} catch (error) {
|
||||
if (requestId !== requestVersionRef.current || activeUserRef.current !== targetUser) return
|
||||
console.error(error)
|
||||
message.error(t("Get Data Failed"))
|
||||
setLoadError((error as Error)?.message || t("Get Data Failed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestId === requestVersionRef.current && activeUserRef.current === targetUser) setLoading(false)
|
||||
}
|
||||
}, [listAllUserServiceAccounts, message, t, userName])
|
||||
}, [listAllUserServiceAccounts, t, userName])
|
||||
|
||||
React.useEffect(() => {
|
||||
activeUserRef.current = userName
|
||||
requestVersionRef.current += 1
|
||||
setData([])
|
||||
setLoadedUserName("")
|
||||
setSearchTerm("")
|
||||
setLoadError("")
|
||||
setNewDialogOpen(false)
|
||||
setEditDialogOpen(false)
|
||||
setEditRow(null)
|
||||
setNoticeOpen(false)
|
||||
setNoticeData(null)
|
||||
void loadAccounts()
|
||||
}, [loadAccounts])
|
||||
|
||||
return () => {
|
||||
requestVersionRef.current += 1
|
||||
}
|
||||
}, [loadAccounts, userName])
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (loadedUserName !== userName) return []
|
||||
if (!searchTerm) return data
|
||||
const term = searchTerm.toLowerCase()
|
||||
return data.filter((row) => row.accessKey.toLowerCase().includes(term))
|
||||
}, [data, searchTerm])
|
||||
}, [data, loadedUserName, searchTerm, userName])
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
(row: AccessKeyRow) => {
|
||||
const targetUser = userName
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this key?"),
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: async () => {
|
||||
if (activeUserRef.current !== targetUser) return
|
||||
try {
|
||||
await deleteServiceAccount(row.accessKey)
|
||||
message.success(t("Delete Success"))
|
||||
@@ -631,7 +841,7 @@ export function UserEditAccessKeys({ userName }: UserEditAccessKeysProps) {
|
||||
},
|
||||
})
|
||||
},
|
||||
[deleteServiceAccount, dialog, loadAccounts, message, t],
|
||||
[deleteServiceAccount, dialog, loadAccounts, message, t, userName],
|
||||
)
|
||||
|
||||
const columns = React.useMemo<ColumnDef<AccessKeyRow>[]>(
|
||||
@@ -735,13 +945,26 @@ export function UserEditAccessKeys({ userName }: UserEditAccessKeysProps) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Access Keys")}
|
||||
emptyDescription={t("Create a new access key to get started.")}
|
||||
tableClass="min-w-full"
|
||||
/>
|
||||
{loadError ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-3 border border-dashed p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="max-w-md text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={() => void loadAccounts()}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading || loadedUserName !== userName}
|
||||
emptyTitle={t("No Access Keys")}
|
||||
emptyDescription={t("Create a new access key to get started.")}
|
||||
tableClass="min-w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserAccessKeysNewDialog
|
||||
open={newDialogOpen}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect, useId, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiArrowDownSLine, RiCheckLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandList } from "@/components/ui/command"
|
||||
import { MultiSelectCommandItem } from "@/components/user/multi-select-command-item"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface OptionItem {
|
||||
@@ -25,8 +26,16 @@ interface UserEditGroupsProps {
|
||||
export function UserEditGroups({ value, options, disabled = false, onChange }: UserEditGroupsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerId = useId()
|
||||
const selectionSummaryId = `${triggerId}-selection`
|
||||
const selectionSummary = value.length ? value.join(", ") : t("Select Group")
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) setOpen(false)
|
||||
}, [disabled])
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
if (disabled) return
|
||||
if (value.includes(group)) {
|
||||
onChange(value.filter((item) => item !== group))
|
||||
return
|
||||
@@ -36,36 +45,47 @@ export function UserEditGroups({ value, options, disabled = false, onChange }: U
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{t("Groups")}</FieldLabel>
|
||||
<FieldLabel htmlFor={triggerId}>{t("Groups")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={(nextOpen) => !disabled && setOpen(nextOpen)}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
id={triggerId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 w-full justify-between gap-2"
|
||||
aria-label={t("Groups")}
|
||||
className="min-h-10 w-full min-w-0 justify-between gap-2"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`${t("Groups")}: ${selectionSummary}`}
|
||||
aria-describedby={selectionSummaryId}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate">{value.length ? value.join(", ") : t("Select Group")}</span>
|
||||
<span id={selectionSummaryId} className="truncate">
|
||||
{selectionSummary}
|
||||
</span>
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<PopoverContent className="w-(--anchor-width) max-w-(--available-width) p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search Group")} />
|
||||
<CommandList>
|
||||
<CommandInput aria-label={t("Search Group")} placeholder={t("Search Group")} />
|
||||
<CommandList role="listbox" aria-label={t("Groups")} aria-multiselectable="true">
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem key={option.value} value={option.label} onSelect={() => toggleGroup(option.value)}>
|
||||
<MultiSelectCommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
selected={value.includes(option.value)}
|
||||
onSelect={() => toggleGroup(option.value)}
|
||||
>
|
||||
<RiCheckLine
|
||||
className={cn("me-2 size-4", value.includes(option.value) ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
<span className="min-w-0 break-all">{option.label}</span>
|
||||
</MultiSelectCommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
@@ -75,8 +95,8 @@ export function UserEditGroups({ value, options, disabled = false, onChange }: U
|
||||
{value.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{value.map((group) => (
|
||||
<Badge key={group} variant="secondary">
|
||||
{group}
|
||||
<Badge key={group} variant="secondary" className="h-auto max-w-full whitespace-normal">
|
||||
<span className="break-all">{group}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CommandItem } from "@/components/ui/command"
|
||||
|
||||
type MultiSelectCommandItemProps = Omit<React.ComponentProps<typeof CommandItem>, "aria-selected" | "data-checked"> & {
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export function MultiSelectCommandItem({ selected, children, ...props }: MultiSelectCommandItemProps) {
|
||||
const itemRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const item = itemRef.current
|
||||
if (!item) return
|
||||
|
||||
const syncSelection = () => {
|
||||
if (item.getAttribute("aria-selected") !== String(selected)) {
|
||||
item.setAttribute("aria-selected", String(selected))
|
||||
}
|
||||
}
|
||||
|
||||
syncSelection()
|
||||
const observer = new MutationObserver(syncSelection)
|
||||
observer.observe(item, { attributes: true, attributeFilter: ["aria-selected"] })
|
||||
return () => observer.disconnect()
|
||||
}, [selected])
|
||||
|
||||
return (
|
||||
<CommandItem ref={itemRef} {...props} data-checked={selected} aria-selected={selected}>
|
||||
{children}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
+245
-170
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiArrowDownSLine, RiCheckLine } from "@remixicon/react"
|
||||
import { RiArrowDownSLine, RiCheckLine, RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -44,23 +44,54 @@ export function UserNewForm({ open, onOpenChange, onSuccess }: UserNewFormProps)
|
||||
const [groupOpen, setGroupOpen] = React.useState(false)
|
||||
const [policyOpen, setPolicyOpen] = React.useState(false)
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const [errors, setErrors] = React.useState({ accessKey: "", secretKey: "" })
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setAccessKey("")
|
||||
setSecretKey("")
|
||||
setGroups([])
|
||||
setPolicies([])
|
||||
setErrors({ accessKey: "", secretKey: "" })
|
||||
Promise.all([
|
||||
listGroup().then((res: string[]) => setGroupsList((res ?? []).map((g) => ({ label: g, value: g })))),
|
||||
listPolicies().then((res: Record<string, unknown>) =>
|
||||
setPoliciesList(Object.keys(res ?? {}).map((p) => ({ label: p, value: p }))),
|
||||
),
|
||||
]).catch(() => {})
|
||||
if (!open) return
|
||||
|
||||
setAccessKey("")
|
||||
setSecretKey("")
|
||||
setGroups([])
|
||||
setPolicies([])
|
||||
setGroupsList([])
|
||||
setPoliciesList([])
|
||||
setGroupOpen(false)
|
||||
setPolicyOpen(false)
|
||||
setErrors({ accessKey: "", secretKey: "" })
|
||||
}, [open])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
|
||||
setGroupsList([])
|
||||
setPoliciesList([])
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
|
||||
Promise.all([listGroup(), listPolicies()])
|
||||
.then(([groupResult, policyResult]) => {
|
||||
if (cancelled) return
|
||||
setGroupsList(((groupResult as string[] | undefined) ?? []).map((g) => ({ label: g, value: g })))
|
||||
setPoliciesList(
|
||||
Object.keys((policyResult as Record<string, unknown> | undefined) ?? {}).map((p) => ({ label: p, value: p })),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
setLoadError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, listGroup, listPolicies])
|
||||
}, [listGroup, listPolicies, loadVersion, open, t])
|
||||
|
||||
const toggleGroup = (value: string) => {
|
||||
setGroups((prev) => (prev.includes(value) ? prev.filter((g) => g !== value) : [...prev, value]))
|
||||
@@ -71,6 +102,9 @@ export function UserNewForm({ open, onOpenChange, onSuccess }: UserNewFormProps)
|
||||
}
|
||||
|
||||
const closeModal = () => onOpenChange(false)
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!submitting || nextOpen) onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = { accessKey: "", secretKey: "" }
|
||||
@@ -89,6 +123,7 @@ export function UserNewForm({ open, onOpenChange, onSuccess }: UserNewFormProps)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (submitting) return
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
@@ -135,177 +170,217 @@ export function UserNewForm({ open, onOpenChange, onSuccess }: UserNewFormProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className="overflow-x-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,52rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||
aria-busy={loading || submitting}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3 pe-12">
|
||||
<DialogTitle>{t("Create User")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="-mx-2 max-h-[80vh] space-y-6 overflow-y-auto overflow-x-hidden px-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitForm()
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 space-y-6 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-user-access-key">{t("User Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="new-user-access-key"
|
||||
name="new-user-access-key"
|
||||
value={accessKey}
|
||||
onChange={(e) => setAccessKey(e.target.value)}
|
||||
minLength={USERNAME_MIN_LENGTH}
|
||||
maxLength={USERNAME_MAX_LENGTH}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.accessKey)}
|
||||
aria-describedby={errors.accessKey ? "new-user-access-key-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="new-user-access-key-error">{errors.accessKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-user-password">{t("Password")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="new-user-password"
|
||||
name="new-user-password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
type="password"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
maxLength={PASSWORD_MAX_LENGTH}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(errors.secretKey)}
|
||||
aria-describedby={errors.secretKey ? "new-user-password-error" : undefined}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError id="new-user-password-error">{errors.secretKey}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border border-dashed p-3 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex flex-wrap items-center gap-3 border border-dashed p-3" role="alert">
|
||||
<p className="min-w-0 flex-1 text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" onClick={() => setLoadVersion((current) => current + 1)}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-user-access-key">{t("User Name")}</FieldLabel>
|
||||
<FieldLabel>{t("Groups")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="new-user-access-key"
|
||||
name="new-user-access-key"
|
||||
value={accessKey}
|
||||
onChange={(e) => setAccessKey(e.target.value)}
|
||||
minLength={USERNAME_MIN_LENGTH}
|
||||
maxLength={USERNAME_MAX_LENGTH}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.accessKey)}
|
||||
/>
|
||||
<Popover open={groupOpen} onOpenChange={setGroupOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 w-full min-w-0 justify-between gap-2"
|
||||
aria-label={t("Groups")}
|
||||
disabled={loading || Boolean(loadError) || submitting}
|
||||
>
|
||||
<span className="truncate">{groups.length ? groups.join(", ") : t("Select Group")}</span>
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search Group")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{groupsList.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => toggleGroup(option.value)}
|
||||
>
|
||||
<RiCheckLine
|
||||
className={cn(
|
||||
"me-2 size-4",
|
||||
groups.includes(option.value) ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{groups.length > 0 && (
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||
{groups.map((value) => (
|
||||
<Badge key={value} variant="secondary" className="h-auto max-w-full whitespace-normal">
|
||||
<span className="min-w-0 break-all">{value}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FieldContent>
|
||||
<FieldError>{errors.accessKey}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-user-password">{t("Password")}</FieldLabel>
|
||||
<FieldLabel>{t("Policy")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="new-user-password"
|
||||
name="new-user-password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
type="password"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
maxLength={PASSWORD_MAX_LENGTH}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.secretKey)}
|
||||
/>
|
||||
<Popover open={policyOpen} onOpenChange={setPolicyOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 w-full min-w-0 justify-between gap-2"
|
||||
aria-label={t("Policy")}
|
||||
disabled={loading || Boolean(loadError) || submitting}
|
||||
>
|
||||
<span className="truncate">
|
||||
{policies.length ? policies.join(", ") : t("Select user group policies")}
|
||||
</span>
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search Policy")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{policiesList.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => togglePolicy(option.value)}
|
||||
>
|
||||
<RiCheckLine
|
||||
className={cn(
|
||||
"me-2 size-4",
|
||||
policies.includes(option.value) ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{policies.length > 0 && (
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||
{policies.map((value) => (
|
||||
<Badge key={value} variant="secondary" className="h-auto max-w-full whitespace-normal">
|
||||
<span className="min-w-0 break-all">{value}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FieldContent>
|
||||
<FieldError>{errors.secretKey}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Groups")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Popover open={groupOpen} onOpenChange={setGroupOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 justify-between gap-2"
|
||||
aria-label={t("Groups")}
|
||||
>
|
||||
<span className="truncate">{groups.length ? groups.join(", ") : t("Select Group")}</span>
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search Group")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{groupsList.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => toggleGroup(option.value)}
|
||||
>
|
||||
<RiCheckLine
|
||||
className={cn("me-2 size-4", groups.includes(option.value) ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{groups.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{groups.map((value) => (
|
||||
<Badge key={value} variant="secondary">
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="default" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Policy")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Popover open={policyOpen} onOpenChange={setPolicyOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 justify-between gap-2"
|
||||
aria-label={t("Policy")}
|
||||
>
|
||||
<span className="truncate">
|
||||
{policies.length ? policies.join(", ") : t("Select user group policies")}
|
||||
</span>
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search Policy")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{policiesList.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => togglePolicy(option.value)}
|
||||
>
|
||||
<RiCheckLine
|
||||
className={cn(
|
||||
"me-2 size-4",
|
||||
policies.includes(option.value) ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{policies.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{policies.map((value) => (
|
||||
<Badge key={value} variant="secondary">
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
+46
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiDownloadLine, RiErrorWarningLine } from "@remixicon/react"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
@@ -26,6 +27,7 @@ export function UserNotice({ open, onOpenChange, data, onClose, title }: UserNot
|
||||
const accessKey = data?.credentials?.accessKey ?? ""
|
||||
const secretKey = data?.credentials?.secretKey ?? ""
|
||||
const url = data?.url ?? ""
|
||||
const hasCredentials = Boolean(accessKey && secretKey)
|
||||
|
||||
const closeModal = React.useCallback(() => {
|
||||
onOpenChange(false)
|
||||
@@ -33,6 +35,7 @@ export function UserNotice({ open, onOpenChange, data, onClose, title }: UserNot
|
||||
}, [onOpenChange, onClose])
|
||||
|
||||
const exportFile = React.useCallback(() => {
|
||||
if (!accessKey || !secretKey) return
|
||||
download(
|
||||
"credentials.json",
|
||||
JSON.stringify({
|
||||
@@ -47,33 +50,63 @@ export function UserNotice({ open, onOpenChange, data, onClose, title }: UserNot
|
||||
}, [url, accessKey, secretKey, closeModal])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal} disablePointerDismissal>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={() => undefined} disablePointerDismissal>
|
||||
<DialogContent
|
||||
className="max-h-[min(90dvh,40rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="border-b px-4 py-3">
|
||||
<DialogTitle>{title ?? t("New user has been created")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="flex gap-3 border bg-muted/30 p-3" role="note">
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-foreground" aria-hidden />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">{t("Warning")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("The secret key is shown only once. Copy or export it before closing.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Access Key")}</FieldLabel>
|
||||
<FieldLabel htmlFor="created-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<CopyInput value={accessKey} readOnly copyIcon className="w-full" />
|
||||
<CopyInput
|
||||
id="created-access-key"
|
||||
value={accessKey}
|
||||
readOnly
|
||||
copyIcon
|
||||
copyLabel={`${t("Copy")} ${t("Access Key")}`}
|
||||
aria-label={t("Access Key")}
|
||||
className="w-full"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("Secret Key")}</FieldLabel>
|
||||
<FieldLabel htmlFor="created-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<CopyInput value={secretKey} readOnly copyIcon className="w-full" />
|
||||
<CopyInput
|
||||
id="created-secret-key"
|
||||
value={secretKey}
|
||||
readOnly
|
||||
copyIcon
|
||||
copyLabel={`${t("Copy")} ${t("Secret Key")}`}
|
||||
aria-label={t("Secret Key")}
|
||||
className="w-full"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={closeModal}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={exportFile}>
|
||||
{t("Export")}
|
||||
<Button type="button" variant="default" onClick={exportFile} disabled={!hasCredentials}>
|
||||
<RiDownloadLine className="size-4" aria-hidden />
|
||||
{t("Download")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
+106
-37
@@ -2,12 +2,13 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiArrowDownSLine, RiCheckLine } from "@remixicon/react"
|
||||
import { RiArrowDownSLine, RiCheckLine, RiRefreshLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandList } from "@/components/ui/command"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { MultiSelectCommandItem } from "@/components/user/multi-select-command-item"
|
||||
import { useUsers } from "@/hooks/use-users"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -29,68 +30,136 @@ export function UserSelector({
|
||||
autoLoad = true,
|
||||
}: UserSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { listUsers } = useUsers()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [users, setUsers] = React.useState<{ label: string; value: string }[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadError, setLoadError] = React.useState("")
|
||||
const [loadVersion, setLoadVersion] = React.useState(0)
|
||||
const triggerId = React.useId()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoLoad && open) {
|
||||
listUsers()
|
||||
.then((res: Record<string, unknown>) => {
|
||||
setUsers(
|
||||
Object.entries(res ?? {}).map(([username]) => ({
|
||||
label: username,
|
||||
value: username,
|
||||
})),
|
||||
)
|
||||
})
|
||||
.catch(() => message.error(t("Failed to get data")))
|
||||
if (disabled) setOpen(false)
|
||||
}, [disabled])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoLoad || !open) return
|
||||
let cancelled = false
|
||||
|
||||
setUsers([])
|
||||
setLoadError("")
|
||||
setLoading(true)
|
||||
listUsers()
|
||||
.then((res: Record<string, unknown> | null) => {
|
||||
if (cancelled) return
|
||||
setUsers(
|
||||
Object.keys(res ?? {})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((username) => ({ label: username, value: username })),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
setLoadError((error as Error)?.message || t("Failed to get data"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [autoLoad, open, listUsers, message, t])
|
||||
}, [autoLoad, open, listUsers, loadVersion, t])
|
||||
|
||||
const displayLabel = label ?? t("Users")
|
||||
const displayPlaceholder = placeholder ?? t("Select user group members")
|
||||
const selectionSummaryId = `${triggerId}-selection`
|
||||
const selectionSummary = value.length ? value.join(", ") : displayPlaceholder
|
||||
|
||||
const toggleUser = (v: string) => {
|
||||
if (disabled || loading) return
|
||||
onChange(value.includes(v) ? value.filter((item) => item !== v) : [...value, v])
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!disabled) setOpen(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel className="text-sm font-medium">{displayLabel}</FieldLabel>
|
||||
<FieldLabel htmlFor={triggerId} className="text-sm font-medium">
|
||||
{displayLabel}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
id={triggerId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10 w-full justify-between gap-2"
|
||||
className="min-h-10 w-full min-w-0 justify-between gap-2"
|
||||
disabled={disabled}
|
||||
aria-label={displayLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`${displayLabel}: ${selectionSummary}`}
|
||||
aria-describedby={selectionSummaryId}
|
||||
>
|
||||
<span className="truncate">{value.length ? value.join(", ") : displayPlaceholder}</span>
|
||||
<span id={selectionSummaryId} className="truncate">
|
||||
{selectionSummary}
|
||||
</span>
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-72 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("Search User")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users.map((option) => (
|
||||
<CommandItem key={option.value} value={option.label} onSelect={() => toggleUser(option.value)}>
|
||||
<RiCheckLine
|
||||
className={cn("me-2 size-4", value.includes(option.value) ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<PopoverContent
|
||||
className="w-(--anchor-width) max-w-(--available-width) p-0"
|
||||
align="start"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<Command shouldFilter={!loading && !loadError}>
|
||||
<CommandInput
|
||||
aria-label={t("Search User")}
|
||||
placeholder={t("Search User")}
|
||||
disabled={loading || Boolean(loadError)}
|
||||
/>
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-24 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-4" aria-hidden />
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex min-h-24 flex-col items-center justify-center gap-3 p-4 text-center" role="alert">
|
||||
<p className="max-w-full break-words text-sm text-destructive">{loadError}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setLoadVersion((v) => v + 1)}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CommandList role="listbox" aria-label={displayLabel} aria-multiselectable="true">
|
||||
<CommandEmpty>{t("No Data")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users.map((option) => {
|
||||
const selected = value.includes(option.value)
|
||||
return (
|
||||
<MultiSelectCommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
selected={selected}
|
||||
onSelect={() => toggleUser(option.value)}
|
||||
>
|
||||
<RiCheckLine className={cn("me-2 size-4", selected ? "opacity-100" : "opacity-0")} />
|
||||
<span className="min-w-0 break-all">{option.label}</span>
|
||||
</MultiSelectCommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { S3Client } from "@aws-sdk/client-s3"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { addApiPrefixMiddleware } from "@/lib/api-prefix-middleware"
|
||||
import { configManager } from "@/lib/config"
|
||||
import { getServiceErrorMessage, getXmlErrorMessage } from "@/lib/error-handler"
|
||||
import { getServiceErrorMessage, getXmlErrorCode, getXmlErrorMessage } from "@/lib/error-handler"
|
||||
import { scheduleMicrotask } from "@/lib/schedule-microtask"
|
||||
import type { SiteConfig } from "@/types/config"
|
||||
|
||||
@@ -37,13 +37,13 @@ const readResponseBodyText = async (
|
||||
return new TextDecoder("utf-8").decode(bytes)
|
||||
}
|
||||
|
||||
const createS3ServiceError = (message: string, statusCode: number) => {
|
||||
const createS3ServiceError = (message: string, statusCode: number, code?: string | null) => {
|
||||
const error = new Error(message) as Error & {
|
||||
Code?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
error.name = message
|
||||
error.Code = message
|
||||
error.name = code ?? "S3ServiceError"
|
||||
if (code) error.Code = code
|
||||
error.$metadata = { httpStatusCode: statusCode }
|
||||
return error
|
||||
}
|
||||
@@ -142,10 +142,11 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
|
||||
if (typeof statusCode === "number" && statusCode >= 300) {
|
||||
const streamCollector = client.config.streamCollector as StreamCollector | undefined
|
||||
const bodyText = await readResponseBodyText(response.body, streamCollector)
|
||||
const errorCode = bodyText ? getXmlErrorCode(bodyText) : null
|
||||
const errorMessage = bodyText ? (getXmlErrorMessage(bodyText) ?? bodyText.trim()) : null
|
||||
|
||||
if (errorMessage) {
|
||||
throw createS3ServiceError(errorMessage, statusCode)
|
||||
throw createS3ServiceError(errorMessage, statusCode, errorCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +203,8 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const serviceErrorMessage = getServiceErrorMessage(error)
|
||||
if (serviceErrorMessage) {
|
||||
throw new Error(serviceErrorMessage)
|
||||
if (serviceErrorMessage && error instanceof Error) {
|
||||
error.message = serviceErrorMessage
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Console UI/Interaction Audit Register
|
||||
|
||||
This is the durable source for the ongoing whole-project UI review. Add a row when a reviewer identifies a concrete issue; do not erase resolved rows. Every UI change must point to a before/after evidence key in the screenshot manifest.
|
||||
|
||||
## PR extraction checklist
|
||||
|
||||
Use the resolved rows for the PR description:
|
||||
|
||||
1. State the user-facing problem and its affected flow.
|
||||
2. State the safety or accessibility invariant that now holds.
|
||||
3. Attach the matching before/after comparison images from the manifest. Do not put local filesystem paths in the PR description.
|
||||
4. List focused tests and the full quality gates that passed.
|
||||
5. Keep any unresolved row visible as follow-up scope; do not imply it was fixed.
|
||||
|
||||
## Audit record
|
||||
|
||||
| ID | Surface | Finding | Impact | Resolution | Evidence key | Status |
|
||||
| ------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------- |
|
||||
| UI-001 | Running status | Loading, partial, and failed server data were visually conflated. | Operators could act on stale or incomplete health information. | Separated status states and added explicit feedback/retry treatment. | `running-status-10` | Resolved |
|
||||
| UI-002 | Pool operations | Long-running rebalance/decommission flows did not consistently expose state, errors, and mobile-safe actions. | Destructive operations were difficult to verify and recover from. | Added state-aware action handling, error surfaces, and responsive layouts. | `pool-operations-9` | Resolved |
|
||||
| UI-003 | Bucket settings | Configuration tabs had inconsistent hierarchy and several flows could reset state, mask errors, or apply unsafe replication semantics. | Misconfiguration risk and poor recovery in lifecycle, events, replication, and encryption flows. | Normalized settings layout; preserved form state; surfaced errors; corrected replication role semantics. | `bucket-settings-11` | Resolved |
|
||||
| UI-004 | SSE status | A failed status request could be displayed as “not configured”, while stale backend/metrics remained visible; long default key IDs could overflow mobile cards. | Operators could configure or mutate KMS from an untrusted state, and mobile users could lose key context. | Keep the failure distinct from unconfigured; hide all stale details; block stateful actions while status syncs; wrap identifiers. | `sse-16-overview` | Resolved |
|
||||
| UI-005 | SSE configuration | Long configuration form had weak grouping, incomplete required semantics, and no field-targeted client-side error recovery. | High cognitive load and inaccessible error correction. | Use fieldsets/legends, client validation with focus, and native/ARIA required state. | `sse-14-progressive` | Resolved |
|
||||
| UI-006 | SSE mutations | KMS create/reconfigure/key actions could overlap or proceed from stale status; uncertain responses left the UI unverified. | Conflicting writes and incorrect default-key deletion decisions. | Serialize mutations, keep create locked while key inventory is loading or failed, reconcile uncertain outcomes from the server, and recheck default-key status before delete. | `sse-16-recaptured-states` | Resolved |
|
||||
| UI-007 | SSE key actions | Mobile cards dropped schedule/cancel deletion actions; immediate deletion was over-prominent. | Mobile users did not have desktop-equivalent, safe key management. | Provide action parity and present staged deletion before immediate deletion. | `sse-18-key-card-flow` | Resolved |
|
||||
| UI-008 | SSE confirmation | Key dialogs did not name the target/default-key risk; service restart/stop bypassed confirmation. | Operators could apply destructive/disruptive actions without enough context. | Target-aware destructive confirmation, service-state confirmation, and dismissal lock while processing. | `sse-17-service-stop` | Resolved |
|
||||
| UI-009 | SSE secrets | Diagnostic request logging could expose credentials. | Vault token leakage through client diagnostics. | Redact secret-bearing request fields before logging. | `sse-13-security` | Resolved |
|
||||
| UI-010 | SSE Local backend | Local KMS was offered without the backend contract needed to preserve/validate an opaque master key; a reconfiguration could make existing keys unreadable. | Production Local KMS configuration and default-key updates could weaken secret handling or make data unrecoverable. | Make Local configuration read-only in Console until the backend offers preservation plus verified rotation; keep Local default-key changes server-managed. | `sse-16-local-safety` | Resolved |
|
||||
| UI-011 | SSE unsaved form | Sidebar navigation, browser history, and page exit could silently discard a long KMS configuration form. | Operators could lose backend settings or newly-entered credentials. | Keep a server baseline, guard unload/link/history navigation, and prohibit discarding while a mutation is in progress. | `sse-16-dirty-form` | Resolved |
|
||||
| UI-012 | IAM user/group selectors | Multi-select controls did not expose their selected state, search purpose, or listbox semantics; edit mode could also leave keyboard focus behind. | Keyboard and assistive-technology users could not reliably understand or resume the current edit flow. | Added named search fields, put multiselect semantics on the actual CommandList, synchronized selected state around cmdk's active-option attribute, and restored focus only on real edit transitions. | `iam-13-a11y` | Resolved |
|
||||
| UI-013 | SSE configuration | Reliability and cache controls made the mobile KMS form unnecessarily tall and pushed the primary actions below the fold. | First-time operators had to scan a dense expert-only section before reaching the save/reset actions. | Grouped retry/cache controls under a native Advanced Settings disclosure; validation state reopens the section when an affected field needs attention. | `sse-14-progressive` | Resolved |
|
||||
| UI-014 | IAM group tables | Members and policy tables had no explicit accessible name in the reusable table primitive. | Screen-reader users could lose context when several tables or dialogs were present. | Added an optional visually-hidden table caption and supplied localized captions for the Members and Policies tables without changing the visual layout. | `iam-14-captions` | Resolved |
|
||||
| UI-015 | OIDC configuration | Claim and role mapping controls occupied the same long mobile flow as the connection credentials and redirect settings. | Operators had to scan a dense expert-only section before reaching the next page section. | Kept provider identity, credentials, scopes, and redirect controls visible; moved claim/role mapping into a native Advanced Settings disclosure. | `oidc-15-progressive` | Resolved |
|
||||
| UI-016 | Module switch settings | A failed module-switch read left an empty bordered panel after the toast disappeared, with no local retry and no visible indication that values were unavailable. | Operators could mistake an unavailable settings read for an empty configuration and could not recover in context. | Added a persistent destructive alert with the error detail and Sync retry; retained stale values as non-editable while a refresh is unresolved. | `settings-20-error` | Resolved |
|
||||
| UI-017 | Access-key dialogs | Long credential forms could push actions out of the viewport and did not consistently explain expiry or field errors. | Operators could lose the only chance to copy credentials or submit a corrected form. | Fixed the dialog header/body/footer layout, kept actions reachable, and added expiry guidance plus field-specific validation. | `remaining-21` | Resolved |
|
||||
| UI-018 | User/group workflows | Assignment and edit dialogs could grow with badges, conflate loading/error/empty states, and retry already-succeeded requests. | Bulk IAM changes were hard to recover and could leave users unsure which groups were updated. | Bounded dialog bodies, added explicit state/retry treatment, compact selection summaries, and retry only failed group operations. | `remaining-21` | Resolved |
|
||||
| UI-019 | Shared tables/navigation | Pagination and table semantics were difficult to use on narrow screens and lacked context for assistive technology. | Keyboard and mobile users could lose table context or fail to reach page actions. | Added captions/sort semantics, mobile pagination layout, and a single predictable overflow surface. | `remaining-21` | Resolved |
|
||||
| UI-020 | Utility surfaces | Long filenames, object rows, and license text could overflow or become unreadable on narrow screens. | Import/export and inspection tasks required horizontal scrolling or clipped content. | Added bounded readable surfaces, safe wrapping, and mobile-first action alignment. | `remaining-21` | Resolved |
|
||||
| UI-021 | Authentication surfaces | Login/configuration hero layouts and actions did not consistently use dynamic viewport sizing or touch-safe targets. | Mobile sign-in could be clipped or require imprecise taps. | Switched to dynamic viewport-safe layout and full-width 44px mobile actions. | `remaining-21` | Resolved |
|
||||
| UI-022 | Rule/task dialogs | Event, audit, lifecycle, replication, tier, and site-replication forms could hide their footer actions behind long content. | Operators could not reliably save or cancel long configuration flows. | Standardized one scrolling body with fixed header/footer and disabled dismissal while submitting. | `remaining-21` | Resolved |
|
||||
| UI-023 | Dialog/form hierarchy | Full rectangular borders were reused for outer surfaces, semantic groups, descriptions, metadata, and progress states. | Repeated nested frames weakened hierarchy, increased cognitive load, and made passive content look interactive. | Initial remediation mechanically replaced boxes with recursive separators, while its fixture misrepresented the real hierarchy. Rework and valid recapture are pending. | `border-depth-22` invalid | Open |
|
||||
| UI-024 | Section divider hierarchy | SSE configuration repeats dividers across sibling fieldsets, Advanced Settings, and nested cache content; key details and site-replication content also stack dividers across semantic levels. | Repeated lines imply false parent-child relationships and make content appear detached from its heading. | Use one divider system only between peer sections; use headings and spacing inside a section, and remove duplicate dividers after already-divided metadata. | Pending live capture | Open |
|
||||
| UI-025 | Passive metadata surfaces | SSE status metrics, selected-pool details, and site-replication peer metadata are rendered as complete framed panels inside an existing Card or dialog surface. | Passive values look interactive and create card-inside-card depth. | Replace nested panels with definition lists, open grids, muted backgrounds, or a single section separator while retaining warning and selection affordances. | Pending live capture | Open |
|
||||
| UI-026 | Compound editor rows | Lifecycle/replication tag editors, access-key policy switches, and IAM edit panels add complete frames around already-bordered controls inside dialogs. | Repeated outlines increase density and make ordinary fields look like independent subcards. | Keep input/control outlines, but express the containing editor row with spacing, a muted background, or one sibling divider unless it is itself a selectable object. | Pending live capture | Open |
|
||||
| UI-027 | Login form | The desktop form pane used 7/12 of the split panel and allowed a short credential form to expand to `max-w-md`. | Credential fields looked visually loose and the form outweighed the supporting hero. | Balanced the desktop split at 1/2 and constrained the credential form to `max-w-sm` without changing mobile behavior. | `layout-followup-27` | Resolved |
|
||||
| UI-028 | Edit Policy dialog | The complete form body owned vertical scrolling and the policy editor had a 24rem minimum height with manual resizing. | Name/context and editor moved together, and the editor could force an unnecessarily tall dialog. | Kept the form body fixed, made the Policy field consume remaining space, and confined scrolling to a non-resizable textarea with a 12rem minimum. | `layout-followup-27` | Resolved |
|
||||
| UI-029 | Running status controls | Mobile `min-h-11` rules were also applied at desktop widths to Refresh, server filters, and the Sort by trigger. | Compact status toolbars appeared stretched and misaligned with surrounding desktop controls. | Preserved 44px controls on small screens and restored the shared compact control height from `sm` upward. | `layout-followup-27` | Resolved |
|
||||
| UI-030 | Bucket rule detail pages | Events, Replication, and Lifecycle rendered Add/Refresh in a second toolbar below the page header, separate from Back navigation. | Related page-level actions were split across two rows and displaced the table downward. | Added a tab header render seam and composed Back, Add, and Refresh together in the page header for all three scoped bucket pages. | `layout-followup-27` | Resolved |
|
||||
| UI-031 | Top navigation account | The account artwork and its button trigger were both 32px high, so the circular avatar consumed the trigger's complete vertical space. | The avatar visually touched the trigger boundary and crowded adjacent top-navigation controls. | Reduced the artwork to 24px while retaining the 32px button target, leaving a visible 4px inset around the avatar. | `avatar-inset-28` | Resolved |
|
||||
|
||||
### Reconciled border rule
|
||||
|
||||
Keep at most two structural frame levels. A region may use one complete outline; nested groups should use spacing, background, headings, or a single separator unless the nested element is itself an input, warning/error, or explicit interactive choice.
|
||||
|
||||
Dividers only express relationships between peer sections. Use one aligned divider system per surface; do not recursively add dividers to an ancestor and its descendants.
|
||||
|
||||
### Border hierarchy sweep coverage
|
||||
|
||||
The 2026-07-11 follow-up reviewed 44 candidate files containing cards, dialogs, fieldsets, details, or form borders. Dialog header/footer dividers, alerts, selectable option tiles, mobile entity cards, table/list boundaries, upload zones, and progress strips were retained where the border communicates a real surface, state, or affordance. UI-024 through UI-026 capture the remaining cross-page patterns.
|
||||
|
||||
| Finding | Confirmed source locations |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| UI-024 | `app/(dashboard)/sse/page.tsx:1087`, `app/(dashboard)/sse/page.tsx:1340`, `app/(dashboard)/sse/page.tsx:1388`, `app/(dashboard)/sse/page.tsx:1887`, `components/site-replication/new-form.tsx:258` |
|
||||
| UI-025 | `app/(dashboard)/sse/page.tsx:943`, `app/(dashboard)/sse/page.tsx:961`, `app/(dashboard)/pool-decommission/page.tsx:625`, `app/(dashboard)/pool-decommission/page.tsx:647`, `components/site-replication/edit-form.tsx:180` |
|
||||
| UI-026 | `components/lifecycle/new-form.tsx:575`, `components/replication/new-form.tsx:517`, `components/access-keys/new-item.tsx:312`, `components/user/edit/access-keys.tsx:352`, `components/user-group/edit-form.tsx:143`, `components/user-group/members.tsx:168`, `components/user-group/policies.tsx:207` |
|
||||
|
||||
## Deferred design debt
|
||||
|
||||
| ID | Date | Source | Severity | What | Affected users | Suggested fix | Status | Notes |
|
||||
| ------ | ---------- | ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DD-001 | 2026-07-10 | SSE visual review | Minor | SSE form remains a dense expert workflow after grouping. | Operators configuring Vault/KMS for the first time. | Evaluate progressive disclosure for advanced retry/cache fields after usage feedback. | Resolved | Reliability/cache fields are now progressively disclosed; backend and default-key fields remain visible. |
|
||||
| DD-002 | 2026-07-10 | SSE accessibility review | Minor | Key details and configuration error recovery need a final keyboard/screen-reader session against a live KMS backend. | Keyboard and assistive-technology users. | Add e2e coverage once a stable KMS fixture is available. | Resolved | Live fixture DOM snapshot verified labelled KMS fields, table caption, and alertdialog name/description/buttons; source tests remain in `tests/lib/sse-safety.test.js`. |
|
||||
| DD-003 | 2026-07-10 | IAM accessibility review | Minor | Members and policy tables still need explicit captions/summary text for screen-reader context. | Keyboard and assistive-technology users. | Add captions or labelled table containers without changing the visual layout. | Resolved | Added localized sr-only captions to both tables; visual-neutral before/after captures are retained. |
|
||||
|
||||
## Evidence rules
|
||||
|
||||
- `before` and `after` must be captured from the actual current component tree at the same viewport and state.
|
||||
- A static fixture is allowed only to illustrate a state that cannot safely be produced locally; label it as a fixture and do not present it as runtime proof.
|
||||
- Comparison images are append-only. If implementation changes, create a new evidence key; do not overwrite prior proof.
|
||||
- `sse-14-progressive` and `iam-14-captions` use temporary static fixtures for deterministic screenshots; treat the source/tests as the implementation proof until live backend captures replace them.
|
||||
- `oidc-15-progressive` uses a temporary static fixture for deterministic screenshots; treat the source/tests as the implementation proof until a live OIDC backend capture replaces it.
|
||||
- `settings-20-error` and `remaining-21` use temporary static fixtures for deterministic before/after comparisons; the source/tests remain authoritative until live component captures replace them.
|
||||
- `border-depth-22` is invalid because the fixture invented a nested SSE hierarchy that does not match the component tree. Do not cite it as improvement evidence.
|
||||
- `layout-followup-27` is a source-faithful static fixture for the four changed layout relationships; source tests and component code remain the implementation proof until authenticated live captures replace it.
|
||||
- `avatar-inset-28` is a source-faithful static fixture for the exact 32px trigger and 32px-to-24px artwork change; source tests and component code remain authoritative.
|
||||
- The full on-disk mapping is maintained in the screenshot manifest next to the image archive.
|
||||
|
||||
## Latest verification
|
||||
|
||||
| Date | Checks | Result |
|
||||
| ---------- | --------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 2026-07-11 | `pnpm type-check` | Passed |
|
||||
| 2026-07-11 | `pnpm lint` | Passed |
|
||||
| 2026-07-11 | `pnpm format:check` | Passed |
|
||||
| 2026-07-11 | `node --test tests/lib/*.test.js` | 187 passed |
|
||||
| 2026-07-11 | `pnpm build` | Passed; Next.js emitted only the existing multiple-lockfile root warning |
|
||||
| 2026-07-11 | `git diff --check` | Passed |
|
||||
| 2026-07-11 | Screenshot archive integrity | 61 comparison images readable; temporary fixture routes removed |
|
||||
| 2026-07-11 | `border-depth-22` evidence | Files readable, but evidence invalid; temporary fixture routes removed |
|
||||
| 2026-07-11 | Border hierarchy source sweep | 44 candidate files reviewed; 3 unresolved cross-page patterns recorded |
|
||||
| 2026-07-11 | `layout-followup-27` evidence | Before, after, and comparison PNGs readable; temporary fixture removed |
|
||||
| 2026-07-11 | `avatar-inset-28` evidence | Before, after, and comparison PNGs readable; temporary fixture removed |
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
DeleteBucketReplicationCommand,
|
||||
PutBucketNotificationConfigurationCommand,
|
||||
GetBucketNotificationConfigurationCommand,
|
||||
MFADelete,
|
||||
BucketVersioningStatus,
|
||||
type S3Client,
|
||||
} from "@aws-sdk/client-s3"
|
||||
@@ -164,7 +163,6 @@ export function useBucket() {
|
||||
Bucket: bucket,
|
||||
VersioningConfiguration: {
|
||||
Status: status === "Enabled" ? BucketVersioningStatus.Enabled : BucketVersioningStatus.Suspended,
|
||||
MFADelete: MFADelete.Enabled,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
+155
-32
@@ -2,9 +2,10 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSystem } from "@/hooks/use-system"
|
||||
import {
|
||||
normalizeDataUsageInfo,
|
||||
normalizeMetricsInfo,
|
||||
normalizeStorageInfo,
|
||||
normalizeSystemInfo,
|
||||
type DataUsageInfo,
|
||||
@@ -15,67 +16,189 @@ import {
|
||||
|
||||
export type { DataUsageInfo, MetricsInfo, ServerInfo, StorageInfo, SystemInfo } from "@/lib/performance-data"
|
||||
|
||||
export type PerformanceDataSource = "system" | "usage" | "storage" | "metrics"
|
||||
export type PerformanceSourceErrors = Partial<Record<PerformanceDataSource, string>>
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
function hasSystemSnapshot(value: SystemInfo) {
|
||||
return Boolean(value.buckets || value.objects || value.servers || value.backend)
|
||||
}
|
||||
|
||||
function hasUsageSnapshot(value: DataUsageInfo) {
|
||||
return value.total_capacity !== undefined || value.total_used_capacity !== undefined
|
||||
}
|
||||
|
||||
function hasStorageSnapshot(value: StorageInfo) {
|
||||
return Boolean(value.backend && Object.values(value.backend).some((item) => item !== undefined))
|
||||
}
|
||||
|
||||
function hasMetricsSnapshot(value: MetricsInfo) {
|
||||
return value.aggregated?.scanner !== undefined
|
||||
}
|
||||
|
||||
export function usePerformanceData() {
|
||||
const { t } = useTranslation()
|
||||
const systemApi = useSystem()
|
||||
const { getSystemInfo, getDataUsageInfo, getStorageInfo, getSystemMetrics } = useSystem()
|
||||
|
||||
const [metricsInfo, setMetricsInfo] = useState<MetricsInfo>({})
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo>({})
|
||||
const [datausageinfo, setDatausageinfo] = useState<DataUsageInfo>({})
|
||||
const [storageinfo, setStorageinfo] = useState<StorageInfo>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const mountedRef = React.useRef(true)
|
||||
const [sourceErrors, setSourceErrors] = useState<PerformanceSourceErrors>({})
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null)
|
||||
const [metricsUpdatedAt, setMetricsUpdatedAt] = useState<Date | null>(null)
|
||||
const mountedRef = React.useRef(false)
|
||||
const refetchingRef = React.useRef(false)
|
||||
const requestVersionRef = React.useRef(0)
|
||||
const hasSystemDataRef = React.useRef(false)
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null)
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
if (!mountedRef.current || refetchingRef.current) return
|
||||
const requestVersion = ++requestVersionRef.current
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), 15_000)
|
||||
abortControllerRef.current = controller
|
||||
refetchingRef.current = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const [sysRes, usageRes, storageRes] = await Promise.all([
|
||||
systemApi.getSystemInfo(),
|
||||
systemApi.getDataUsageInfo(),
|
||||
systemApi.getStorageInfo(),
|
||||
const [systemResult, usageResult, storageResult, metricsResult] = await Promise.allSettled([
|
||||
getSystemInfo(controller.signal),
|
||||
getDataUsageInfo(controller.signal),
|
||||
getStorageInfo(controller.signal),
|
||||
getSystemMetrics(controller.signal),
|
||||
])
|
||||
if (!mountedRef.current) return
|
||||
setSystemInfo(normalizeSystemInfo(sysRes))
|
||||
setDatausageinfo((usageRes as DataUsageInfo) ?? {})
|
||||
setStorageinfo(normalizeStorageInfo(storageRes))
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
console.error("Failed to load performance data:", err)
|
||||
setError((err as Error)?.message ?? t("Get Data Failed"))
|
||||
if (!mountedRef.current || requestVersion !== requestVersionRef.current) return
|
||||
|
||||
const nextSourceErrors: PerformanceSourceErrors = {}
|
||||
let systemRefreshed = false
|
||||
let optionalSourceRefreshed = false
|
||||
|
||||
if (systemResult.status === "fulfilled") {
|
||||
const normalized = normalizeSystemInfo(systemResult.value)
|
||||
if (hasSystemSnapshot(normalized)) {
|
||||
setSystemInfo(normalized)
|
||||
hasSystemDataRef.current = true
|
||||
systemRefreshed = true
|
||||
} else {
|
||||
nextSourceErrors.system = "Get Data Failed"
|
||||
}
|
||||
} else {
|
||||
nextSourceErrors.system = getErrorMessage(systemResult.reason, "Get Data Failed")
|
||||
}
|
||||
|
||||
if (usageResult.status === "fulfilled" && usageResult.value !== undefined) {
|
||||
const normalized = normalizeDataUsageInfo(usageResult.value)
|
||||
if (hasUsageSnapshot(normalized)) {
|
||||
setDatausageinfo(normalized)
|
||||
optionalSourceRefreshed = true
|
||||
} else {
|
||||
nextSourceErrors.usage = "Get Data Failed"
|
||||
}
|
||||
} else {
|
||||
nextSourceErrors.usage =
|
||||
usageResult.status === "rejected"
|
||||
? getErrorMessage(usageResult.reason, "Get Data Failed")
|
||||
: "Storage usage is unavailable for this account."
|
||||
}
|
||||
|
||||
if (storageResult.status === "fulfilled") {
|
||||
const normalized = normalizeStorageInfo(storageResult.value)
|
||||
if (hasStorageSnapshot(normalized)) {
|
||||
setStorageinfo(normalized)
|
||||
optionalSourceRefreshed = true
|
||||
} else {
|
||||
nextSourceErrors.storage = "Get Data Failed"
|
||||
}
|
||||
} else {
|
||||
nextSourceErrors.storage = getErrorMessage(storageResult.reason, "Get Data Failed")
|
||||
}
|
||||
|
||||
if (metricsResult.status === "fulfilled") {
|
||||
const normalized = normalizeMetricsInfo(metricsResult.value)
|
||||
if (hasMetricsSnapshot(normalized)) {
|
||||
setMetricsInfo(normalized)
|
||||
setMetricsUpdatedAt(new Date())
|
||||
optionalSourceRefreshed = true
|
||||
} else {
|
||||
nextSourceErrors.metrics = "Get Data Failed"
|
||||
}
|
||||
} else {
|
||||
nextSourceErrors.metrics = getErrorMessage(metricsResult.reason, "Get Data Failed")
|
||||
}
|
||||
|
||||
setSourceErrors(nextSourceErrors)
|
||||
setError(
|
||||
!hasSystemDataRef.current && !systemRefreshed && !optionalSourceRefreshed
|
||||
? "Unable to load cluster status. Check your connection and refresh."
|
||||
: null,
|
||||
)
|
||||
if (systemRefreshed) setLastUpdatedAt(new Date())
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false)
|
||||
window.clearTimeout(timeout)
|
||||
if (abortControllerRef.current === controller) abortControllerRef.current = null
|
||||
if (requestVersion === requestVersionRef.current) {
|
||||
refetchingRef.current = false
|
||||
if (mountedRef.current) {
|
||||
setHasLoaded(true)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const metricsRes = await systemApi.getSystemMetrics()
|
||||
if (mountedRef.current) setMetricsInfo((metricsRes as MetricsInfo) ?? {})
|
||||
} catch {
|
||||
if (mountedRef.current) setMetricsInfo({})
|
||||
} finally {
|
||||
refetchingRef.current = false
|
||||
}
|
||||
}, [systemApi, t])
|
||||
}, [getDataUsageInfo, getStorageInfo, getSystemInfo, getSystemMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
hasSystemDataRef.current = false
|
||||
setSystemInfo({})
|
||||
setMetricsInfo({})
|
||||
setDatausageinfo({})
|
||||
setStorageinfo({})
|
||||
setHasLoaded(false)
|
||||
setRefreshing(true)
|
||||
setError(null)
|
||||
setSourceErrors({})
|
||||
setLastUpdatedAt(null)
|
||||
setMetricsUpdatedAt(null)
|
||||
void refetch()
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void refetch()
|
||||
}, 60_000)
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void refetch()
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
requestVersionRef.current += 1
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
refetchingRef.current = false
|
||||
window.clearInterval(interval)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetch once on mount; systemApi is stable
|
||||
}, [])
|
||||
}, [refetch])
|
||||
|
||||
return {
|
||||
systemInfo,
|
||||
metricsInfo,
|
||||
datausageinfo,
|
||||
storageinfo,
|
||||
loading,
|
||||
loading: refreshing && !hasLoaded,
|
||||
refreshing,
|
||||
hasLoaded,
|
||||
error,
|
||||
sourceErrors,
|
||||
lastUpdatedAt,
|
||||
metricsUpdatedAt,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function usePoolOperations() {
|
||||
)
|
||||
|
||||
const getRebalanceViewModel = useCallback(async (): Promise<RebalanceViewModel> => {
|
||||
const [overview, status] = await Promise.all([getPoolsOverview(), getRebalanceStatus().catch(() => null)])
|
||||
const [overview, status] = await Promise.all([getPoolsOverview(), getRebalanceStatus()])
|
||||
return {
|
||||
overview,
|
||||
status,
|
||||
@@ -105,12 +105,9 @@ export function usePoolOperations() {
|
||||
|
||||
const getDecommissionViewModel = useCallback(
|
||||
async (poolId?: string, isConfirming = false): Promise<DecommissionViewModel> => {
|
||||
const [overview, rebalanceStatus] = await Promise.all([
|
||||
getPoolsOverview(),
|
||||
getRebalanceStatus().catch(() => null),
|
||||
])
|
||||
const [overview, rebalanceStatus] = await Promise.all([getPoolsOverview(), getRebalanceStatus()])
|
||||
const rebalanceState = deriveRebalanceDisplayState(rebalanceStatus, overview.supportState)
|
||||
const decommissionStatus = poolId ? await getDecommissionStatus(poolId).catch(() => null) : null
|
||||
const decommissionStatus = poolId ? await getDecommissionStatus(poolId) : null
|
||||
|
||||
return {
|
||||
overview,
|
||||
|
||||
+44
-26
@@ -6,41 +6,59 @@ import { useApi } from "@/contexts/api-context"
|
||||
export function useSystem() {
|
||||
const api = useApi()
|
||||
|
||||
const getSystemInfo = useCallback(async () => {
|
||||
return api.get("/info")
|
||||
}, [api])
|
||||
const getSystemInfo = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
return api.get("/info", signal ? { signal, dedupe: false } : undefined)
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const getStorageInfo = useCallback(async () => {
|
||||
return api.get("/storageinfo")
|
||||
}, [api])
|
||||
const getStorageInfo = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
return api.get("/storageinfo", {
|
||||
suppress403Redirect: true,
|
||||
signal,
|
||||
dedupe: signal ? false : undefined,
|
||||
})
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const getDataUsageInfo = useCallback(async () => {
|
||||
try {
|
||||
return await api.get("/datausageinfo", { suppress403Redirect: true })
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ?? (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 403) {
|
||||
// Preserve previous behavior: treat 403 as "no data" instead of rejecting.
|
||||
return undefined
|
||||
const getDataUsageInfo = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
try {
|
||||
return await api.get("/datausageinfo", {
|
||||
suppress403Redirect: true,
|
||||
signal,
|
||||
dedupe: signal ? false : undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ?? (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 403) {
|
||||
// Preserve previous behavior: treat 403 as "no data" instead of rejecting.
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}, [api])
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const getSystemMetrics = useCallback(async () => {
|
||||
try {
|
||||
return await api.get("/metrics", {
|
||||
const getSystemMetrics = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
return api.get("/metrics", {
|
||||
params: {
|
||||
n: "1",
|
||||
types: "1",
|
||||
},
|
||||
signal,
|
||||
dedupe: signal ? false : undefined,
|
||||
suppress403Redirect: true,
|
||||
})
|
||||
} catch {
|
||||
// Keep page rendering resilient when metrics endpoint is temporarily unavailable.
|
||||
}
|
||||
return {}
|
||||
}, [api])
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const getLicense = useCallback(async () => {
|
||||
return api.get("/license")
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "الموقع الحالي",
|
||||
"Current User Policy": "سياسة المستخدم الحالية",
|
||||
"Current Version": "الإصدار الحالي",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "حالة الخلفية الحالية للحاويات والمستخدمين والمجموعات والسياسات وقواعد انتهاء دورة الحياة المنسوخة.",
|
||||
"Current site + remote peers": "الموقع الحالي + النظراء البعيدون",
|
||||
"Current user policy": "سياسة المستخدم الحالية",
|
||||
@@ -926,10 +925,6 @@
|
||||
"Restart KMS": "إعادة تشغيل KMS",
|
||||
"Restart Required": "إعادة التشغيل مطلوبة",
|
||||
"Restore Key": "استعادة المفتاح",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "استئناف",
|
||||
"Resync cancel request sent successfully": "تم إرسال طلب إلغاء إعادة المزامنة بنجاح",
|
||||
"Resync request started successfully": "تم بدء طلب إعادة المزامنة بنجاح",
|
||||
@@ -1131,6 +1126,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "يكتشف الخادم الموقع الحالي تلقائيًا. أضف موقعًا بعيدًا واحدًا أو أكثر للانضمام إلى مجموعة النسخ.",
|
||||
"The directory must be an absolute path on the server.": "يجب أن يكون الدليل مسارًا مطلقًا على الخادم.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "يحتوي الملف المُصدَّر على معلومات حساسة. يرجى الحفاظ عليه بأمان.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "يتم عرض المفتاح السري مرة واحدة فقط. انسخه أو صدّره قبل الإغلاق.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "يكتشف الخادم هوية الموقع المحلي من نشر RustFS النشط.",
|
||||
"The two passwords are inconsistent": "كلمتا المرور غير متطابقتين",
|
||||
"The value is used only for the initial join request.": "تُستخدم هذه القيمة فقط في طلب الانضمام الأولي.",
|
||||
@@ -1138,7 +1134,6 @@
|
||||
"This action cannot be undone.": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "يقوم هذا الإجراء بإخراج التجمع المحدد من الخدمة، ويجب استخدامه فقط بعد التأكد من اكتمال إعادة التوازن.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "يُستخدم هذا المفتاح كمفتاح SSE الافتراضي للمنصة لكل من SSE-KMS وSSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "يُستخدم هذا المفتاح كافتراضي للمنصة لـ SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "ستنتهي صلاحية هذا الرابط عند انتهاء جلستك أو في الوقت المحدد.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "يحذف المفتاح نهائيًا فورًا ولا يمكن التراجع.",
|
||||
@@ -1182,7 +1177,6 @@
|
||||
"Understanding Key Types": "فهم أنواع المفاتيح",
|
||||
"Unhealthy": "غير سليم",
|
||||
"Unknown": "غير معروف",
|
||||
"Degraded": "متدهور",
|
||||
"Unknown Folder": "مجلد غير معروف",
|
||||
"Unlimited": "غير محدود",
|
||||
"Unnamed Site": "موقع بدون اسم",
|
||||
@@ -1354,6 +1348,139 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "الكل",
|
||||
"All reported servers and disks are online.": "جميع الخوادم والأقراص المبلّغ عنها متصلة.",
|
||||
"Check your connection, then refresh the status page.": "تحقق من اتصالك، ثم حدّث صفحة الحالة.",
|
||||
"Cluster Health": "صحة المجموعة",
|
||||
"Cluster information": "معلومات المجموعة",
|
||||
"Current Scan Started": "بدء الفحص الحالي",
|
||||
"Degraded": "متدهور",
|
||||
"Endpoint, ascending": "نقطة النهاية، تصاعديًا",
|
||||
"Endpoint, descending": "نقطة النهاية، تنازليًا",
|
||||
"Filter servers": "تصفية الخوادم",
|
||||
"Initializing": "جارٍ التهيئة",
|
||||
"Inventory": "الجرد",
|
||||
"Last Completed Scan": "آخر فحص مكتمل",
|
||||
"Last refreshed": "آخر تحديث",
|
||||
"Needs attention": "يتطلب الانتباه",
|
||||
"Needs attention first": "المحتاجة للانتباه أولًا",
|
||||
"No drive information was reported for this server.": "لم يتم الإبلاغ عن معلومات أقراص لهذا الخادم.",
|
||||
"No server information was reported.": "لم يتم الإبلاغ عن معلومات خوادم.",
|
||||
"No servers match the current filter": "لا توجد خوادم تطابق عامل التصفية الحالي",
|
||||
"Offline or degraded resources require attention.": "تتطلب الموارد غير المتصلة أو المتدهورة الانتباه.",
|
||||
"Parity and backend settings reported by the cluster.": "إعدادات التكافؤ والواجهة الخلفية المبلّغ عنها من المجموعة.",
|
||||
"Refresh to try again.": "حدّث الصفحة للمحاولة مجددًا.",
|
||||
"Refreshing…": "جارٍ التحديث…",
|
||||
"Review capacity pressure and scanner activity in one place.": "راجع ضغط السعة ونشاط الماسح في مكان واحد.",
|
||||
"Scan Duration": "مدة الفحص",
|
||||
"Scanner metrics": "مقاييس الماسح",
|
||||
"Show all servers": "عرض جميع الخوادم",
|
||||
"Some resource health data is unavailable or still initializing.": "بعض بيانات صحة الموارد غير متاحة أو لا تزال قيد التهيئة.",
|
||||
"Some status data is unavailable.": "بعض بيانات الحالة غير متاحة.",
|
||||
"Storage Configuration": "إعداد التخزين",
|
||||
"Storage Usage and Scanner": "استخدام التخزين والماسح",
|
||||
"Storage usage is unavailable for this account.": "استخدام التخزين غير متاح لهذا الحساب.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "تعذر تحميل حالة المجموعة. تحقق من اتصالك وحدّث الصفحة.",
|
||||
"Unavailable": "غير متاح",
|
||||
"Uptime, longest first": "وقت التشغيل، الأطول أولًا",
|
||||
"Uptime, shortest first": "وقت التشغيل، الأقصر أولًا",
|
||||
"Previously loaded values may be stale.": "قد تكون القيم المحمّلة سابقًا قديمة.",
|
||||
"Scanner Status": "حالة الماسح",
|
||||
"Scanning": "جارٍ الفحص",
|
||||
"Never run": "لم يعمل من قبل",
|
||||
"Access & Sharing": "الوصول والمشاركة",
|
||||
"Add key-value metadata for organization and automation.": "أضف بيانات وصفية بنمط مفتاح-قيمة للتنظيم والأتمتة.",
|
||||
"Automation": "الأتمتة",
|
||||
"Bucket Settings": "إعدادات الحاوية",
|
||||
"Capacity & Metadata": "السعة والبيانات الوصفية",
|
||||
"Configuration changed. Refresh and try again.": "تغيّر الإعداد. حدّث الصفحة وحاول مجددًا.",
|
||||
"Configuration unavailable": "الإعداد غير متاح",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "اضبط إرسال الأحداث وقواعد دورة حياة الكائنات والنسخ المتماثل.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "تحكّم في من يمكنه الوصول إلى هذه الحاوية وأصول المتصفح المسموح بها.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "يتحكم في الوصول المجهول والموثّق إلى الكائنات في هذه الحاوية.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "يتحكم في أصول الويب التي يمكنها إرسال طلبات عبر الأصول إلى هذه الحاوية.",
|
||||
"Copy objects to another bucket or site.": "انسخ الكائنات إلى حاوية أو موقع آخر.",
|
||||
"Data Protection": "حماية البيانات",
|
||||
"Delete Event Subscription": "حذف اشتراك الحدث",
|
||||
"Delete Rule": "حذف القاعدة",
|
||||
"Encrypts new objects at rest by default.": "يشفّر الكائنات الجديدة وهي مخزّنة افتراضيًا.",
|
||||
"Enter a valid positive quota.": "أدخل حصة موجبة صالحة.",
|
||||
"Event subscriptions unavailable": "اشتراكات الأحداث غير متاحة",
|
||||
"Expire": "انتهاء الصلاحية",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "الانتقال إلى قسم",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "احتفظ بإصدارات الكائنات السابقة واستعدها بعد الكتابة فوقها أو حذفها.",
|
||||
"Lifecycle rules unavailable": "قواعد دورة الحياة غير متاحة",
|
||||
"Limits the total data stored in this bucket.": "يحدّ إجمالي البيانات المخزّنة في هذه الحاوية.",
|
||||
"Loading": "جارٍ التحميل",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "أدر التشفير والتحكم في الإصدارات وObject Lock والاحتفاظ الافتراضي.",
|
||||
"No event subscriptions": "لا توجد اشتراكات أحداث",
|
||||
"No lifecycle rules": "لا توجد قواعد لدورة الحياة",
|
||||
"No replication rules": "لا توجد قواعد للنسخ المتماثل",
|
||||
"Notify status unavailable": "حالة الإشعارات غير متاحة",
|
||||
"Open": "فتح",
|
||||
"Protects object versions from deletion during a retention period.": "يحمي إصدارات الكائنات من الحذف خلال فترة الاحتفاظ.",
|
||||
"Quota Unit": "وحدة الحصة",
|
||||
"Replication rules unavailable": "قواعد النسخ المتماثل غير متاحة",
|
||||
"Requires Object Lock": "يتطلب Object Lock",
|
||||
"Save CORS": "حفظ CORS",
|
||||
"Save Encryption": "حفظ التشفير",
|
||||
"Save Policy": "حفظ السياسة",
|
||||
"Save Quota": "حفظ الحصة",
|
||||
"Save Retention": "حفظ الاحتفاظ",
|
||||
"Save Tag": "حفظ الوسم",
|
||||
"Send notifications when objects change.": "أرسل إشعارات عند تغيير الكائنات.",
|
||||
"Set a storage limit and organize this bucket with tags.": "عيّن حدًا للتخزين ونظّم هذه الحاوية باستخدام الوسوم.",
|
||||
"Sets the default retention mode and period for new object versions.": "يعيّن وضع وفترة الاحتفاظ الافتراضيين لإصدارات الكائنات الجديدة.",
|
||||
"Tag key already exists": "مفتاح الوسم موجود بالفعل",
|
||||
"The replication target was created but could not be rolled back.": "أُنشئت وجهة النسخ المتماثل، لكن تعذّر التراجع عن إنشائها.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "حُذفت القاعدة، لكن تعذرت إزالة الوجهة البعيدة.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "لا يملك هذا الاشتراك معرّفًا ثابتًا. حدّث الصفحة قبل حذفه.",
|
||||
"Transition or expire objects automatically.": "انقل الكائنات إلى فئة تخزين أخرى أو أنهِ صلاحيتها تلقائيًا.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "تعذّر تحميل اشتراكات الأحداث. حدّث الصفحة قبل إجراء تغييرات.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "تعذّر تحميل قواعد دورة الحياة. حدّث الصفحة قبل إجراء تغييرات.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "تعذّر تحميل قواعد النسخ المتماثل. حدّث الصفحة قبل إجراء تغييرات.",
|
||||
"Unable to load this setting. Refresh before making changes.": "تعذّر تحميل هذا الإعداد. حدّث الصفحة قبل إجراء تغييرات.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "تعذّر التحقق من تفعيل الإشعارات. حدّث الصفحة قبل إجراء تغييرات.",
|
||||
"Unnamed rule": "قاعدة بلا اسم",
|
||||
"Unnamed subscription": "اشتراك بلا اسم",
|
||||
"Versioning": "التحكم في الإصدارات",
|
||||
"Versioning can be suspended but existing versions are preserved.": "يمكن تعليق التحكم في الإصدارات مع الاحتفاظ بالإصدارات الحالية.",
|
||||
"Versioning stays enabled while Object Lock is active.": "يظل التحكم في الإصدارات مفعّلًا أثناء تفعيل Object Lock.",
|
||||
"bucket": "حاوية",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "ربما حُفظت الوجهة البعيدة. راجع إعداد النسخ المتماثل قبل إعادة المحاولة.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "قد يتيح الوصول العام للكائنات لأي شخص تسمح له السياسة. راجعها قبل الحفظ.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "يزيل سياسة الحاوية. يظل الوصول عبر IAM وبيانات الاعتماد ساريًا.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "يستخدم سياسة JSON أدناه. قد تمنع العبارات غير الصالحة الوصول.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "يستخدم مفتاح KMS المحدد لتشفير الكائنات الجديدة.",
|
||||
"Uses server-managed keys to encrypt new objects.": "يستخدم مفاتيح يديرها الخادم لتشفير الكائنات الجديدة.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "لا يمكن تقصير مدة الاحتفاظ أو تجاوزها حتى تنتهي.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "يمكن للمستخدمين المصرح لهم تجاوز مدة الاحتفاظ أو تقصيرها عند الضرورة.",
|
||||
"Unable to load versioning status.": "تعذّر تحميل حالة التحكم في الإصدارات.",
|
||||
"Unable to load storage tiers.": "تعذّر تحميل طبقات التخزين.",
|
||||
"Unable to load event targets.": "تعذّر تحميل أهداف الأحداث.",
|
||||
"Backend and default key": "الواجهة الخلفية والمفتاح الافتراضي",
|
||||
"Reliability": "الموثوقية",
|
||||
"KMS Cache": "ذاكرة التخزين المؤقت لـ KMS",
|
||||
"Vault connection": "اتصال Vault",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "لا يمكن حذف مفتاح SSE الافتراضي. اختر مفتاحًا افتراضيًا آخر أولًا.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "قد يؤدي تغيير حالة خدمة KMS إلى انقطاع طلبات SSE لفترة وجيزة.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "سيتم إيقاف KMS. لن تتوفر خدمة SSE وإدارة المفاتيح حتى تعيد تشغيل KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "لديك تغييرات غير محفوظة في KMS. هل تريد تجاهلها؟",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "نتيجة العملية غير مؤكدة. تم تحديث الحالة الحالية.",
|
||||
"Please enter a Local KMS master key.": "يرجى إدخال مفتاح رئيسي لـ Local KMS.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "أدخل هذا المفتاح في كل مرة تحفظ فيها إعداد Local KMS. لا يتم عرضه أو الاحتفاظ به مطلقًا.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "تكوين KMS لنظام الملفات المحلي للقراءة فقط في وحدة التحكم حتى يتوفر تدوير آمن للمفتاح الرئيسي.",
|
||||
"Set a default key for Local KMS in server configuration.": "عيّن مفتاحًا افتراضيًا لـ Local KMS في تكوين الخادم.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "لا تزال هناك عملية قيد التنفيذ. انتظر حتى تنتهي قبل مغادرة هذه الصفحة.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "احفظ تغييرات تكوين KMS أو تجاهلها قبل تعيين مفتاح افتراضي تم إنشاؤه حديثًا.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "يجب أن يكون اسم الكائن الجديد مختلفًا",
|
||||
"Object name cannot contain slashes": "لا يمكن أن يحتوي اسم الكائن على شرطات مائلة",
|
||||
"Object name is required": "اسم الكائن مطلوب",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Aktueller Standort",
|
||||
"Current User Policy": "Aktuelle Benutzerrichtlinie",
|
||||
"Current Version": "Aktuelle Version",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Aktueller Backend-Status für replizierte Buckets, Benutzer, Gruppen, Richtlinien und Ablaufregeln des Lebenszyklus.",
|
||||
"Current site + remote peers": "Aktuelle Site + entfernte Peers",
|
||||
"Current user policy": "Aktuelle Benutzerrichtlinie",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON erfolgreich formatiert",
|
||||
"JSON is already formatted": "JSON ist bereits formatiert",
|
||||
"KMS Backend": "KMS-Backend",
|
||||
"Backend and default key": "Backend und Standardschlüssel",
|
||||
"Reliability": "Zuverlässigkeit",
|
||||
"KMS Cache": "KMS-Cache",
|
||||
"Vault connection": "Vault-Verbindung",
|
||||
"KMS Configuration": "KMS-Konfiguration",
|
||||
"KMS Key": "KMS-Schlüssel",
|
||||
"KMS Key Details": "KMS-Schlüsseldetails",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "KMS-Server läuft, Konfigurationsdetails sind privat",
|
||||
"KMS server status unknown": "KMS-Server-Status unbekannt",
|
||||
"KMS service has errors": "KMS-Dienst hat Fehler",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Der Standard-SSE-Schlüssel kann nicht gelöscht werden. Wählen Sie zuerst einen anderen Standardschlüssel aus.",
|
||||
"KMS service is stopped": "KMS-Dienst ist gestoppt",
|
||||
"KMS service not initialized, please configure it first": "KMS-Dienst nicht initialisiert, bitte zuerst konfigurieren",
|
||||
"KMS service restarted successfully": "KMS-Dienst erfolgreich neu gestartet",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "KMS neu starten",
|
||||
"Restart Required": "Neustart erforderlich",
|
||||
"Restore Key": "Schlüssel wiederherstellen",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Fortsetzen",
|
||||
"Resync cancel request sent successfully": "Anfrage zum Abbrechen der Neusynchronisierung erfolgreich gesendet",
|
||||
"Resync request started successfully": "Neusynchronisierungsanfrage erfolgreich gestartet",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Der aktuelle Standort wird automatisch vom Server erkannt. Fügen Sie einen oder mehrere Remote-Standorte hinzu, um der Replikationsgruppe beizutreten.",
|
||||
"The directory must be an absolute path on the server.": "Das Verzeichnis muss ein absoluter Pfad auf dem Server sein.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "Die exportierte Datei enthält sensible Informationen. Bitte bewahren Sie sie sicher auf.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "Der geheime Schlüssel wird nur einmal angezeigt. Kopieren oder exportieren Sie ihn vor dem Schließen.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Der Server erkennt die lokale Site-Identität aus der aktiven RustFS-Bereitstellung.",
|
||||
"The two passwords are inconsistent": "Die beiden Passwörter stimmen nicht überein",
|
||||
"The value is used only for the initial join request.": "Dieser Wert wird nur für die erste Beitrittsanfrage verwendet.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Diese Aktion legt den ausgewählten Pool still und sollte erst verwendet werden, nachdem der Neuausgleich abgeschlossen ist.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Dieser Schlüssel wird als plattformweiter Standard-SSE-Schlüssel für SSE-KMS und SSE-S3 verwendet.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Dieser Schlüssel ist die Plattform-Standardvorgabe für SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Dieser Link läuft ab, wenn Ihre Sitzung endet oder zum angegebenen Zeitpunkt.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Der Schlüssel wird sofort dauerhaft gelöscht und kann nicht rückgängig gemacht werden.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Schlüsseltypen verstehen",
|
||||
"Unhealthy": "Fehlerhaft",
|
||||
"Unknown": "Unbekannt",
|
||||
"Degraded": "Beeinträchtigt",
|
||||
"Unknown Folder": "Unbekannter Ordner",
|
||||
"Unlimited": "Unbegrenzt",
|
||||
"Unnamed Site": "Unbenannte Site",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Alle",
|
||||
"All reported servers and disks are online.": "Alle gemeldeten Server und Laufwerke sind online.",
|
||||
"Check your connection, then refresh the status page.": "Prüfen Sie Ihre Verbindung und aktualisieren Sie dann die Statusseite.",
|
||||
"Cluster Health": "Clusterzustand",
|
||||
"Cluster information": "Clusterinformationen",
|
||||
"Current Scan Started": "Start des aktuellen Scans",
|
||||
"Degraded": "Beeinträchtigt",
|
||||
"Endpoint, ascending": "Endpunkt, aufsteigend",
|
||||
"Endpoint, descending": "Endpunkt, absteigend",
|
||||
"Filter servers": "Server filtern",
|
||||
"Initializing": "Wird initialisiert",
|
||||
"Inventory": "Inventar",
|
||||
"Last Completed Scan": "Letzter abgeschlossener Scan",
|
||||
"Last refreshed": "Zuletzt aktualisiert",
|
||||
"Needs attention": "Handlungsbedarf",
|
||||
"Needs attention first": "Handlungsbedarf zuerst",
|
||||
"No drive information was reported for this server.": "Für diesen Server wurden keine Laufwerksinformationen gemeldet.",
|
||||
"No server information was reported.": "Es wurden keine Serverinformationen gemeldet.",
|
||||
"No servers match the current filter": "Keine Server entsprechen dem aktuellen Filter",
|
||||
"Offline or degraded resources require attention.": "Offline- oder beeinträchtigte Ressourcen müssen geprüft werden.",
|
||||
"Parity and backend settings reported by the cluster.": "Vom Cluster gemeldete Paritäts- und Backend-Einstellungen.",
|
||||
"Refresh to try again.": "Zum erneuten Versuch aktualisieren.",
|
||||
"Refreshing…": "Wird aktualisiert…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Kapazitätsauslastung und Scanneraktivität zentral prüfen.",
|
||||
"Scan Duration": "Scandauer",
|
||||
"Scanner metrics": "Scanner-Metriken",
|
||||
"Show all servers": "Alle Server anzeigen",
|
||||
"Some resource health data is unavailable or still initializing.": "Einige Daten zum Ressourcenzustand sind nicht verfügbar oder werden noch initialisiert.",
|
||||
"Some status data is unavailable.": "Einige Statusdaten sind nicht verfügbar.",
|
||||
"Storage Configuration": "Speicherkonfiguration",
|
||||
"Storage Usage and Scanner": "Speichernutzung und Scanner",
|
||||
"Storage usage is unavailable for this account.": "Die Speichernutzung ist für dieses Konto nicht verfügbar.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Der Clusterstatus konnte nicht geladen werden. Prüfen Sie Ihre Verbindung und aktualisieren Sie die Seite.",
|
||||
"Unavailable": "Nicht verfügbar",
|
||||
"Uptime, longest first": "Betriebszeit, längste zuerst",
|
||||
"Uptime, shortest first": "Betriebszeit, kürzeste zuerst",
|
||||
"Previously loaded values may be stale.": "Zuvor geladene Werte sind möglicherweise veraltet.",
|
||||
"Scanner Status": "Scannerstatus",
|
||||
"Scanning": "Scan läuft",
|
||||
"Never run": "Nie ausgeführt",
|
||||
"Access & Sharing": "Zugriff & Freigabe",
|
||||
"Add key-value metadata for organization and automation.": "Schlüssel-Wert-Metadaten für Organisation und Automatisierung hinzufügen.",
|
||||
"Automation": "Automatisierung",
|
||||
"Bucket Settings": "Bucket-Einstellungen",
|
||||
"Capacity & Metadata": "Kapazität & Metadaten",
|
||||
"Configuration changed. Refresh and try again.": "Die Konfiguration wurde geändert. Aktualisieren Sie die Seite und versuchen Sie es erneut.",
|
||||
"Configuration unavailable": "Konfiguration nicht verfügbar",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Ereigniszustellung, Objektlebenszyklus und Replikationsregeln konfigurieren.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Legen Sie fest, wer auf diesen Bucket zugreifen darf und welche Browser-Ursprünge zulässig sind.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Steuert den anonymen und authentifizierten Zugriff auf Objekte in diesem Bucket.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Steuert, welche Web-Ursprünge Cross-Origin-Anfragen an diesen Bucket senden dürfen.",
|
||||
"Copy objects to another bucket or site.": "Objekte in einen anderen Bucket oder an einen anderen Standort kopieren.",
|
||||
"Data Protection": "Datenschutz",
|
||||
"Delete Event Subscription": "Ereignisabonnement löschen",
|
||||
"Delete Rule": "Regel löschen",
|
||||
"Encrypts new objects at rest by default.": "Verschlüsselt neue Objekte standardmäßig im Ruhezustand.",
|
||||
"Enter a valid positive quota.": "Geben Sie ein gültiges positives Kontingent ein.",
|
||||
"Event subscriptions unavailable": "Ereignisabonnements nicht verfügbar",
|
||||
"Expire": "Ablaufen lassen",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Zum Abschnitt springen",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Behält frühere Objektversionen bei und ermöglicht die Wiederherstellung nach Überschreiben oder Löschen.",
|
||||
"Lifecycle rules unavailable": "Lebenszyklusregeln nicht verfügbar",
|
||||
"Limits the total data stored in this bucket.": "Begrenzt die Gesamtmenge der in diesem Bucket gespeicherten Daten.",
|
||||
"Loading": "Wird geladen",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Verschlüsselung, Versionierung, Object Lock und Standardaufbewahrung verwalten.",
|
||||
"No event subscriptions": "Keine Ereignisabonnements",
|
||||
"No lifecycle rules": "Keine Lebenszyklusregeln",
|
||||
"No replication rules": "Keine Replikationsregeln",
|
||||
"Notify status unavailable": "Benachrichtigungsstatus nicht verfügbar",
|
||||
"Open": "Öffnen",
|
||||
"Protects object versions from deletion during a retention period.": "Schützt Objektversionen während einer Aufbewahrungsfrist vor dem Löschen.",
|
||||
"Quota Unit": "Kontingenteinheit",
|
||||
"Replication rules unavailable": "Replikationsregeln nicht verfügbar",
|
||||
"Requires Object Lock": "Erfordert Object Lock",
|
||||
"Save CORS": "CORS speichern",
|
||||
"Save Encryption": "Verschlüsselung speichern",
|
||||
"Save Policy": "Richtlinie speichern",
|
||||
"Save Quota": "Kontingent speichern",
|
||||
"Save Retention": "Aufbewahrung speichern",
|
||||
"Save Tag": "Tag speichern",
|
||||
"Send notifications when objects change.": "Benachrichtigungen senden, wenn sich Objekte ändern.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Ein Speicherlimit festlegen und diesen Bucket mit Tags organisieren.",
|
||||
"Sets the default retention mode and period for new object versions.": "Legt den Standardaufbewahrungsmodus und -zeitraum für neue Objektversionen fest.",
|
||||
"Tag key already exists": "Der Tag-Schlüssel ist bereits vorhanden",
|
||||
"The replication target was created but could not be rolled back.": "Das Replikationsziel wurde erstellt, konnte aber nicht zurückgesetzt werden.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "Die Regel wurde gelöscht, das entfernte Ziel konnte jedoch nicht entfernt werden.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Dieses Abonnement hat keine stabile ID. Aktualisieren Sie die Seite, bevor Sie es löschen.",
|
||||
"Transition or expire objects automatically.": "Objekte automatisch in eine andere Speicherklasse verschieben oder ablaufen lassen.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Ereignisabonnements konnten nicht geladen werden. Aktualisieren Sie die Seite, bevor Sie Änderungen vornehmen.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Lebenszyklusregeln konnten nicht geladen werden. Aktualisieren Sie die Seite, bevor Sie Änderungen vornehmen.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Replikationsregeln konnten nicht geladen werden. Aktualisieren Sie die Seite, bevor Sie Änderungen vornehmen.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Diese Einstellung konnte nicht geladen werden. Aktualisieren Sie die Seite, bevor Sie Änderungen vornehmen.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Es konnte nicht geprüft werden, ob Benachrichtigungen aktiviert sind. Aktualisieren Sie die Seite, bevor Sie Änderungen vornehmen.",
|
||||
"Unnamed rule": "Unbenannte Regel",
|
||||
"Unnamed subscription": "Unbenanntes Abonnement",
|
||||
"Versioning": "Versionierung",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Die Versionierung kann ausgesetzt werden; vorhandene Versionen bleiben erhalten.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Die Versionierung bleibt aktiviert, solange Object Lock aktiv ist.",
|
||||
"bucket": "Bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "Das entfernte Ziel wurde möglicherweise gespeichert. Prüfen Sie die Replikationskonfiguration, bevor Sie es erneut versuchen.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "Öffentlicher Zugriff kann Objekte für alle Personen zugänglich machen, denen die Richtlinie Zugriff gewährt. Prüfen Sie die Richtlinie vor dem Speichern.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Entfernt die Bucket-Richtlinie. IAM- und zugangsdatenbasierter Zugriff gelten weiterhin.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Verwendet die unten stehende JSON-Richtlinie. Ungültige Anweisungen können den Zugriff blockieren.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Verwendet den ausgewählten KMS-Schlüssel, um neue Objekte zu verschlüsseln.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Verwendet serververwaltete Schlüssel, um neue Objekte zu verschlüsseln.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Die Aufbewahrungsdauer kann bis zu ihrem Ablauf weder verkürzt noch umgangen werden.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Autorisierte Benutzer können die Aufbewahrung bei Bedarf umgehen oder verkürzen.",
|
||||
"Unable to load versioning status.": "Der Versionierungsstatus konnte nicht geladen werden.",
|
||||
"Unable to load storage tiers.": "Die Speicherebenen konnten nicht geladen werden.",
|
||||
"Unable to load event targets.": "Die Ereignisziele konnten nicht geladen werden.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Das Ändern des KMS-Dienststatus kann SSE-Anfragen kurzzeitig unterbrechen.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS wird gestoppt. SSE und die Schlüsselverwaltung sind nicht verfügbar, bis Sie KMS erneut starten.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Sie haben nicht gespeicherte KMS-Änderungen. Möchten Sie sie verwerfen?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "Das Ergebnis des Vorgangs ist ungewiss. Der aktuelle Status wurde aktualisiert.",
|
||||
"Please enter a Local KMS master key.": "Bitte geben Sie einen Local-KMS-Hauptschlüssel ein.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Geben Sie ihn bei jedem Speichern der Local-KMS-Konfiguration ein. Er wird nie angezeigt oder gespeichert.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "Die Local-Dateisystem-KMS-Konfiguration ist in der Konsole schreibgeschützt, bis eine sichere Rotation des Hauptschlüssels verfügbar ist.",
|
||||
"Set a default key for Local KMS in server configuration.": "Legen Sie in der Serverkonfiguration einen Standardschlüssel für Local KMS fest.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Ein Vorgang läuft noch. Warten Sie, bis er abgeschlossen ist, bevor Sie diese Seite verlassen.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Speichern oder verwerfen Sie die Änderungen an der KMS-Konfiguration, bevor Sie einen neu erstellten Standardschlüssel festlegen.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Der neue Objektname muss anders sein",
|
||||
"Object name cannot contain slashes": "Der Objektname darf keine Schrägstriche enthalten",
|
||||
"Object name is required": "Der Objektname ist erforderlich",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Current Site",
|
||||
"Current User Policy": "Current User Policy",
|
||||
"Current Version": "Current Version",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.",
|
||||
"Current site + remote peers": "Current site + remote peers",
|
||||
"Current user policy": "Current user policy",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON formatted successfully",
|
||||
"JSON is already formatted": "JSON is already formatted",
|
||||
"KMS Backend": "KMS Backend",
|
||||
"Backend and default key": "Backend and default key",
|
||||
"Reliability": "Reliability",
|
||||
"KMS Cache": "KMS Cache",
|
||||
"Vault connection": "Vault connection",
|
||||
"KMS Configuration": "KMS Configuration",
|
||||
"KMS Key": "KMS Key",
|
||||
"KMS Key Details": "KMS Key Details",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "KMS server is running, configuration details are private",
|
||||
"KMS server status unknown": "KMS server status unknown",
|
||||
"KMS service has errors": "KMS service has errors",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Cannot delete the default SSE key. Choose another default key first.",
|
||||
"KMS service is stopped": "KMS service is stopped",
|
||||
"KMS service not initialized, please configure it first": "KMS service not initialized, please configure it first",
|
||||
"KMS service restarted successfully": "KMS service restarted successfully",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Restart KMS",
|
||||
"Restart Required": "Restart Required",
|
||||
"Restore Key": "Restore Key",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Resume",
|
||||
"Resync cancel request sent successfully": "Resync cancel request sent successfully",
|
||||
"Resync request started successfully": "Resync request started successfully",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "The current site is detected automatically by the server. Add one or more remote sites to join the replication group.",
|
||||
"The directory must be an absolute path on the server.": "The directory must be an absolute path on the server.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "The exported file contains sensitive information. Please keep it secure.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "The secret key is shown only once. Copy or export it before closing.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "The server detects the local site identity from the active RustFS deployment.",
|
||||
"The two passwords are inconsistent": "The two passwords are inconsistent",
|
||||
"The value is used only for the initial join request.": "The value is used only for the initial join request.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "This action cannot be undone.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "This action retires the selected pool and should be used only after verifying rebalance has completed.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "This key is used as the platform default SSE key for SSE-KMS and SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "This key is used as the platform default for SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "This link will expire when your session ends or at the specified time.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "This permanently deletes the key immediately and cannot be undone.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Understanding Key Types",
|
||||
"Unhealthy": "Unhealthy",
|
||||
"Unknown": "Unknown",
|
||||
"Degraded": "Degraded",
|
||||
"Unknown Folder": "Unknown Folder",
|
||||
"Unlimited": "Unlimited",
|
||||
"Unnamed Site": "Unnamed Site",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "All",
|
||||
"All reported servers and disks are online.": "All reported servers and disks are online.",
|
||||
"Check your connection, then refresh the status page.": "Check your connection, then refresh the status page.",
|
||||
"Cluster Health": "Cluster Health",
|
||||
"Cluster information": "Cluster information",
|
||||
"Current Scan Started": "Current Scan Started",
|
||||
"Degraded": "Degraded",
|
||||
"Endpoint, ascending": "Endpoint, ascending",
|
||||
"Endpoint, descending": "Endpoint, descending",
|
||||
"Filter servers": "Filter servers",
|
||||
"Initializing": "Initializing",
|
||||
"Inventory": "Inventory",
|
||||
"Last Completed Scan": "Last Completed Scan",
|
||||
"Last refreshed": "Last refreshed",
|
||||
"Needs attention": "Needs attention",
|
||||
"Needs attention first": "Needs attention first",
|
||||
"No drive information was reported for this server.": "No drive information was reported for this server.",
|
||||
"No server information was reported.": "No server information was reported.",
|
||||
"No servers match the current filter": "No servers match the current filter",
|
||||
"Offline or degraded resources require attention.": "Offline or degraded resources require attention.",
|
||||
"Parity and backend settings reported by the cluster.": "Parity and backend settings reported by the cluster.",
|
||||
"Refresh to try again.": "Refresh to try again.",
|
||||
"Refreshing…": "Refreshing…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Review capacity pressure and scanner activity in one place.",
|
||||
"Scan Duration": "Scan Duration",
|
||||
"Scanner metrics": "Scanner metrics",
|
||||
"Show all servers": "Show all servers",
|
||||
"Some resource health data is unavailable or still initializing.": "Some resource health data is unavailable or still initializing.",
|
||||
"Some status data is unavailable.": "Some status data is unavailable.",
|
||||
"Storage Configuration": "Storage Configuration",
|
||||
"Storage Usage and Scanner": "Storage Usage and Scanner",
|
||||
"Storage usage is unavailable for this account.": "Storage usage is unavailable for this account.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Unable to load cluster status. Check your connection and refresh.",
|
||||
"Unavailable": "Unavailable",
|
||||
"Uptime, longest first": "Uptime, longest first",
|
||||
"Uptime, shortest first": "Uptime, shortest first",
|
||||
"Previously loaded values may be stale.": "Previously loaded values may be stale.",
|
||||
"Scanner Status": "Scanner Status",
|
||||
"Scanning": "Scanning",
|
||||
"Never run": "Never run",
|
||||
"Access & Sharing": "Access & Sharing",
|
||||
"Add key-value metadata for organization and automation.": "Add key-value metadata for organization and automation.",
|
||||
"Automation": "Automation",
|
||||
"Bucket Settings": "Bucket Settings",
|
||||
"Capacity & Metadata": "Capacity & Metadata",
|
||||
"Configuration changed. Refresh and try again.": "Configuration changed. Refresh and try again.",
|
||||
"Configuration unavailable": "Configuration unavailable",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Configure event delivery, object lifecycle, and replication rules.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Control who can access this bucket and which browser origins are allowed.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Controls anonymous and authenticated access to objects in this bucket.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Controls which web origins can send cross-origin requests to this bucket.",
|
||||
"Copy objects to another bucket or site.": "Copy objects to another bucket or site.",
|
||||
"Data Protection": "Data Protection",
|
||||
"Delete Event Subscription": "Delete Event Subscription",
|
||||
"Delete Rule": "Delete Rule",
|
||||
"Encrypts new objects at rest by default.": "Encrypts new objects at rest by default.",
|
||||
"Enter a valid positive quota.": "Enter a valid positive quota.",
|
||||
"Event subscriptions unavailable": "Event subscriptions unavailable",
|
||||
"Expire": "Expire",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Jump to section",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Keep previous object versions and recover from overwrites or deletes.",
|
||||
"Lifecycle rules unavailable": "Lifecycle rules unavailable",
|
||||
"Limits the total data stored in this bucket.": "Limits the total data stored in this bucket.",
|
||||
"Loading": "Loading",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Manage encryption, versioning, object lock, and default retention.",
|
||||
"No event subscriptions": "No event subscriptions",
|
||||
"No lifecycle rules": "No lifecycle rules",
|
||||
"No replication rules": "No replication rules",
|
||||
"Notify status unavailable": "Notify status unavailable",
|
||||
"Open": "Open",
|
||||
"Protects object versions from deletion during a retention period.": "Protects object versions from deletion during a retention period.",
|
||||
"Quota Unit": "Quota Unit",
|
||||
"Replication rules unavailable": "Replication rules unavailable",
|
||||
"Requires Object Lock": "Requires Object Lock",
|
||||
"Save CORS": "Save CORS",
|
||||
"Save Encryption": "Save Encryption",
|
||||
"Save Policy": "Save Policy",
|
||||
"Save Quota": "Save Quota",
|
||||
"Save Retention": "Save Retention",
|
||||
"Save Tag": "Save Tag",
|
||||
"Send notifications when objects change.": "Send notifications when objects change.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Set a storage limit and organize this bucket with tags.",
|
||||
"Sets the default retention mode and period for new object versions.": "Sets the default retention mode and period for new object versions.",
|
||||
"Tag key already exists": "Tag key already exists",
|
||||
"The replication target was created but could not be rolled back.": "The replication target was created but could not be rolled back.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "The rule was deleted, but the remote target could not be removed.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "This subscription has no stable ID. Refresh before deleting it.",
|
||||
"Transition or expire objects automatically.": "Transition or expire objects automatically.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Unable to load event subscriptions. Refresh before making changes.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Unable to load lifecycle rules. Refresh before making changes.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Unable to load replication rules. Refresh before making changes.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Unable to load this setting. Refresh before making changes.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Unable to verify whether notify is enabled. Refresh before making changes.",
|
||||
"Unnamed rule": "Unnamed rule",
|
||||
"Unnamed subscription": "Unnamed subscription",
|
||||
"Versioning": "Versioning",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Versioning can be suspended but existing versions are preserved.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Versioning stays enabled while Object Lock is active.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "The remote target may have been saved. Review the replication configuration before retrying.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "Public access can expose objects to anyone allowed by the policy. Review it before saving.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Removes the bucket policy. IAM and credential-based access still apply.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Uses the JSON policy below. Invalid statements can block access.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Uses the selected KMS key to encrypt new objects.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Uses server-managed keys to encrypt new objects.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Retention cannot be shortened or bypassed until it expires.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Authorized users can bypass or shorten retention when necessary.",
|
||||
"Unable to load versioning status.": "Unable to load versioning status.",
|
||||
"Unable to load storage tiers.": "Unable to load storage tiers.",
|
||||
"Unable to load event targets.": "Unable to load event targets.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Changing KMS service state may briefly interrupt SSE requests.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS will be stopped. SSE and key management will be unavailable until you start KMS again.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "You have unsaved KMS changes. Do you want to discard them?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "The operation result is uncertain. Current status has been refreshed.",
|
||||
"Please enter a Local KMS master key.": "Please enter a Local KMS master key.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Enter this every time you save Local KMS configuration. It is never shown or retained.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.",
|
||||
"Set a default key for Local KMS in server configuration.": "Set a default key for Local KMS in server configuration.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "An operation is still in progress. Wait for it to finish before leaving this page.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Save or discard KMS configuration changes before setting a newly created default key.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "New object name must be different",
|
||||
"Object name cannot contain slashes": "Object name cannot contain slashes",
|
||||
"Object name is required": "Object name is required",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Sitio Actual",
|
||||
"Current User Policy": "Política de Usuario Actual",
|
||||
"Current Version": "Versión Actual",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Estado actual del backend para buckets, usuarios, grupos, políticas y reglas de expiración del ciclo de vida replicados.",
|
||||
"Current site + remote peers": "Sitio actual + pares remotos",
|
||||
"Current user policy": "Política de usuario actual",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON formateado exitosamente",
|
||||
"JSON is already formatted": "JSON ya está formateado",
|
||||
"KMS Backend": "Backend de KMS",
|
||||
"Backend and default key": "Backend y clave predeterminada",
|
||||
"Reliability": "Fiabilidad",
|
||||
"KMS Cache": "Caché de KMS",
|
||||
"Vault connection": "Conexión con Vault",
|
||||
"KMS Configuration": "Configuración de KMS",
|
||||
"KMS Key": "Clave KMS",
|
||||
"KMS Key Details": "Detalles de la clave KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "El servidor KMS está en ejecución, los detalles de configuración son privados",
|
||||
"KMS server status unknown": "Estado del servidor KMS desconocido",
|
||||
"KMS service has errors": "El servicio KMS tiene errores",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "No se puede eliminar la clave SSE predeterminada. Elige primero otra clave predeterminada.",
|
||||
"KMS service is stopped": "El servicio KMS está detenido",
|
||||
"KMS service not initialized, please configure it first": "Servicio KMS no inicializado, configúrelo primero",
|
||||
"KMS service restarted successfully": "Servicio KMS reiniciado correctamente",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Reiniciar KMS",
|
||||
"Restart Required": "Reinicio requerido",
|
||||
"Restore Key": "Restaurar clave",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Reanudar",
|
||||
"Resync cancel request sent successfully": "La solicitud para cancelar la resincronización se envió correctamente",
|
||||
"Resync request started successfully": "La solicitud de resincronización se inició correctamente",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "El sitio actual se detecta automáticamente por el servidor. Añade uno o más sitios remotos para unirte al grupo de replicación.",
|
||||
"The directory must be an absolute path on the server.": "El directorio debe ser una ruta absoluta en el servidor.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "El archivo exportado contiene información sensible. Manténgalo seguro.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "La clave secreta se muestra una sola vez. Cópiala o expórtala antes de cerrar.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "El servidor detecta la identidad del sitio local a partir de la implementación activa de RustFS.",
|
||||
"The two passwords are inconsistent": "Las dos contraseñas no coinciden",
|
||||
"The value is used only for the initial join request.": "El valor se usa solo para la solicitud inicial de unión.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Esta acción no se puede deshacer.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Esta acción retira el pool seleccionado y solo debe usarse tras verificar que el reequilibrio ha finalizado.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Esta clave se usa como la clave SSE predeterminada de la plataforma para SSE-KMS y SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Esta clave se usa como predeterminada de la plataforma para SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Este enlace caducará cuando finalice su sesión o en la hora especificada.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Esto elimina la clave de forma permanente e inmediata y no se puede deshacer.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Comprensión de Tipos de Clave",
|
||||
"Unhealthy": "Con problemas",
|
||||
"Unknown": "Desconocido",
|
||||
"Degraded": "Degradado",
|
||||
"Unknown Folder": "Carpeta Desconocida",
|
||||
"Unlimited": "Ilimitado",
|
||||
"Unnamed Site": "Sitio sin nombre",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Todos",
|
||||
"All reported servers and disks are online.": "Todos los servidores y discos informados están en línea.",
|
||||
"Check your connection, then refresh the status page.": "Comprueba la conexión y actualiza la página de estado.",
|
||||
"Cluster Health": "Estado del clúster",
|
||||
"Cluster information": "Información del clúster",
|
||||
"Current Scan Started": "Inicio del análisis actual",
|
||||
"Degraded": "Degradado",
|
||||
"Endpoint, ascending": "Punto final, ascendente",
|
||||
"Endpoint, descending": "Punto final, descendente",
|
||||
"Filter servers": "Filtrar servidores",
|
||||
"Initializing": "Inicializando",
|
||||
"Inventory": "Inventario",
|
||||
"Last Completed Scan": "Último análisis completado",
|
||||
"Last refreshed": "Última actualización",
|
||||
"Needs attention": "Requiere atención",
|
||||
"Needs attention first": "Primero los que requieren atención",
|
||||
"No drive information was reported for this server.": "No se proporcionó información de las unidades de este servidor.",
|
||||
"No server information was reported.": "No se proporcionó información de los servidores.",
|
||||
"No servers match the current filter": "Ningún servidor coincide con el filtro actual",
|
||||
"Offline or degraded resources require attention.": "Los recursos sin conexión o degradados requieren atención.",
|
||||
"Parity and backend settings reported by the cluster.": "Configuración de paridad y backend informada por el clúster.",
|
||||
"Refresh to try again.": "Actualiza para volver a intentarlo.",
|
||||
"Refreshing…": "Actualizando…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Revisa la capacidad y la actividad del escáner en un solo lugar.",
|
||||
"Scan Duration": "Duración del análisis",
|
||||
"Scanner metrics": "Métricas del escáner",
|
||||
"Show all servers": "Mostrar todos los servidores",
|
||||
"Some resource health data is unavailable or still initializing.": "Algunos datos del estado de los recursos no están disponibles o aún se están inicializando.",
|
||||
"Some status data is unavailable.": "Algunos datos de estado no están disponibles.",
|
||||
"Storage Configuration": "Configuración de almacenamiento",
|
||||
"Storage Usage and Scanner": "Uso del almacenamiento y escáner",
|
||||
"Storage usage is unavailable for this account.": "El uso del almacenamiento no está disponible para esta cuenta.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "No se pudo cargar el estado del clúster. Comprueba la conexión y actualiza.",
|
||||
"Unavailable": "No disponible",
|
||||
"Uptime, longest first": "Tiempo de actividad, más largo primero",
|
||||
"Uptime, shortest first": "Tiempo de actividad, más corto primero",
|
||||
"Previously loaded values may be stale.": "Los valores cargados anteriormente pueden estar desactualizados.",
|
||||
"Scanner Status": "Estado del escáner",
|
||||
"Scanning": "Análisis en curso",
|
||||
"Never run": "Nunca ejecutado",
|
||||
"Access & Sharing": "Acceso y uso compartido",
|
||||
"Add key-value metadata for organization and automation.": "Añade metadatos de clave-valor para la organización y la automatización.",
|
||||
"Automation": "Automatización",
|
||||
"Bucket Settings": "Configuración del bucket",
|
||||
"Capacity & Metadata": "Capacidad y metadatos",
|
||||
"Configuration changed. Refresh and try again.": "La configuración ha cambiado. Actualiza la página e inténtalo de nuevo.",
|
||||
"Configuration unavailable": "Configuración no disponible",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Configura la entrega de eventos, el ciclo de vida de los objetos y las reglas de replicación.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Controla quién puede acceder a este bucket y qué orígenes del navegador están permitidos.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Controla el acceso anónimo y autenticado a los objetos de este bucket.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Controla qué orígenes web pueden enviar solicitudes de origen cruzado a este bucket.",
|
||||
"Copy objects to another bucket or site.": "Copia objetos a otro bucket o sitio.",
|
||||
"Data Protection": "Protección de datos",
|
||||
"Delete Event Subscription": "Eliminar suscripción a eventos",
|
||||
"Delete Rule": "Eliminar regla",
|
||||
"Encrypts new objects at rest by default.": "Cifra de forma predeterminada los nuevos objetos en reposo.",
|
||||
"Enter a valid positive quota.": "Introduce una cuota positiva válida.",
|
||||
"Event subscriptions unavailable": "Suscripciones a eventos no disponibles",
|
||||
"Expire": "Hacer caducar",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Ir a la sección",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Conserva las versiones anteriores de los objetos y permite recuperarlas tras sobrescrituras o eliminaciones.",
|
||||
"Lifecycle rules unavailable": "Reglas de ciclo de vida no disponibles",
|
||||
"Limits the total data stored in this bucket.": "Limita la cantidad total de datos almacenados en este bucket.",
|
||||
"Loading": "Cargando",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Gestiona el cifrado, el control de versiones, Object Lock y la retención predeterminada.",
|
||||
"No event subscriptions": "No hay suscripciones a eventos",
|
||||
"No lifecycle rules": "No hay reglas de ciclo de vida",
|
||||
"No replication rules": "No hay reglas de replicación",
|
||||
"Notify status unavailable": "Estado de las notificaciones no disponible",
|
||||
"Open": "Abrir",
|
||||
"Protects object versions from deletion during a retention period.": "Protege las versiones de los objetos contra la eliminación durante un periodo de retención.",
|
||||
"Quota Unit": "Unidad de cuota",
|
||||
"Replication rules unavailable": "Reglas de replicación no disponibles",
|
||||
"Requires Object Lock": "Requiere Object Lock",
|
||||
"Save CORS": "Guardar CORS",
|
||||
"Save Encryption": "Guardar cifrado",
|
||||
"Save Policy": "Guardar política",
|
||||
"Save Quota": "Guardar cuota",
|
||||
"Save Retention": "Guardar retención",
|
||||
"Save Tag": "Guardar etiqueta",
|
||||
"Send notifications when objects change.": "Envía notificaciones cuando cambien los objetos.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Establece un límite de almacenamiento y organiza este bucket con etiquetas.",
|
||||
"Sets the default retention mode and period for new object versions.": "Establece el modo y el periodo de retención predeterminados para las nuevas versiones de objetos.",
|
||||
"Tag key already exists": "La clave de la etiqueta ya existe",
|
||||
"The replication target was created but could not be rolled back.": "El destino de replicación se creó, pero no se pudo revertir.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "La regla se eliminó, pero no se pudo retirar el destino remoto.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Esta suscripción no tiene un ID estable. Actualiza la página antes de eliminarla.",
|
||||
"Transition or expire objects automatically.": "Transfiere automáticamente los objetos a otra clase de almacenamiento o haz que caduquen.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "No se han podido cargar las suscripciones a eventos. Actualiza la página antes de hacer cambios.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "No se han podido cargar las reglas de ciclo de vida. Actualiza la página antes de hacer cambios.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "No se han podido cargar las reglas de replicación. Actualiza la página antes de hacer cambios.",
|
||||
"Unable to load this setting. Refresh before making changes.": "No se ha podido cargar esta configuración. Actualiza la página antes de hacer cambios.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "No se ha podido comprobar si las notificaciones están activadas. Actualiza la página antes de hacer cambios.",
|
||||
"Unnamed rule": "Regla sin nombre",
|
||||
"Unnamed subscription": "Suscripción sin nombre",
|
||||
"Versioning": "Control de versiones",
|
||||
"Versioning can be suspended but existing versions are preserved.": "El control de versiones se puede suspender, pero se conservan las versiones existentes.",
|
||||
"Versioning stays enabled while Object Lock is active.": "El control de versiones permanece activado mientras Object Lock está activo.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "Es posible que el destino remoto se haya guardado. Revisa la configuración de replicación antes de volver a intentarlo.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "El acceso público puede exponer los objetos a cualquier persona autorizada por la política. Revísala antes de guardar.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Elimina la política del bucket. El acceso mediante IAM y credenciales sigue vigente.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Utiliza la política JSON que aparece a continuación. Las instrucciones no válidas pueden bloquear el acceso.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Utiliza la clave KMS seleccionada para cifrar los nuevos objetos.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Utiliza claves gestionadas por el servidor para cifrar los nuevos objetos.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "El periodo de retención no se puede acortar ni eludir hasta que finalice.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Los usuarios autorizados pueden eludir o acortar la retención cuando sea necesario.",
|
||||
"Unable to load versioning status.": "No se ha podido cargar el estado del control de versiones.",
|
||||
"Unable to load storage tiers.": "No se han podido cargar los niveles de almacenamiento.",
|
||||
"Unable to load event targets.": "No se han podido cargar los destinos de eventos.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Cambiar el estado del servicio KMS puede interrumpir brevemente las solicitudes SSE.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS se detendrá. SSE y la gestión de claves no estarán disponibles hasta que vuelvas a iniciar KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Tienes cambios de KMS sin guardar. ¿Quieres descartarlos?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "El resultado de la operación es incierto. Se ha actualizado el estado actual.",
|
||||
"Please enter a Local KMS master key.": "Introduce una clave maestra de KMS local.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Introdúcela cada vez que guardes la configuración de KMS local. Nunca se muestra ni se conserva.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "La configuración de KMS del sistema de archivos local es de solo lectura en la consola hasta que esté disponible la rotación segura de la clave maestra.",
|
||||
"Set a default key for Local KMS in server configuration.": "Configura una clave predeterminada para KMS local en la configuración del servidor.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Todavía hay una operación en curso. Espera a que termine antes de salir de esta página.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Guarda o descarta los cambios de configuración de KMS antes de establecer como predeterminada una clave recién creada.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "El nuevo nombre del objeto debe ser diferente",
|
||||
"Object name cannot contain slashes": "El nombre del objeto no puede contener barras",
|
||||
"Object name is required": "El nombre del objeto es obligatorio",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Site actuel",
|
||||
"Current User Policy": "Politique utilisateur actuelle",
|
||||
"Current Version": "Version actuelle",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "État actuel du backend pour les buckets, utilisateurs, groupes, politiques et règles d’expiration du cycle de vie répliqués.",
|
||||
"Current site + remote peers": "Site actuel + pairs distants",
|
||||
"Current user policy": "Politique utilisateur actuelle",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON formaté avec succès",
|
||||
"JSON is already formatted": "JSON est déjà formaté",
|
||||
"KMS Backend": "Backend KMS",
|
||||
"Backend and default key": "Backend et clé par défaut",
|
||||
"Reliability": "Fiabilité",
|
||||
"KMS Cache": "Cache KMS",
|
||||
"Vault connection": "Connexion à Vault",
|
||||
"KMS Configuration": "Configuration KMS",
|
||||
"KMS Key": "Clé KMS",
|
||||
"KMS Key Details": "Détails de la clé KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "Le serveur KMS fonctionne, les détails de configuration sont privés",
|
||||
"KMS server status unknown": "Statut du serveur KMS inconnu",
|
||||
"KMS service has errors": "Le service KMS a des erreurs",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Impossible de supprimer la clé SSE par défaut. Choisissez d’abord une autre clé par défaut.",
|
||||
"KMS service is stopped": "Le service KMS est arrêté",
|
||||
"KMS service not initialized, please configure it first": "Service KMS non initialisé, veuillez le configurer d'abord",
|
||||
"KMS service restarted successfully": "Service KMS redémarré avec succès",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Redémarrer KMS",
|
||||
"Restart Required": "Redémarrage requis",
|
||||
"Restore Key": "Restaurer la clé",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Reprendre",
|
||||
"Resync cancel request sent successfully": "La demande d'annulation de la resynchronisation a été envoyée avec succès",
|
||||
"Resync request started successfully": "La demande de resynchronisation a été lancée avec succès",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Le site actuel est détecté automatiquement par le serveur. Ajoutez un ou plusieurs sites distants pour rejoindre le groupe de réplication.",
|
||||
"The directory must be an absolute path on the server.": "Le répertoire doit être un chemin absolu sur le serveur.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "Le fichier exporté contient des informations sensibles. Veuillez le conserver en sécurité.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "La clé secrète n’est affichée qu’une seule fois. Copiez-la ou exportez-la avant de fermer.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Le serveur détecte l'identité du site local à partir du déploiement RustFS actif.",
|
||||
"The two passwords are inconsistent": "Les deux mots de passe sont différents",
|
||||
"The value is used only for the initial join request.": "Cette valeur est utilisée uniquement pour la demande de jonction initiale.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Cette action ne peut pas être annulée.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Cette action retire le pool sélectionné et ne doit être utilisée qu’après avoir vérifié que le rééquilibrage est terminé.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Cette clé est utilisée comme clé SSE par défaut de la plateforme pour SSE-KMS et SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Cette clé est utilisée par défaut sur la plateforme pour SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Ce lien expirera lorsque votre session prendra fin ou à l'heure indiquée.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Supprime définitivement la clé immédiatement, sans retour en arrière.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Comprendre les types de clés",
|
||||
"Unhealthy": "Défaillant",
|
||||
"Unknown": "Inconnu",
|
||||
"Degraded": "Dégradé",
|
||||
"Unknown Folder": "Dossier inconnu",
|
||||
"Unlimited": "Illimité",
|
||||
"Unnamed Site": "Site sans nom",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Tous",
|
||||
"All reported servers and disks are online.": "Tous les serveurs et disques signalés sont en ligne.",
|
||||
"Check your connection, then refresh the status page.": "Vérifiez votre connexion, puis actualisez la page d’état.",
|
||||
"Cluster Health": "État du cluster",
|
||||
"Cluster information": "Informations sur le cluster",
|
||||
"Current Scan Started": "Début de l’analyse en cours",
|
||||
"Degraded": "Dégradé",
|
||||
"Endpoint, ascending": "Point de terminaison, croissant",
|
||||
"Endpoint, descending": "Point de terminaison, décroissant",
|
||||
"Filter servers": "Filtrer les serveurs",
|
||||
"Initializing": "Initialisation",
|
||||
"Inventory": "Inventaire",
|
||||
"Last Completed Scan": "Dernière analyse terminée",
|
||||
"Last refreshed": "Dernière actualisation",
|
||||
"Needs attention": "Attention requise",
|
||||
"Needs attention first": "Attention requise en premier",
|
||||
"No drive information was reported for this server.": "Aucune information sur les disques n’a été signalée pour ce serveur.",
|
||||
"No server information was reported.": "Aucune information sur les serveurs n’a été signalée.",
|
||||
"No servers match the current filter": "Aucun serveur ne correspond au filtre actuel",
|
||||
"Offline or degraded resources require attention.": "Les ressources hors ligne ou dégradées nécessitent votre attention.",
|
||||
"Parity and backend settings reported by the cluster.": "Paramètres de parité et du backend signalés par le cluster.",
|
||||
"Refresh to try again.": "Actualisez pour réessayer.",
|
||||
"Refreshing…": "Actualisation…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Surveillez la capacité et l’activité du scanner au même endroit.",
|
||||
"Scan Duration": "Durée de l’analyse",
|
||||
"Scanner metrics": "Métriques du scanner",
|
||||
"Show all servers": "Afficher tous les serveurs",
|
||||
"Some resource health data is unavailable or still initializing.": "Certaines données sur l’état des ressources sont indisponibles ou en cours d’initialisation.",
|
||||
"Some status data is unavailable.": "Certaines données d’état sont indisponibles.",
|
||||
"Storage Configuration": "Configuration du stockage",
|
||||
"Storage Usage and Scanner": "Utilisation du stockage et scanner",
|
||||
"Storage usage is unavailable for this account.": "L’utilisation du stockage n’est pas disponible pour ce compte.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Impossible de charger l’état du cluster. Vérifiez votre connexion et actualisez.",
|
||||
"Unavailable": "Indisponible",
|
||||
"Uptime, longest first": "Durée de fonctionnement, plus longue d’abord",
|
||||
"Uptime, shortest first": "Durée de fonctionnement, plus courte d’abord",
|
||||
"Previously loaded values may be stale.": "Les valeurs précédemment chargées sont peut-être obsolètes.",
|
||||
"Scanner Status": "État du scanner",
|
||||
"Scanning": "Analyse en cours",
|
||||
"Never run": "Jamais exécuté",
|
||||
"Access & Sharing": "Accès et partage",
|
||||
"Add key-value metadata for organization and automation.": "Ajouter des métadonnées clé-valeur pour l’organisation et l’automatisation.",
|
||||
"Automation": "Automatisation",
|
||||
"Bucket Settings": "Paramètres du bucket",
|
||||
"Capacity & Metadata": "Capacité et métadonnées",
|
||||
"Configuration changed. Refresh and try again.": "La configuration a changé. Actualisez la page et réessayez.",
|
||||
"Configuration unavailable": "Configuration indisponible",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Configurer la diffusion des événements, le cycle de vie des objets et les règles de réplication.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Définissez qui peut accéder à ce bucket et quelles origines de navigateur sont autorisées.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Contrôle l’accès anonyme et authentifié aux objets de ce bucket.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Contrôle quelles origines web peuvent envoyer des requêtes inter-origines à ce bucket.",
|
||||
"Copy objects to another bucket or site.": "Copier des objets vers un autre bucket ou site.",
|
||||
"Data Protection": "Protection des données",
|
||||
"Delete Event Subscription": "Supprimer l’abonnement aux événements",
|
||||
"Delete Rule": "Supprimer la règle",
|
||||
"Encrypts new objects at rest by default.": "Chiffre par défaut les nouveaux objets au repos.",
|
||||
"Enter a valid positive quota.": "Saisissez un quota positif valide.",
|
||||
"Event subscriptions unavailable": "Abonnements aux événements indisponibles",
|
||||
"Expire": "Faire expirer",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Accéder à la section",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Conserve les versions précédentes des objets et permet de les restaurer après un remplacement ou une suppression.",
|
||||
"Lifecycle rules unavailable": "Règles de cycle de vie indisponibles",
|
||||
"Limits the total data stored in this bucket.": "Limite la quantité totale de données stockées dans ce bucket.",
|
||||
"Loading": "Chargement",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Gérer le chiffrement, la gestion des versions, Object Lock et la rétention par défaut.",
|
||||
"No event subscriptions": "Aucun abonnement aux événements",
|
||||
"No lifecycle rules": "Aucune règle de cycle de vie",
|
||||
"No replication rules": "Aucune règle de réplication",
|
||||
"Notify status unavailable": "État des notifications indisponible",
|
||||
"Open": "Ouvrir",
|
||||
"Protects object versions from deletion during a retention period.": "Protège les versions d’objets contre la suppression pendant une période de rétention.",
|
||||
"Quota Unit": "Unité du quota",
|
||||
"Replication rules unavailable": "Règles de réplication indisponibles",
|
||||
"Requires Object Lock": "Nécessite Object Lock",
|
||||
"Save CORS": "Enregistrer CORS",
|
||||
"Save Encryption": "Enregistrer le chiffrement",
|
||||
"Save Policy": "Enregistrer la politique",
|
||||
"Save Quota": "Enregistrer le quota",
|
||||
"Save Retention": "Enregistrer la rétention",
|
||||
"Save Tag": "Enregistrer le tag",
|
||||
"Send notifications when objects change.": "Envoyer des notifications lorsque des objets sont modifiés.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Définir une limite de stockage et organiser ce bucket avec des tags.",
|
||||
"Sets the default retention mode and period for new object versions.": "Définit le mode et la période de rétention par défaut des nouvelles versions d’objets.",
|
||||
"Tag key already exists": "Cette clé de tag existe déjà",
|
||||
"The replication target was created but could not be rolled back.": "La cible de réplication a été créée, mais l’opération n’a pas pu être annulée.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "La règle a été supprimée, mais la cible distante n’a pas pu être retirée.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Cet abonnement n’a pas d’identifiant stable. Actualisez la page avant de le supprimer.",
|
||||
"Transition or expire objects automatically.": "Changer automatiquement la classe de stockage des objets ou les faire expirer.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Impossible de charger les abonnements aux événements. Actualisez la page avant toute modification.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Impossible de charger les règles de cycle de vie. Actualisez la page avant toute modification.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Impossible de charger les règles de réplication. Actualisez la page avant toute modification.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Impossible de charger ce paramètre. Actualisez la page avant toute modification.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Impossible de vérifier si les notifications sont activées. Actualisez la page avant toute modification.",
|
||||
"Unnamed rule": "Règle sans nom",
|
||||
"Unnamed subscription": "Abonnement sans nom",
|
||||
"Versioning": "Gestion des versions",
|
||||
"Versioning can be suspended but existing versions are preserved.": "La gestion des versions peut être suspendue, mais les versions existantes sont conservées.",
|
||||
"Versioning stays enabled while Object Lock is active.": "La gestion des versions reste activée tant que Object Lock est actif.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "La cible distante a peut-être été enregistrée. Vérifiez la configuration de réplication avant de réessayer.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "L’accès public peut exposer des objets à toute personne autorisée par la politique. Vérifiez-la avant d’enregistrer.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Supprime la politique du bucket. Les accès via IAM et les identifiants restent applicables.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Utilise la politique JSON ci-dessous. Des instructions non valides peuvent bloquer l’accès.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Utilise la clé KMS sélectionnée pour chiffrer les nouveaux objets.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Utilise des clés gérées par le serveur pour chiffrer les nouveaux objets.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "La période de rétention ne peut être raccourcie ni contournée avant son expiration.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Les utilisateurs autorisés peuvent contourner ou raccourcir la rétention si nécessaire.",
|
||||
"Unable to load versioning status.": "Impossible de charger l’état de la gestion des versions.",
|
||||
"Unable to load storage tiers.": "Impossible de charger les niveaux de stockage.",
|
||||
"Unable to load event targets.": "Impossible de charger les cibles d’événements.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "La modification de l’état du service KMS peut interrompre brièvement les requêtes SSE.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS va être arrêté. SSE et la gestion des clés seront indisponibles jusqu’au redémarrage de KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Vous avez des modifications KMS non enregistrées. Voulez-vous les abandonner ?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "Le résultat de l’opération est incertain. L’état actuel a été mis à jour.",
|
||||
"Please enter a Local KMS master key.": "Veuillez saisir une clé principale KMS locale.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Saisissez-la chaque fois que vous enregistrez la configuration KMS locale. Elle n’est jamais affichée ni conservée.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "La configuration KMS du système de fichiers local est en lecture seule dans la console jusqu’à ce que la rotation sécurisée de la clé principale soit disponible.",
|
||||
"Set a default key for Local KMS in server configuration.": "Définissez une clé par défaut pour le KMS local dans la configuration du serveur.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Une opération est toujours en cours. Attendez qu’elle se termine avant de quitter cette page.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Enregistrez ou abandonnez les modifications de la configuration KMS avant de définir une clé nouvellement créée comme clé par défaut.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Le nouveau nom de l’objet doit être différent",
|
||||
"Object name cannot contain slashes": "Le nom de l’objet ne peut pas contenir de barres obliques",
|
||||
"Object name is required": "Le nom de l’objet est obligatoire",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Situs Saat Ini",
|
||||
"Current User Policy": "Kebijakan Pengguna Saat Ini",
|
||||
"Current Version": "Versi Saat Ini",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Status backend saat ini untuk bucket, pengguna, grup, kebijakan, dan aturan kedaluwarsa siklus hidup yang direplikasi.",
|
||||
"Current site + remote peers": "Situs saat ini + peer jarak jauh",
|
||||
"Current user policy": "Kebijakan pengguna saat ini",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON berhasil diformat",
|
||||
"JSON is already formatted": "JSON sudah diformat",
|
||||
"KMS Backend": "Backend KMS",
|
||||
"Backend and default key": "Backend dan kunci default",
|
||||
"Reliability": "Keandalan",
|
||||
"KMS Cache": "Cache KMS",
|
||||
"Vault connection": "Koneksi Vault",
|
||||
"KMS Configuration": "Konfigurasi KMS",
|
||||
"KMS Key": "KMS Key",
|
||||
"KMS Key Details": "Detail kunci KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "Server KMS berjalan, rincian konfigurasi bersifat pribadi",
|
||||
"KMS server status unknown": "Status server KMS tidak diketahui",
|
||||
"KMS service has errors": "Layanan KMS mengalami kesalahan",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Kunci SSE default tidak dapat dihapus. Pilih kunci default lain terlebih dahulu.",
|
||||
"KMS service is stopped": "Layanan KMS dihentikan",
|
||||
"KMS service not initialized, please configure it first": "Layanan KMS tidak diinisialisasi, harap konfigurasikan terlebih dahulu",
|
||||
"KMS service restarted successfully": "Layanan KMS berhasil dimulai ulang",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Mulai ulang KMS",
|
||||
"Restart Required": "Perlu Restart",
|
||||
"Restore Key": "Pulihkan kunci",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Lanjutkan",
|
||||
"Resync cancel request sent successfully": "Permintaan pembatalan sinkronisasi ulang berhasil dikirim",
|
||||
"Resync request started successfully": "Permintaan sinkronisasi ulang berhasil dimulai",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Situs saat ini terdeteksi otomatis oleh server. Tambahkan satu atau beberapa situs jarak jauh untuk bergabung ke grup replikasi.",
|
||||
"The directory must be an absolute path on the server.": "Direktori harus berupa path absolut di server.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "File yang diekspor berisi informasi sensitif. Harap jaga keamanannya.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "Kunci rahasia hanya ditampilkan sekali. Salin atau ekspor sebelum menutup.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Server mendeteksi identitas situs lokal dari deployment RustFS yang aktif.",
|
||||
"The two passwords are inconsistent": "Kedua kata sandi tidak cocok",
|
||||
"The value is used only for the initial join request.": "Nilai ini hanya digunakan untuk permintaan bergabung awal.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Tindakan ini memensiunkan pool yang dipilih dan hanya boleh digunakan setelah penyeimbangan ulang selesai.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Kunci ini digunakan sebagai kunci SSE default platform untuk SSE-KMS dan SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Kunci ini digunakan sebagai default platform untuk SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Tautan ini akan kedaluwarsa saat sesi Anda berakhir atau pada waktu yang ditentukan.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Menghapus kunci secara permanen segera dan tidak dapat dibatalkan.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Memahami Tipe Kunci",
|
||||
"Unhealthy": "Tidak sehat",
|
||||
"Unknown": "Tidak Diketahui",
|
||||
"Degraded": "Terdegradasi",
|
||||
"Unknown Folder": "Folder Tidak Diketahui",
|
||||
"Unlimited": "Tidak Terbatas",
|
||||
"Unnamed Site": "Situs tanpa nama",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Semua",
|
||||
"All reported servers and disks are online.": "Semua server dan disk yang dilaporkan sedang online.",
|
||||
"Check your connection, then refresh the status page.": "Periksa koneksi, lalu muat ulang halaman status.",
|
||||
"Cluster Health": "Kesehatan klaster",
|
||||
"Cluster information": "Informasi klaster",
|
||||
"Current Scan Started": "Waktu mulai pemindaian saat ini",
|
||||
"Degraded": "Terdegradasi",
|
||||
"Endpoint, ascending": "Endpoint, urut naik",
|
||||
"Endpoint, descending": "Endpoint, urut turun",
|
||||
"Filter servers": "Filter server",
|
||||
"Initializing": "Menginisialisasi",
|
||||
"Inventory": "Inventaris",
|
||||
"Last Completed Scan": "Pemindaian terakhir selesai",
|
||||
"Last refreshed": "Terakhir diperbarui",
|
||||
"Needs attention": "Perlu perhatian",
|
||||
"Needs attention first": "Dahulukan yang perlu perhatian",
|
||||
"No drive information was reported for this server.": "Tidak ada informasi drive untuk server ini.",
|
||||
"No server information was reported.": "Tidak ada informasi server.",
|
||||
"No servers match the current filter": "Tidak ada server yang cocok dengan filter saat ini",
|
||||
"Offline or degraded resources require attention.": "Sumber daya offline atau terdegradasi perlu diperhatikan.",
|
||||
"Parity and backend settings reported by the cluster.": "Pengaturan paritas dan backend yang dilaporkan klaster.",
|
||||
"Refresh to try again.": "Muat ulang untuk mencoba lagi.",
|
||||
"Refreshing…": "Memuat ulang…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Pantau beban kapasitas dan aktivitas pemindai di satu tempat.",
|
||||
"Scan Duration": "Durasi pemindaian",
|
||||
"Scanner metrics": "Metrik pemindai",
|
||||
"Show all servers": "Tampilkan semua server",
|
||||
"Some resource health data is unavailable or still initializing.": "Sebagian data kesehatan sumber daya tidak tersedia atau masih diinisialisasi.",
|
||||
"Some status data is unavailable.": "Sebagian data status tidak tersedia.",
|
||||
"Storage Configuration": "Konfigurasi penyimpanan",
|
||||
"Storage Usage and Scanner": "Penggunaan penyimpanan dan pemindai",
|
||||
"Storage usage is unavailable for this account.": "Data penggunaan penyimpanan tidak tersedia untuk akun ini.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Tidak dapat memuat status klaster. Periksa koneksi dan muat ulang halaman.",
|
||||
"Unavailable": "Tidak tersedia",
|
||||
"Uptime, longest first": "Waktu aktif, terlama dahulu",
|
||||
"Uptime, shortest first": "Waktu aktif, tersingkat dahulu",
|
||||
"Previously loaded values may be stale.": "Nilai yang dimuat sebelumnya mungkin sudah usang.",
|
||||
"Scanner Status": "Status pemindai",
|
||||
"Scanning": "Memindai",
|
||||
"Never run": "Belum pernah dijalankan",
|
||||
"Access & Sharing": "Akses & Berbagi",
|
||||
"Add key-value metadata for organization and automation.": "Tambahkan metadata pasangan kunci-nilai untuk pengelolaan dan otomatisasi.",
|
||||
"Automation": "Otomatisasi",
|
||||
"Bucket Settings": "Pengaturan Bucket",
|
||||
"Capacity & Metadata": "Kapasitas & Metadata",
|
||||
"Configuration changed. Refresh and try again.": "Konfigurasi telah berubah. Muat ulang lalu coba lagi.",
|
||||
"Configuration unavailable": "Konfigurasi tidak tersedia",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Atur pengiriman peristiwa, siklus hidup objek, dan aturan replikasi.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Atur siapa yang dapat mengakses bucket ini dan origin browser yang diizinkan.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Mengatur akses anonim dan terautentikasi ke objek dalam bucket ini.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Mengatur origin web yang dapat mengirim permintaan CORS ke bucket ini.",
|
||||
"Copy objects to another bucket or site.": "Salin objek ke bucket atau situs lain.",
|
||||
"Data Protection": "Perlindungan Data",
|
||||
"Delete Event Subscription": "Hapus Langganan Peristiwa",
|
||||
"Delete Rule": "Hapus Aturan",
|
||||
"Encrypts new objects at rest by default.": "Mengenkripsi objek baru saat disimpan secara default.",
|
||||
"Enter a valid positive quota.": "Masukkan kuota positif yang valid.",
|
||||
"Event subscriptions unavailable": "Langganan peristiwa tidak tersedia",
|
||||
"Expire": "Kedaluwarsa",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Lompat ke bagian",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Simpan versi objek sebelumnya agar dapat dipulihkan setelah ditimpa atau dihapus.",
|
||||
"Lifecycle rules unavailable": "Aturan siklus hidup tidak tersedia",
|
||||
"Limits the total data stored in this bucket.": "Membatasi total data yang disimpan dalam bucket ini.",
|
||||
"Loading": "Memuat",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Kelola enkripsi, pembuatan versi, Object Lock, dan retensi default.",
|
||||
"No event subscriptions": "Tidak ada langganan peristiwa",
|
||||
"No lifecycle rules": "Tidak ada aturan siklus hidup",
|
||||
"No replication rules": "Tidak ada aturan replikasi",
|
||||
"Notify status unavailable": "Status notifikasi tidak tersedia",
|
||||
"Open": "Buka",
|
||||
"Protects object versions from deletion during a retention period.": "Melindungi versi objek agar tidak dihapus selama periode retensi.",
|
||||
"Quota Unit": "Satuan Kuota",
|
||||
"Replication rules unavailable": "Aturan replikasi tidak tersedia",
|
||||
"Requires Object Lock": "Memerlukan Object Lock",
|
||||
"Save CORS": "Simpan CORS",
|
||||
"Save Encryption": "Simpan Enkripsi",
|
||||
"Save Policy": "Simpan Policy",
|
||||
"Save Quota": "Simpan Kuota",
|
||||
"Save Retention": "Simpan Retensi",
|
||||
"Save Tag": "Simpan Tag",
|
||||
"Send notifications when objects change.": "Kirim notifikasi saat objek berubah.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Tetapkan batas penyimpanan dan kelola bucket ini dengan tag.",
|
||||
"Sets the default retention mode and period for new object versions.": "Menetapkan mode dan periode retensi default untuk versi objek baru.",
|
||||
"Tag key already exists": "Kunci tag sudah ada",
|
||||
"The replication target was created but could not be rolled back.": "Target replikasi telah dibuat, tetapi tidak dapat dibatalkan.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "Aturan telah dihapus, tetapi target jarak jauh tidak dapat dihapus.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Langganan ini tidak memiliki ID stabil. Muat ulang sebelum menghapusnya.",
|
||||
"Transition or expire objects automatically.": "Pindahkan kelas penyimpanan atau kedaluwarsakan objek secara otomatis.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Tidak dapat memuat langganan peristiwa. Muat ulang sebelum membuat perubahan.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Tidak dapat memuat aturan siklus hidup. Muat ulang sebelum membuat perubahan.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Tidak dapat memuat aturan replikasi. Muat ulang sebelum membuat perubahan.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Tidak dapat memuat pengaturan ini. Muat ulang sebelum membuat perubahan.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Tidak dapat memastikan apakah notifikasi aktif. Muat ulang sebelum membuat perubahan.",
|
||||
"Unnamed rule": "Aturan tanpa nama",
|
||||
"Unnamed subscription": "Langganan tanpa nama",
|
||||
"Versioning": "Pembuatan Versi",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Pembuatan versi dapat ditangguhkan, tetapi versi yang ada tetap disimpan.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Pembuatan versi tetap aktif selama Object Lock aktif.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "Target jarak jauh mungkin telah disimpan. Tinjau konfigurasi replikasi sebelum mencoba lagi.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "Akses publik dapat mengekspos objek kepada siapa pun yang diizinkan oleh policy. Tinjau sebelum menyimpan.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Menghapus policy bucket. Akses berbasis IAM dan kredensial tetap berlaku.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Menggunakan policy JSON di bawah ini. Pernyataan yang tidak valid dapat memblokir akses.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Menggunakan kunci KMS yang dipilih untuk mengenkripsi objek baru.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Menggunakan kunci yang dikelola server untuk mengenkripsi objek baru.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Retensi tidak dapat dipersingkat atau dilewati hingga berakhir.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Pengguna yang berwenang dapat melewati atau mempersingkat retensi bila diperlukan.",
|
||||
"Unable to load versioning status.": "Tidak dapat memuat status pembuatan versi.",
|
||||
"Unable to load storage tiers.": "Tidak dapat memuat tier penyimpanan.",
|
||||
"Unable to load event targets.": "Tidak dapat memuat target peristiwa.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Mengubah status layanan KMS dapat mengganggu permintaan SSE untuk sementara.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS akan dihentikan. SSE dan pengelolaan kunci tidak akan tersedia hingga Anda memulai kembali KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Anda memiliki perubahan KMS yang belum disimpan. Ingin membuangnya?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "Hasil operasi tidak pasti. Status saat ini telah diperbarui.",
|
||||
"Please enter a Local KMS master key.": "Masukkan kunci utama KMS Lokal.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Masukkan kunci ini setiap kali Anda menyimpan konfigurasi KMS Lokal. Kunci ini tidak pernah ditampilkan atau disimpan.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "Konfigurasi KMS sistem file lokal hanya dapat dibaca di Konsol hingga rotasi kunci utama yang aman tersedia.",
|
||||
"Set a default key for Local KMS in server configuration.": "Tetapkan kunci default untuk KMS Lokal dalam konfigurasi server.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Operasi masih berlangsung. Tunggu hingga selesai sebelum meninggalkan halaman ini.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Simpan atau buang perubahan konfigurasi KMS sebelum menetapkan kunci yang baru dibuat sebagai kunci default.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Nama objek baru harus berbeda",
|
||||
"Object name cannot contain slashes": "Nama objek tidak boleh berisi garis miring",
|
||||
"Object name is required": "Nama objek wajib diisi",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Sito corrente",
|
||||
"Current User Policy": "Politica utente corrente",
|
||||
"Current Version": "Versione corrente",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Stato attuale del backend per bucket, utenti, gruppi, policy e regole di scadenza del ciclo di vita replicati.",
|
||||
"Current site + remote peers": "Sito corrente + peer remoti",
|
||||
"Current user policy": "Politica utente corrente",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON formattato con successo",
|
||||
"JSON is already formatted": "JSON è già formattato",
|
||||
"KMS Backend": "Backend KMS",
|
||||
"Backend and default key": "Backend e chiave predefinita",
|
||||
"Reliability": "Affidabilità",
|
||||
"KMS Cache": "Cache KMS",
|
||||
"Vault connection": "Connessione a Vault",
|
||||
"KMS Configuration": "Configurazione KMS",
|
||||
"KMS Key": "Chiave KMS",
|
||||
"KMS Key Details": "Dettagli chiave KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "Il server KMS è in esecuzione, i dettagli di configurazione sono privati",
|
||||
"KMS server status unknown": "Stato server KMS sconosciuto",
|
||||
"KMS service has errors": "Il servizio KMS ha errori",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Impossibile eliminare la chiave SSE predefinita. Scegli prima un’altra chiave predefinita.",
|
||||
"KMS service is stopped": "Il servizio KMS è fermo",
|
||||
"KMS service not initialized, please configure it first": "Servizio KMS non inizializzato, configuralo prima",
|
||||
"KMS service restarted successfully": "Servizio KMS riavviato correttamente",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Riavvia KMS",
|
||||
"Restart Required": "Riavvio richiesto",
|
||||
"Restore Key": "Ripristina chiave",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Riprendi",
|
||||
"Resync cancel request sent successfully": "Richiesta di annullamento della risincronizzazione inviata con successo",
|
||||
"Resync request started successfully": "Richiesta di risincronizzazione avviata con successo",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Il sito corrente viene rilevato automaticamente dal server. Aggiungi uno o più siti remoti per entrare nel gruppo di replica.",
|
||||
"The directory must be an absolute path on the server.": "La directory deve essere un percorso assoluto sul server.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "Il file esportato contiene informazioni sensibili. Conservalo in modo sicuro.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "La chiave segreta viene mostrata una sola volta. Copiala o esportala prima di chiudere.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Il server rileva l'identità del sito locale dalla distribuzione RustFS attiva.",
|
||||
"The two passwords are inconsistent": "Le due password non corrispondono",
|
||||
"The value is used only for the initial join request.": "Il valore viene usato solo per la richiesta iniziale di adesione.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Questa azione non può essere annullata.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Questa azione ritira il pool selezionato e deve essere usata solo dopo aver verificato il completamento del ribilanciamento.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Questa chiave viene usata come chiave SSE predefinita della piattaforma per SSE-KMS e SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Questa chiave è il predefinito della piattaforma per SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Questo link scadrà quando la sessione terminerà o all'ora specificata.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Elimina definitivamente la chiave immediatamente e non può essere annullato.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Comprensione tipi chiave",
|
||||
"Unhealthy": "Non integro",
|
||||
"Unknown": "Sconosciuto",
|
||||
"Degraded": "Degradato",
|
||||
"Unknown Folder": "Cartella sconosciuta",
|
||||
"Unlimited": "Illimitato",
|
||||
"Unnamed Site": "Sito senza nome",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Tutti",
|
||||
"All reported servers and disks are online.": "Tutti i server e le unità segnalati sono online.",
|
||||
"Check your connection, then refresh the status page.": "Controlla la connessione, quindi aggiorna la pagina di stato.",
|
||||
"Cluster Health": "Stato del cluster",
|
||||
"Cluster information": "Informazioni sul cluster",
|
||||
"Current Scan Started": "Avvio della scansione corrente",
|
||||
"Degraded": "Degradato",
|
||||
"Endpoint, ascending": "Endpoint, crescente",
|
||||
"Endpoint, descending": "Endpoint, decrescente",
|
||||
"Filter servers": "Filtra server",
|
||||
"Initializing": "Inizializzazione",
|
||||
"Inventory": "Inventario",
|
||||
"Last Completed Scan": "Ultima scansione completata",
|
||||
"Last refreshed": "Ultimo aggiornamento",
|
||||
"Needs attention": "Richiede attenzione",
|
||||
"Needs attention first": "Prima quelli che richiedono attenzione",
|
||||
"No drive information was reported for this server.": "Nessuna informazione sulle unità è stata segnalata per questo server.",
|
||||
"No server information was reported.": "Nessuna informazione sui server è stata segnalata.",
|
||||
"No servers match the current filter": "Nessun server corrisponde al filtro corrente",
|
||||
"Offline or degraded resources require attention.": "Le risorse offline o degradate richiedono attenzione.",
|
||||
"Parity and backend settings reported by the cluster.": "Impostazioni di parità e backend segnalate dal cluster.",
|
||||
"Refresh to try again.": "Aggiorna per riprovare.",
|
||||
"Refreshing…": "Aggiornamento…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Controlla la capacità e l’attività dello scanner in un unico punto.",
|
||||
"Scan Duration": "Durata della scansione",
|
||||
"Scanner metrics": "Metriche dello scanner",
|
||||
"Show all servers": "Mostra tutti i server",
|
||||
"Some resource health data is unavailable or still initializing.": "Alcuni dati sullo stato delle risorse non sono disponibili o sono ancora in fase di inizializzazione.",
|
||||
"Some status data is unavailable.": "Alcuni dati di stato non sono disponibili.",
|
||||
"Storage Configuration": "Configurazione dello storage",
|
||||
"Storage Usage and Scanner": "Utilizzo dello storage e scanner",
|
||||
"Storage usage is unavailable for this account.": "L’utilizzo dello storage non è disponibile per questo account.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Impossibile caricare lo stato del cluster. Controlla la connessione e aggiorna.",
|
||||
"Unavailable": "Non disponibile",
|
||||
"Uptime, longest first": "Tempo di attività, più lungo prima",
|
||||
"Uptime, shortest first": "Tempo di attività, più breve prima",
|
||||
"Previously loaded values may be stale.": "I valori caricati in precedenza potrebbero non essere aggiornati.",
|
||||
"Scanner Status": "Stato dello scanner",
|
||||
"Scanning": "Scansione in corso",
|
||||
"Never run": "Mai eseguito",
|
||||
"Access & Sharing": "Accesso e condivisione",
|
||||
"Add key-value metadata for organization and automation.": "Aggiungi metadati chiave-valore per l’organizzazione e l’automazione.",
|
||||
"Automation": "Automazione",
|
||||
"Bucket Settings": "Impostazioni del bucket",
|
||||
"Capacity & Metadata": "Capacità e metadati",
|
||||
"Configuration changed. Refresh and try again.": "La configurazione è cambiata. Aggiorna la pagina e riprova.",
|
||||
"Configuration unavailable": "Configurazione non disponibile",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Configura la distribuzione degli eventi, il ciclo di vita degli oggetti e le regole di replica.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Controlla chi può accedere a questo bucket e quali origini del browser sono consentite.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Controlla l’accesso anonimo e autenticato agli oggetti in questo bucket.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Controlla quali origini web possono inviare richieste cross-origin a questo bucket.",
|
||||
"Copy objects to another bucket or site.": "Copia gli oggetti in un altro bucket o sito.",
|
||||
"Data Protection": "Protezione dei dati",
|
||||
"Delete Event Subscription": "Elimina sottoscrizione agli eventi",
|
||||
"Delete Rule": "Elimina regola",
|
||||
"Encrypts new objects at rest by default.": "Crittografa per impostazione predefinita i nuovi oggetti inattivi.",
|
||||
"Enter a valid positive quota.": "Inserisci una quota positiva valida.",
|
||||
"Event subscriptions unavailable": "Sottoscrizioni agli eventi non disponibili",
|
||||
"Expire": "Fai scadere",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Vai alla sezione",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Conserva le versioni precedenti degli oggetti e consente il ripristino dopo sovrascritture o eliminazioni.",
|
||||
"Lifecycle rules unavailable": "Regole del ciclo di vita non disponibili",
|
||||
"Limits the total data stored in this bucket.": "Limita la quantità totale di dati archiviati in questo bucket.",
|
||||
"Loading": "Caricamento",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Gestisci crittografia, controllo delle versioni, Object Lock e conservazione predefinita.",
|
||||
"No event subscriptions": "Nessuna sottoscrizione agli eventi",
|
||||
"No lifecycle rules": "Nessuna regola del ciclo di vita",
|
||||
"No replication rules": "Nessuna regola di replica",
|
||||
"Notify status unavailable": "Stato delle notifiche non disponibile",
|
||||
"Open": "Apri",
|
||||
"Protects object versions from deletion during a retention period.": "Protegge le versioni degli oggetti dall’eliminazione durante un periodo di conservazione.",
|
||||
"Quota Unit": "Unità della quota",
|
||||
"Replication rules unavailable": "Regole di replica non disponibili",
|
||||
"Requires Object Lock": "Richiede Object Lock",
|
||||
"Save CORS": "Salva CORS",
|
||||
"Save Encryption": "Salva crittografia",
|
||||
"Save Policy": "Salva policy",
|
||||
"Save Quota": "Salva quota",
|
||||
"Save Retention": "Salva conservazione",
|
||||
"Save Tag": "Salva tag",
|
||||
"Send notifications when objects change.": "Invia notifiche quando gli oggetti cambiano.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Imposta un limite di archiviazione e organizza questo bucket con i tag.",
|
||||
"Sets the default retention mode and period for new object versions.": "Imposta la modalità e il periodo di conservazione predefiniti per le nuove versioni degli oggetti.",
|
||||
"Tag key already exists": "La chiave del tag esiste già",
|
||||
"The replication target was created but could not be rolled back.": "La destinazione di replica è stata creata, ma non è stato possibile annullare l’operazione.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "La regola è stata eliminata, ma non è stato possibile rimuovere la destinazione remota.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Questa sottoscrizione non ha un ID stabile. Aggiorna la pagina prima di eliminarla.",
|
||||
"Transition or expire objects automatically.": "Sposta automaticamente gli oggetti in un’altra classe di archiviazione o falli scadere.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Impossibile caricare le sottoscrizioni agli eventi. Aggiorna la pagina prima di apportare modifiche.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Impossibile caricare le regole del ciclo di vita. Aggiorna la pagina prima di apportare modifiche.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Impossibile caricare le regole di replica. Aggiorna la pagina prima di apportare modifiche.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Impossibile caricare questa impostazione. Aggiorna la pagina prima di apportare modifiche.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Impossibile verificare se le notifiche sono abilitate. Aggiorna la pagina prima di apportare modifiche.",
|
||||
"Unnamed rule": "Regola senza nome",
|
||||
"Unnamed subscription": "Sottoscrizione senza nome",
|
||||
"Versioning": "Controllo delle versioni",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Il controllo delle versioni può essere sospeso, ma le versioni esistenti vengono conservate.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Il controllo delle versioni rimane attivo finché Object Lock è attivo.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "La destinazione remota potrebbe essere stata salvata. Controlla la configurazione della replica prima di riprovare.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "L’accesso pubblico può esporre gli oggetti a chiunque sia autorizzato dalla policy. Controllala prima di salvare.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Rimuove la policy del bucket. L’accesso tramite IAM e credenziali rimane valido.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Utilizza la policy JSON seguente. Le istruzioni non valide possono bloccare l’accesso.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Utilizza la chiave KMS selezionata per crittografare i nuovi oggetti.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Utilizza chiavi gestite dal server per crittografare i nuovi oggetti.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Il periodo di conservazione non può essere ridotto né aggirato prima della scadenza.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Gli utenti autorizzati possono aggirare o ridurre il periodo di conservazione quando necessario.",
|
||||
"Unable to load versioning status.": "Impossibile caricare lo stato del controllo delle versioni.",
|
||||
"Unable to load storage tiers.": "Impossibile caricare i livelli di archiviazione.",
|
||||
"Unable to load event targets.": "Impossibile caricare le destinazioni degli eventi.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "La modifica dello stato del servizio KMS può interrompere brevemente le richieste SSE.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS verrà arrestato. SSE e la gestione delle chiavi non saranno disponibili finché non riavvii KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Hai modifiche KMS non salvate. Vuoi annullarle?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "Il risultato dell’operazione è incerto. Lo stato attuale è stato aggiornato.",
|
||||
"Please enter a Local KMS master key.": "Inserisci una chiave master KMS locale.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Inseriscila ogni volta che salvi la configurazione KMS locale. Non viene mai mostrata né conservata.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "La configurazione KMS del file system locale è di sola lettura nella console finché non sarà disponibile la rotazione sicura della chiave master.",
|
||||
"Set a default key for Local KMS in server configuration.": "Imposta una chiave predefinita per KMS locale nella configurazione del server.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "È ancora in corso un’operazione. Attendi che termini prima di lasciare questa pagina.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Salva o annulla le modifiche alla configurazione KMS prima di impostare come predefinita una chiave appena creata.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Il nuovo nome dell’oggetto deve essere diverso",
|
||||
"Object name cannot contain slashes": "Il nome dell’oggetto non può contenere barre",
|
||||
"Object name is required": "Il nome dell’oggetto è obbligatorio",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "現在のサイト",
|
||||
"Current User Policy": "現在のユーザーポリシー",
|
||||
"Current Version": "現在のバージョン",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "複製対象のバケット、ユーザー、グループ、ポリシー、ライフサイクル期限切れルールの現在のバックエンド状態。",
|
||||
"Current site + remote peers": "現在のサイト + リモートピア",
|
||||
"Current user policy": "現在のユーザーポリシー",
|
||||
@@ -926,10 +925,6 @@
|
||||
"Restart KMS": "KMS を再起動",
|
||||
"Restart Required": "再起動が必要",
|
||||
"Restore Key": "キーを復元",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "再開",
|
||||
"Resync cancel request sent successfully": "再同期のキャンセルリクエストを正常に送信しました",
|
||||
"Resync request started successfully": "再同期リクエストを正常に開始しました",
|
||||
@@ -1131,6 +1126,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "現在のサイトはサーバーによって自動検出されます。レプリケーショングループに参加するには、1 つ以上のリモートサイトを追加してください。",
|
||||
"The directory must be an absolute path on the server.": "ディレクトリはサーバー上の絶対パスである必要があります。",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "エクスポートされたファイルには機密情報が含まれています。安全に保管してください。",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "シークレットキーが表示されるのは一度だけです。閉じる前にコピーまたはエクスポートしてください。",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "サーバーはアクティブな RustFS デプロイメントからローカルサイトの識別情報を検出します。",
|
||||
"The two passwords are inconsistent": "2つのパスワードが一致しません",
|
||||
"The value is used only for the initial join request.": "この値は初回参加リクエストでのみ使用されます。",
|
||||
@@ -1138,7 +1134,6 @@
|
||||
"This action cannot be undone.": "この操作は元に戻せません。",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "この操作は選択したプールを廃止します。リバランス完了を確認した後でのみ実行してください。",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "このキーは、SSE-KMS および SSE-S3 用のプラットフォーム既定の SSE キーとして使用されます。",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "このキーはプラットフォームの SSE-KMS デフォルトとして使用されます。",
|
||||
"This link will expire when your session ends or at the specified time.": "このリンクはセッション終了時、または指定した時刻に期限切れになります。",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "キーをすぐに完全削除します。元に戻せません。",
|
||||
@@ -1182,7 +1177,6 @@
|
||||
"Understanding Key Types": "キータイプの理解",
|
||||
"Unhealthy": "異常",
|
||||
"Unknown": "不明",
|
||||
"Degraded": "劣化",
|
||||
"Unknown Folder": "不明なフォルダ",
|
||||
"Unlimited": "無制限",
|
||||
"Unnamed Site": "無名のサイト",
|
||||
@@ -1354,6 +1348,139 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "すべて",
|
||||
"All reported servers and disks are online.": "報告されたすべてのサーバーとディスクはオンラインです。",
|
||||
"Check your connection, then refresh the status page.": "接続を確認して、ステータスページを更新してください。",
|
||||
"Cluster Health": "クラスターの状態",
|
||||
"Cluster information": "クラスター情報",
|
||||
"Current Scan Started": "現在のスキャン開始時刻",
|
||||
"Degraded": "劣化",
|
||||
"Endpoint, ascending": "エンドポイント(昇順)",
|
||||
"Endpoint, descending": "エンドポイント(降順)",
|
||||
"Filter servers": "サーバーを絞り込む",
|
||||
"Initializing": "初期化中",
|
||||
"Inventory": "インベントリ",
|
||||
"Last Completed Scan": "前回のスキャン完了時刻",
|
||||
"Last refreshed": "最終更新",
|
||||
"Needs attention": "要確認",
|
||||
"Needs attention first": "要確認を優先",
|
||||
"No drive information was reported for this server.": "このサーバーのドライブ情報は報告されていません。",
|
||||
"No server information was reported.": "サーバー情報は報告されていません。",
|
||||
"No servers match the current filter": "現在の条件に一致するサーバーはありません",
|
||||
"Offline or degraded resources require attention.": "オフラインまたは縮退中のリソースを確認してください。",
|
||||
"Parity and backend settings reported by the cluster.": "クラスターから報告されたパリティとバックエンド設定です。",
|
||||
"Refresh to try again.": "更新して再試行してください。",
|
||||
"Refreshing…": "更新中…",
|
||||
"Review capacity pressure and scanner activity in one place.": "容量不足の兆候とスキャナーの稼働状況をまとめて確認できます。",
|
||||
"Scan Duration": "スキャン時間",
|
||||
"Scanner metrics": "スキャナーメトリクス",
|
||||
"Show all servers": "すべてのサーバーを表示",
|
||||
"Some resource health data is unavailable or still initializing.": "一部のリソースの状態データを取得できないか、初期化中です。",
|
||||
"Some status data is unavailable.": "一部のステータスデータを取得できません。",
|
||||
"Storage Configuration": "ストレージ構成",
|
||||
"Storage Usage and Scanner": "ストレージ使用量とスキャナー",
|
||||
"Storage usage is unavailable for this account.": "このアカウントではストレージ使用量を取得できません。",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "クラスターの状態を読み込めません。接続を確認して更新してください。",
|
||||
"Unavailable": "利用不可",
|
||||
"Uptime, longest first": "稼働時間(長い順)",
|
||||
"Uptime, shortest first": "稼働時間(短い順)",
|
||||
"Previously loaded values may be stale.": "以前読み込んだ値は古い可能性があります。",
|
||||
"Scanner Status": "スキャナーの状態",
|
||||
"Scanning": "スキャン中",
|
||||
"Never run": "実行履歴なし",
|
||||
"Access & Sharing": "アクセスと共有",
|
||||
"Add key-value metadata for organization and automation.": "整理や自動化に使うキーと値のメタデータを追加します。",
|
||||
"Automation": "自動化",
|
||||
"Bucket Settings": "バケット設定",
|
||||
"Capacity & Metadata": "容量とメタデータ",
|
||||
"Configuration changed. Refresh and try again.": "設定が変更されました。更新してもう一度お試しください。",
|
||||
"Configuration unavailable": "設定を取得できません",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "イベント通知、オブジェクトのライフサイクル、レプリケーションルールを設定します。",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "このバケットにアクセスできるユーザーと、許可するブラウザーオリジンを管理します。",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "このバケット内のオブジェクトへの匿名および認証済みアクセスを管理します。",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "このバケットにクロスオリジンリクエストを送信できるWebオリジンを管理します。",
|
||||
"Copy objects to another bucket or site.": "オブジェクトを別のバケットまたはサイトにコピーします。",
|
||||
"Data Protection": "データ保護",
|
||||
"Delete Event Subscription": "イベント通知を削除",
|
||||
"Delete Rule": "ルールを削除",
|
||||
"Encrypts new objects at rest by default.": "新しいオブジェクトを保存時に既定で暗号化します。",
|
||||
"Enter a valid positive quota.": "正のクォータ値を入力してください。",
|
||||
"Event subscriptions unavailable": "イベント通知を取得できません",
|
||||
"Expire": "期限切れ",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "セクションへ移動",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "オブジェクトの以前のバージョンを保持し、上書きや削除から復元できるようにします。",
|
||||
"Lifecycle rules unavailable": "ライフサイクルルールを取得できません",
|
||||
"Limits the total data stored in this bucket.": "このバケットに保存できるデータの合計量を制限します。",
|
||||
"Loading": "読み込み中",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "暗号化、バージョニング、オブジェクトロック、既定の保持期間を管理します。",
|
||||
"No event subscriptions": "イベント通知はありません",
|
||||
"No lifecycle rules": "ライフサイクルルールはありません",
|
||||
"No replication rules": "レプリケーションルールはありません",
|
||||
"Notify status unavailable": "通知状態を取得できません",
|
||||
"Open": "開く",
|
||||
"Protects object versions from deletion during a retention period.": "保持期間中、オブジェクトバージョンが削除されないよう保護します。",
|
||||
"Quota Unit": "クォータの単位",
|
||||
"Replication rules unavailable": "レプリケーションルールを取得できません",
|
||||
"Requires Object Lock": "オブジェクトロックが必要",
|
||||
"Save CORS": "CORSを保存",
|
||||
"Save Encryption": "暗号化設定を保存",
|
||||
"Save Policy": "ポリシーを保存",
|
||||
"Save Quota": "クォータを保存",
|
||||
"Save Retention": "保持設定を保存",
|
||||
"Save Tag": "タグを保存",
|
||||
"Send notifications when objects change.": "オブジェクトが変更されたときに通知を送信します。",
|
||||
"Set a storage limit and organize this bucket with tags.": "ストレージ上限を設定し、タグでこのバケットを整理します。",
|
||||
"Sets the default retention mode and period for new object versions.": "新しいオブジェクトバージョンに適用する既定の保持モードと期間を設定します。",
|
||||
"Tag key already exists": "このタグキーはすでに存在します",
|
||||
"The replication target was created but could not be rolled back.": "レプリケーション先は作成されましたが、ロールバックできませんでした。",
|
||||
"The rule was deleted, but the remote target could not be removed.": "ルールは削除されましたが、リモートターゲットを削除できませんでした。",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "この通知には安定したIDがありません。削除する前に更新してください。",
|
||||
"Transition or expire objects automatically.": "オブジェクトを自動的に移行または期限切れにします。",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "イベント通知を読み込めません。変更する前に更新してください。",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "ライフサイクルルールを読み込めません。変更する前に更新してください。",
|
||||
"Unable to load replication rules. Refresh before making changes.": "レプリケーションルールを読み込めません。変更する前に更新してください。",
|
||||
"Unable to load this setting. Refresh before making changes.": "この設定を読み込めません。変更する前に更新してください。",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "通知が有効か確認できません。変更する前に更新してください。",
|
||||
"Unnamed rule": "名前のないルール",
|
||||
"Unnamed subscription": "名前のない通知",
|
||||
"Versioning": "バージョニング",
|
||||
"Versioning can be suspended but existing versions are preserved.": "バージョニングは一時停止できますが、既存のバージョンは保持されます。",
|
||||
"Versioning stays enabled while Object Lock is active.": "オブジェクトロックが有効な間、バージョニングは有効のままです。",
|
||||
"bucket": "バケット",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "リモートターゲットが保存されている可能性があります。再試行する前にレプリケーション設定を確認してください。",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "パブリックアクセスでは、ポリシーで許可されたすべてのユーザーにオブジェクトが公開される可能性があります。保存前に内容を確認してください。",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "バケットポリシーを削除します。IAMおよび認証情報によるアクセスは引き続き適用されます。",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "以下のJSONポリシーを使用します。無効なステートメントによりアクセスできなくなる可能性があります。",
|
||||
"Uses the selected KMS key to encrypt new objects.": "選択したKMSキーで新しいオブジェクトを暗号化します。",
|
||||
"Uses server-managed keys to encrypt new objects.": "サーバー管理のキーで新しいオブジェクトを暗号化します。",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "保持期間は、期限が切れるまで短縮または回避できません。",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "権限のあるユーザーは、必要に応じて保持期間を短縮または回避できます。",
|
||||
"Unable to load versioning status.": "バージョニングの状態を読み込めません。",
|
||||
"Unable to load storage tiers.": "ストレージ階層を読み込めません。",
|
||||
"Unable to load event targets.": "イベントターゲットを読み込めません。",
|
||||
"Backend and default key": "バックエンドとデフォルトキー",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "デフォルト SSE キーは削除できません。先に別のキーをデフォルトに設定してください。",
|
||||
"KMS Cache": "KMS キャッシュ",
|
||||
"Reliability": "信頼性",
|
||||
"Vault connection": "Vault 接続",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "KMS サービスの状態を変更すると、SSE リクエストが一時的に中断される場合があります。",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS を停止します。KMS を再起動するまで、SSE とキー管理は利用できません。",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "KMS に未保存の変更があります。破棄しますか?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "操作結果を確認できません。現在のステータスを更新しました。",
|
||||
"Please enter a Local KMS master key.": "ローカル KMS のマスターキーを入力してください。",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "ローカル KMS の設定を保存するたびに入力してください。このキーは表示も保持もされません。",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "安全なマスターキーローテーションが利用可能になるまで、コンソールではローカルファイルシステム KMS の設定は読み取り専用です。",
|
||||
"Set a default key for Local KMS in server configuration.": "サーバー設定でローカル KMS のデフォルトキーを設定してください。",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "操作が進行中です。完了するまで待ってから、このページを離れてください。",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "新しく作成したキーをデフォルトに設定する前に、KMS 設定の変更を保存または破棄してください。",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "新しいオブジェクト名は異なる必要があります",
|
||||
"Object name cannot contain slashes": "オブジェクト名にスラッシュは使用できません",
|
||||
"Object name is required": "オブジェクト名は必須です",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "현재 사이트",
|
||||
"Current User Policy": "현재 사용자 정책",
|
||||
"Current Version": "현재 버전",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "복제된 버킷, 사용자, 그룹, 정책 및 수명 주기 만료 규칙의 현재 백엔드 상태입니다.",
|
||||
"Current site + remote peers": "현재 사이트 + 원격 피어",
|
||||
"Current user policy": "현재 사용자 정책",
|
||||
@@ -926,10 +925,6 @@
|
||||
"Restart KMS": "KMS 재시작",
|
||||
"Restart Required": "재시작 필요",
|
||||
"Restore Key": "키 복원",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "재개",
|
||||
"Resync cancel request sent successfully": "재동기화 취소 요청을 성공적으로 전송했습니다",
|
||||
"Resync request started successfully": "재동기화 요청을 성공적으로 시작했습니다",
|
||||
@@ -1131,6 +1126,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "현재 사이트는 서버에서 자동으로 감지됩니다. 복제 그룹에 참여하려면 하나 이상의 원격 사이트를 추가하세요.",
|
||||
"The directory must be an absolute path on the server.": "디렉터리는 서버의 절대 경로여야 합니다.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "내보낸 파일에 민감한 정보가 포함되어 있습니다. 안전하게 보관하세요.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "비밀 키는 한 번만 표시됩니다. 닫기 전에 복사하거나 내보내세요.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "서버는 활성 RustFS 배포에서 로컬 사이트 식별 정보를 감지합니다.",
|
||||
"The two passwords are inconsistent": "두 비밀번호가 일치하지 않습니다",
|
||||
"The value is used only for the initial join request.": "이 값은 초기 참여 요청에만 사용됩니다.",
|
||||
@@ -1138,7 +1134,6 @@
|
||||
"This action cannot be undone.": "이 작업은 실행 취소할 수 없습니다.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "이 작업은 선택한 풀을 폐기합니다. 리밸런스가 완료된 것을 확인한 후에만 사용하세요.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "이 키는 SSE-KMS 및 SSE-S3에 대한 플랫폼 기본 SSE 키로 사용됩니다.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "이 키는 플랫폼의 SSE-KMS 기본값으로 사용됩니다.",
|
||||
"This link will expire when your session ends or at the specified time.": "이 링크는 세션이 종료되거나 지정한 시각에 만료됩니다.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "키를 즉시 영구 삭제하며 되돌릴 수 없습니다.",
|
||||
@@ -1182,7 +1177,6 @@
|
||||
"Understanding Key Types": "키 유형 이해",
|
||||
"Unhealthy": "비정상",
|
||||
"Unknown": "알 수 없음",
|
||||
"Degraded": "성능 저하",
|
||||
"Unknown Folder": "알 수 없는 폴더",
|
||||
"Unlimited": "무제한",
|
||||
"Unnamed Site": "이름 없는 사이트",
|
||||
@@ -1354,6 +1348,139 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "전체",
|
||||
"All reported servers and disks are online.": "보고된 모든 서버와 디스크가 온라인 상태입니다.",
|
||||
"Check your connection, then refresh the status page.": "연결을 확인한 후 상태 페이지를 새로고침하세요.",
|
||||
"Cluster Health": "클러스터 상태",
|
||||
"Cluster information": "클러스터 정보",
|
||||
"Current Scan Started": "현재 스캔 시작 시각",
|
||||
"Degraded": "성능 저하",
|
||||
"Endpoint, ascending": "엔드포인트(오름차순)",
|
||||
"Endpoint, descending": "엔드포인트(내림차순)",
|
||||
"Filter servers": "서버 필터링",
|
||||
"Initializing": "초기화 중",
|
||||
"Inventory": "인벤토리",
|
||||
"Last Completed Scan": "마지막 스캔 완료 시각",
|
||||
"Last refreshed": "마지막 새로고침",
|
||||
"Needs attention": "확인 필요",
|
||||
"Needs attention first": "확인 필요 항목 우선",
|
||||
"No drive information was reported for this server.": "이 서버에서 보고된 드라이브 정보가 없습니다.",
|
||||
"No server information was reported.": "보고된 서버 정보가 없습니다.",
|
||||
"No servers match the current filter": "현재 필터와 일치하는 서버가 없습니다",
|
||||
"Offline or degraded resources require attention.": "오프라인이거나 상태가 저하된 리소스를 확인해야 합니다.",
|
||||
"Parity and backend settings reported by the cluster.": "클러스터에서 보고한 패리티 및 백엔드 설정입니다.",
|
||||
"Refresh to try again.": "새로고침하여 다시 시도하세요.",
|
||||
"Refreshing…": "새로고침 중…",
|
||||
"Review capacity pressure and scanner activity in one place.": "용량 부족 위험과 스캐너 활동을 한곳에서 확인하세요.",
|
||||
"Scan Duration": "스캔 소요 시간",
|
||||
"Scanner metrics": "스캐너 지표",
|
||||
"Show all servers": "모든 서버 표시",
|
||||
"Some resource health data is unavailable or still initializing.": "일부 리소스 상태 데이터를 사용할 수 없거나 아직 초기화 중입니다.",
|
||||
"Some status data is unavailable.": "일부 상태 데이터를 사용할 수 없습니다.",
|
||||
"Storage Configuration": "스토리지 구성",
|
||||
"Storage Usage and Scanner": "스토리지 사용량 및 스캐너",
|
||||
"Storage usage is unavailable for this account.": "이 계정에서는 스토리지 사용량을 확인할 수 없습니다.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "클러스터 상태를 불러올 수 없습니다. 연결을 확인하고 새로고침하세요.",
|
||||
"Unavailable": "사용 불가",
|
||||
"Uptime, longest first": "가동 시간(긴 순)",
|
||||
"Uptime, shortest first": "가동 시간(짧은 순)",
|
||||
"Previously loaded values may be stale.": "이전에 불러온 값은 최신 상태가 아닐 수 있습니다.",
|
||||
"Scanner Status": "스캐너 상태",
|
||||
"Scanning": "스캔 중",
|
||||
"Never run": "실행 기록 없음",
|
||||
"Access & Sharing": "액세스 및 공유",
|
||||
"Add key-value metadata for organization and automation.": "정리와 자동화를 위한 키-값 메타데이터를 추가합니다.",
|
||||
"Automation": "자동화",
|
||||
"Bucket Settings": "버킷 설정",
|
||||
"Capacity & Metadata": "용량 및 메타데이터",
|
||||
"Configuration changed. Refresh and try again.": "구성이 변경되었습니다. 새로고침 후 다시 시도하세요.",
|
||||
"Configuration unavailable": "구성을 확인할 수 없음",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "이벤트 전송, 객체 수명 주기, 복제 규칙을 구성합니다.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "이 버킷에 액세스할 수 있는 사용자와 허용할 브라우저 원본을 관리합니다.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "이 버킷의 객체에 대한 익명 및 인증된 액세스를 관리합니다.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "이 버킷으로 교차 출처 요청을 보낼 수 있는 웹 출처를 관리합니다.",
|
||||
"Copy objects to another bucket or site.": "객체를 다른 버킷이나 사이트에 복사합니다.",
|
||||
"Data Protection": "데이터 보호",
|
||||
"Delete Event Subscription": "이벤트 구독 삭제",
|
||||
"Delete Rule": "규칙 삭제",
|
||||
"Encrypts new objects at rest by default.": "새 객체를 기본적으로 저장 시 암호화합니다.",
|
||||
"Enter a valid positive quota.": "0보다 큰 할당량을 입력하세요.",
|
||||
"Event subscriptions unavailable": "이벤트 구독을 확인할 수 없음",
|
||||
"Expire": "만료",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "섹션으로 이동",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "이전 객체 버전을 보존하고 덮어쓰기나 삭제 후 복구할 수 있습니다.",
|
||||
"Lifecycle rules unavailable": "수명 주기 규칙을 확인할 수 없음",
|
||||
"Limits the total data stored in this bucket.": "이 버킷에 저장할 수 있는 전체 데이터양을 제한합니다.",
|
||||
"Loading": "불러오는 중",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "암호화, 버전 관리, 객체 잠금, 기본 보존 정책을 관리합니다.",
|
||||
"No event subscriptions": "이벤트 구독 없음",
|
||||
"No lifecycle rules": "수명 주기 규칙 없음",
|
||||
"No replication rules": "복제 규칙 없음",
|
||||
"Notify status unavailable": "알림 상태를 확인할 수 없음",
|
||||
"Open": "열기",
|
||||
"Protects object versions from deletion during a retention period.": "보존 기간 동안 객체 버전이 삭제되지 않도록 보호합니다.",
|
||||
"Quota Unit": "할당량 단위",
|
||||
"Replication rules unavailable": "복제 규칙을 확인할 수 없음",
|
||||
"Requires Object Lock": "객체 잠금 필요",
|
||||
"Save CORS": "CORS 저장",
|
||||
"Save Encryption": "암호화 설정 저장",
|
||||
"Save Policy": "정책 저장",
|
||||
"Save Quota": "할당량 저장",
|
||||
"Save Retention": "보존 설정 저장",
|
||||
"Save Tag": "태그 저장",
|
||||
"Send notifications when objects change.": "객체가 변경될 때 알림을 보냅니다.",
|
||||
"Set a storage limit and organize this bucket with tags.": "스토리지 한도를 설정하고 태그로 이 버킷을 정리합니다.",
|
||||
"Sets the default retention mode and period for new object versions.": "새 객체 버전에 적용할 기본 보존 모드와 기간을 설정합니다.",
|
||||
"Tag key already exists": "태그 키가 이미 있습니다",
|
||||
"The replication target was created but could not be rolled back.": "복제 대상이 생성되었지만 롤백하지 못했습니다.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "규칙은 삭제되었지만 원격 대상을 제거하지 못했습니다.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "이 구독에는 안정적인 ID가 없습니다. 삭제하기 전에 새로고침하세요.",
|
||||
"Transition or expire objects automatically.": "객체를 자동으로 전환하거나 만료합니다.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "이벤트 구독을 불러올 수 없습니다. 변경하기 전에 새로고침하세요.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "수명 주기 규칙을 불러올 수 없습니다. 변경하기 전에 새로고침하세요.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "복제 규칙을 불러올 수 없습니다. 변경하기 전에 새로고침하세요.",
|
||||
"Unable to load this setting. Refresh before making changes.": "이 설정을 불러올 수 없습니다. 변경하기 전에 새로고침하세요.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "알림 사용 여부를 확인할 수 없습니다. 변경하기 전에 새로고침하세요.",
|
||||
"Unnamed rule": "이름 없는 규칙",
|
||||
"Unnamed subscription": "이름 없는 구독",
|
||||
"Versioning": "버전 관리",
|
||||
"Versioning can be suspended but existing versions are preserved.": "버전 관리는 일시 중지할 수 있지만 기존 버전은 유지됩니다.",
|
||||
"Versioning stays enabled while Object Lock is active.": "객체 잠금이 활성화된 동안에는 버전 관리도 활성화 상태로 유지됩니다.",
|
||||
"bucket": "버킷",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "원격 대상이 저장되었을 수 있습니다. 다시 시도하기 전에 복제 구성을 확인하세요.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "공개 액세스를 사용하면 정책에서 허용된 모든 사용자에게 객체가 노출될 수 있습니다. 저장하기 전에 검토하세요.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "버킷 정책을 제거합니다. IAM 및 자격 증명 기반 액세스는 계속 적용됩니다.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "아래 JSON 정책을 사용합니다. 잘못된 문으로 인해 액세스가 차단될 수 있습니다.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "선택한 KMS 키로 새 객체를 암호화합니다.",
|
||||
"Uses server-managed keys to encrypt new objects.": "서버 관리 키로 새 객체를 암호화합니다.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "보존 기간은 만료될 때까지 단축하거나 우회할 수 없습니다.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "권한이 있는 사용자는 필요할 때 보존 기간을 단축하거나 우회할 수 있습니다.",
|
||||
"Unable to load versioning status.": "버전 관리 상태를 불러올 수 없습니다.",
|
||||
"Unable to load storage tiers.": "스토리지 계층을 불러올 수 없습니다.",
|
||||
"Unable to load event targets.": "이벤트 대상을 불러올 수 없습니다.",
|
||||
"Backend and default key": "백엔드 및 기본 키",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "기본 SSE 키는 삭제할 수 없습니다. 먼저 다른 키를 기본 키로 설정하세요.",
|
||||
"KMS Cache": "KMS 캐시",
|
||||
"Reliability": "안정성",
|
||||
"Vault connection": "Vault 연결",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "KMS 서비스 상태를 변경하면 SSE 요청이 일시적으로 중단될 수 있습니다.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS가 중지됩니다. KMS를 다시 시작할 때까지 SSE 및 키 관리를 사용할 수 없습니다.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "저장하지 않은 KMS 변경 사항이 있습니다. 변경 사항을 취소하시겠습니까?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "작업 결과가 불확실합니다. 현재 상태를 새로 고쳤습니다.",
|
||||
"Please enter a Local KMS master key.": "로컬 KMS 마스터 키를 입력하세요.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "로컬 KMS 구성을 저장할 때마다 입력하세요. 이 키는 표시되거나 보관되지 않습니다.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "안전한 마스터 키 교체 기능이 제공될 때까지 콘솔에서 로컬 파일 시스템 KMS 구성은 읽기 전용입니다.",
|
||||
"Set a default key for Local KMS in server configuration.": "서버 구성에서 로컬 KMS의 기본 키를 설정하세요.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "작업이 아직 진행 중입니다. 완료될 때까지 기다린 후 이 페이지를 나가세요.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "새로 만든 키를 기본 키로 설정하기 전에 KMS 구성 변경 사항을 저장하거나 취소하세요.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "새 객체 이름은 달라야 합니다",
|
||||
"Object name cannot contain slashes": "객체 이름에는 슬래시를 포함할 수 없습니다",
|
||||
"Object name is required": "객체 이름은 필수입니다",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Site Atual",
|
||||
"Current User Policy": "Política de Usuário Atual",
|
||||
"Current Version": "Versão Atual",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Status atual do backend para buckets, usuários, grupos, políticas e regras de expiração do ciclo de vida replicados.",
|
||||
"Current site + remote peers": "Site atual + pares remotos",
|
||||
"Current user policy": "Política de usuário atual",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON formatado com sucesso",
|
||||
"JSON is already formatted": "JSON já está formatado",
|
||||
"KMS Backend": "Backend KMS",
|
||||
"Backend and default key": "Backend e chave padrão",
|
||||
"Reliability": "Confiabilidade",
|
||||
"KMS Cache": "Cache do KMS",
|
||||
"Vault connection": "Conexão com o Vault",
|
||||
"KMS Configuration": "Configuração do KMS",
|
||||
"KMS Key": "Chave KMS",
|
||||
"KMS Key Details": "Detalhes da chave KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "O servidor KMS está em execução, detalhes de configuração são privados",
|
||||
"KMS server status unknown": "Status do servidor KMS desconhecido",
|
||||
"KMS service has errors": "O serviço KMS tem erros",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Não é possível excluir a chave SSE padrão. Escolha outra chave padrão primeiro.",
|
||||
"KMS service is stopped": "O serviço KMS está parado",
|
||||
"KMS service not initialized, please configure it first": "Serviço KMS não inicializado, configure primeiro",
|
||||
"KMS service restarted successfully": "Serviço KMS reiniciado com sucesso",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Reiniciar KMS",
|
||||
"Restart Required": "Reinicialização necessária",
|
||||
"Restore Key": "Restaurar chave",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Retomar",
|
||||
"Resync cancel request sent successfully": "Solicitação de cancelamento da resincronização enviada com sucesso",
|
||||
"Resync request started successfully": "Solicitação de resincronização iniciada com sucesso",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "O site atual é detectado automaticamente pelo servidor. Adicione um ou mais sites remotos para entrar no grupo de replicação.",
|
||||
"The directory must be an absolute path on the server.": "O diretório deve ser um caminho absoluto no servidor.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "O arquivo exportado contém informações sensíveis. Mantenha-o seguro.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "A chave secreta é exibida apenas uma vez. Copie-a ou exporte-a antes de fechar.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "O servidor detecta a identidade do site local a partir da implantação ativa do RustFS.",
|
||||
"The two passwords are inconsistent": "As duas senhas são inconsistentes",
|
||||
"The value is used only for the initial join request.": "O valor é usado apenas para a solicitação inicial de ingresso.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Esta ação não pode ser desfeita.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Esta ação aposenta o pool selecionado e só deve ser usada após verificar que o rebalanceamento foi concluído.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Esta chave é usada como a chave SSE padrão da plataforma para SSE-KMS e SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Esta chave é usada como padrão da plataforma para SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Este link expirará quando sua sessão terminar ou no horário especificado.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Exclui a chave permanentemente de imediato e não pode ser desfeito.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Entendendo Tipos de Chave",
|
||||
"Unhealthy": "Com problemas",
|
||||
"Unknown": "Desconhecido",
|
||||
"Degraded": "Degradado",
|
||||
"Unknown Folder": "Pasta Desconhecida",
|
||||
"Unlimited": "Ilimitado",
|
||||
"Unnamed Site": "Site sem nome",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Todos",
|
||||
"All reported servers and disks are online.": "Todos os servidores e discos reportados estão online.",
|
||||
"Check your connection, then refresh the status page.": "Verifique sua conexão e atualize a página de status.",
|
||||
"Cluster Health": "Integridade do cluster",
|
||||
"Cluster information": "Informações do cluster",
|
||||
"Current Scan Started": "Início da verificação atual",
|
||||
"Degraded": "Degradado",
|
||||
"Endpoint, ascending": "Endpoint, crescente",
|
||||
"Endpoint, descending": "Endpoint, decrescente",
|
||||
"Filter servers": "Filtrar servidores",
|
||||
"Initializing": "Inicializando",
|
||||
"Inventory": "Inventário",
|
||||
"Last Completed Scan": "Última verificação concluída",
|
||||
"Last refreshed": "Última atualização",
|
||||
"Needs attention": "Requer atenção",
|
||||
"Needs attention first": "Primeiro os que requerem atenção",
|
||||
"No drive information was reported for this server.": "Nenhuma informação sobre os discos foi reportada para este servidor.",
|
||||
"No server information was reported.": "Nenhuma informação sobre os servidores foi reportada.",
|
||||
"No servers match the current filter": "Nenhum servidor corresponde ao filtro atual",
|
||||
"Offline or degraded resources require attention.": "Recursos offline ou degradados requerem atenção.",
|
||||
"Parity and backend settings reported by the cluster.": "Configurações de paridade e backend reportadas pelo cluster.",
|
||||
"Refresh to try again.": "Atualize para tentar novamente.",
|
||||
"Refreshing…": "Atualizando…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Analise a capacidade e a atividade do scanner em um só lugar.",
|
||||
"Scan Duration": "Duração da verificação",
|
||||
"Scanner metrics": "Métricas do scanner",
|
||||
"Show all servers": "Mostrar todos os servidores",
|
||||
"Some resource health data is unavailable or still initializing.": "Alguns dados de integridade dos recursos estão indisponíveis ou ainda sendo inicializados.",
|
||||
"Some status data is unavailable.": "Alguns dados de status estão indisponíveis.",
|
||||
"Storage Configuration": "Configuração de armazenamento",
|
||||
"Storage Usage and Scanner": "Uso do armazenamento e scanner",
|
||||
"Storage usage is unavailable for this account.": "O uso do armazenamento não está disponível para esta conta.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Não foi possível carregar o status do cluster. Verifique sua conexão e atualize.",
|
||||
"Unavailable": "Indisponível",
|
||||
"Uptime, longest first": "Tempo de atividade, maior primeiro",
|
||||
"Uptime, shortest first": "Tempo de atividade, menor primeiro",
|
||||
"Previously loaded values may be stale.": "Os valores carregados anteriormente podem estar desatualizados.",
|
||||
"Scanner Status": "Status do scanner",
|
||||
"Scanning": "Verificação em andamento",
|
||||
"Never run": "Nunca executado",
|
||||
"Access & Sharing": "Acesso e compartilhamento",
|
||||
"Add key-value metadata for organization and automation.": "Adicione metadados de chave-valor para organização e automação.",
|
||||
"Automation": "Automação",
|
||||
"Bucket Settings": "Configurações do bucket",
|
||||
"Capacity & Metadata": "Capacidade e metadados",
|
||||
"Configuration changed. Refresh and try again.": "A configuração foi alterada. Atualize a página e tente novamente.",
|
||||
"Configuration unavailable": "Configuração indisponível",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Configure a entrega de eventos, o ciclo de vida dos objetos e as regras de replicação.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Controle quem pode acessar este bucket e quais origens do navegador são permitidas.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Controla o acesso anônimo e autenticado aos objetos deste bucket.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Controla quais origens da web podem enviar solicitações entre origens para este bucket.",
|
||||
"Copy objects to another bucket or site.": "Copie objetos para outro bucket ou site.",
|
||||
"Data Protection": "Proteção de dados",
|
||||
"Delete Event Subscription": "Excluir assinatura de eventos",
|
||||
"Delete Rule": "Excluir regra",
|
||||
"Encrypts new objects at rest by default.": "Criptografa novos objetos em repouso por padrão.",
|
||||
"Enter a valid positive quota.": "Insira uma cota positiva válida.",
|
||||
"Event subscriptions unavailable": "Assinaturas de eventos indisponíveis",
|
||||
"Expire": "Expirar",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Ir para a seção",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Mantém versões anteriores dos objetos e permite recuperá-las após substituições ou exclusões.",
|
||||
"Lifecycle rules unavailable": "Regras de ciclo de vida indisponíveis",
|
||||
"Limits the total data stored in this bucket.": "Limita a quantidade total de dados armazenados neste bucket.",
|
||||
"Loading": "Carregando",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Gerencie criptografia, controle de versões, Object Lock e retenção padrão.",
|
||||
"No event subscriptions": "Nenhuma assinatura de eventos",
|
||||
"No lifecycle rules": "Nenhuma regra de ciclo de vida",
|
||||
"No replication rules": "Nenhuma regra de replicação",
|
||||
"Notify status unavailable": "Status das notificações indisponível",
|
||||
"Open": "Abrir",
|
||||
"Protects object versions from deletion during a retention period.": "Protege as versões dos objetos contra exclusão durante um período de retenção.",
|
||||
"Quota Unit": "Unidade da cota",
|
||||
"Replication rules unavailable": "Regras de replicação indisponíveis",
|
||||
"Requires Object Lock": "Requer Object Lock",
|
||||
"Save CORS": "Salvar CORS",
|
||||
"Save Encryption": "Salvar criptografia",
|
||||
"Save Policy": "Salvar política",
|
||||
"Save Quota": "Salvar cota",
|
||||
"Save Retention": "Salvar retenção",
|
||||
"Save Tag": "Salvar tag",
|
||||
"Send notifications when objects change.": "Envie notificações quando os objetos forem alterados.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Defina um limite de armazenamento e organize este bucket com tags.",
|
||||
"Sets the default retention mode and period for new object versions.": "Define o modo e o período de retenção padrão para novas versões dos objetos.",
|
||||
"Tag key already exists": "A chave da tag já existe",
|
||||
"The replication target was created but could not be rolled back.": "O destino de replicação foi criado, mas não foi possível reverter a operação.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "A regra foi excluída, mas não foi possível remover o destino remoto.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Esta assinatura não tem um ID estável. Atualize a página antes de excluí-la.",
|
||||
"Transition or expire objects automatically.": "Transfira automaticamente os objetos para outra classe de armazenamento ou faça-os expirar.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Não foi possível carregar as assinaturas de eventos. Atualize a página antes de fazer alterações.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Não foi possível carregar as regras de ciclo de vida. Atualize a página antes de fazer alterações.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Não foi possível carregar as regras de replicação. Atualize a página antes de fazer alterações.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Não foi possível carregar esta configuração. Atualize a página antes de fazer alterações.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Não foi possível verificar se as notificações estão ativadas. Atualize a página antes de fazer alterações.",
|
||||
"Unnamed rule": "Regra sem nome",
|
||||
"Unnamed subscription": "Assinatura sem nome",
|
||||
"Versioning": "Controle de versões",
|
||||
"Versioning can be suspended but existing versions are preserved.": "O controle de versões pode ser suspenso, mas as versões existentes são preservadas.",
|
||||
"Versioning stays enabled while Object Lock is active.": "O controle de versões permanece ativado enquanto Object Lock estiver ativo.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "O destino remoto pode ter sido salvo. Revise a configuração de replicação antes de tentar novamente.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "O acesso público pode expor objetos a qualquer pessoa autorizada pela política. Revise-a antes de salvar.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Remove a política do bucket. O acesso por IAM e por credenciais continua válido.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Usa a política JSON abaixo. Instruções inválidas podem bloquear o acesso.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Usa a chave KMS selecionada para criptografar novos objetos.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Usa chaves gerenciadas pelo servidor para criptografar novos objetos.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "O período de retenção não pode ser reduzido nem contornado até expirar.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Usuários autorizados podem contornar ou reduzir o período de retenção quando necessário.",
|
||||
"Unable to load versioning status.": "Não foi possível carregar o status do controle de versões.",
|
||||
"Unable to load storage tiers.": "Não foi possível carregar as camadas de armazenamento.",
|
||||
"Unable to load event targets.": "Não foi possível carregar os destinos de eventos.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Alterar o estado do serviço KMS pode interromper brevemente as solicitações SSE.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "O KMS será interrompido. O SSE e o gerenciamento de chaves ficarão indisponíveis até que você inicie o KMS novamente.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Você tem alterações de KMS não salvas. Deseja descartá-las?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "O resultado da operação é incerto. O status atual foi atualizado.",
|
||||
"Please enter a Local KMS master key.": "Insira uma chave mestra do KMS local.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Insira-a sempre que salvar a configuração do KMS local. Ela nunca é exibida nem armazenada.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "A configuração do KMS do sistema de arquivos local é somente leitura no Console até que a rotação segura da chave mestra esteja disponível.",
|
||||
"Set a default key for Local KMS in server configuration.": "Defina uma chave padrão para o KMS local na configuração do servidor.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Uma operação ainda está em andamento. Aguarde até que ela termine antes de sair desta página.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Salve ou descarte as alterações da configuração do KMS antes de definir uma chave recém-criada como padrão.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "O novo nome do objeto deve ser diferente",
|
||||
"Object name cannot contain slashes": "O nome do objeto não pode conter barras",
|
||||
"Object name is required": "O nome do objeto é obrigatório",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Текущий сайт",
|
||||
"Current User Policy": "Текущая политика пользователя",
|
||||
"Current Version": "Текущая версия",
|
||||
"Current": "Текущий",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Текущий статус backend для реплицированных бакетов, пользователей, групп, политик и правил истечения жизненного цикла.",
|
||||
"Current site + remote peers": "Текущий сайт + удаленные пиры",
|
||||
"Current user policy": "Текущая политика пользователя",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON успешно отформатирован",
|
||||
"JSON is already formatted": "JSON уже отформатирован",
|
||||
"KMS Backend": "Бэкенд KMS",
|
||||
"Backend and default key": "Бэкенд и ключ по умолчанию",
|
||||
"Reliability": "Надёжность",
|
||||
"KMS Cache": "Кэш KMS",
|
||||
"Vault connection": "Подключение к Vault",
|
||||
"KMS Configuration": "Конфигурация KMS",
|
||||
"KMS Key": "Ключ KMS",
|
||||
"KMS Key Details": "Сведения о ключе KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "Сервер KMS работает, детали конфигурации являются приватными",
|
||||
"KMS server status unknown": "Статус сервера KMS неизвестен",
|
||||
"KMS service has errors": "Служба KMS имеет ошибки",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Нельзя удалить ключ SSE по умолчанию. Сначала выберите другой ключ по умолчанию.",
|
||||
"KMS service is stopped": "Служба KMS остановлена",
|
||||
"KMS service not initialized, please configure it first": "Служба KMS не инициализирована, сначала настройте ее",
|
||||
"KMS service restarted successfully": "Служба KMS успешно перезапущена",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Перезапустить KMS",
|
||||
"Restart Required": "Требуется перезапуск",
|
||||
"Restore Key": "Восстановить ключ",
|
||||
"Restore": "Восстановить",
|
||||
"Restore Failed": "Ошибка восстановления",
|
||||
"Restore Success": "Восстановление успешно",
|
||||
"Restore Version": "Версия восстановления",
|
||||
"Resume": "Возобновить",
|
||||
"Resync cancel request sent successfully": "Запрос на отмену повторной синхронизации успешно отправлен",
|
||||
"Resync request started successfully": "Запрос повторной синхронизации успешно запущен",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Текущий сайт определяется сервером автоматически. Добавьте один или несколько удаленных сайтов, чтобы присоединиться к группе репликации.",
|
||||
"The directory must be an absolute path on the server.": "Каталог должен быть абсолютным путём на сервере.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "Экспортированный файл содержит конфиденциальную информацию. Храните его в безопасности.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "Секретный ключ показывается только один раз. Скопируйте или экспортируйте его перед закрытием.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Сервер определяет идентификатор локального сайта из активного развертывания RustFS.",
|
||||
"The two passwords are inconsistent": "Два пароля не совпадают",
|
||||
"The value is used only for the initial join request.": "Это значение используется только для первого запроса на подключение.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Это действие нельзя отменить.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Это действие выводит выбранный пул из эксплуатации и должно использоваться только после завершения перебалансировки.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Этот ключ используется как ключ SSE по умолчанию платформы для SSE-KMS и SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Этот ключ используется по умолчанию платформы для SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Эта ссылка истечет после завершения вашей сессии или в указанное время.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Немедленно и безвозвратно удаляет ключ.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Понимание типов ключей",
|
||||
"Unhealthy": "Неисправен",
|
||||
"Unknown": "Неизвестно",
|
||||
"Degraded": "Деградировано",
|
||||
"Unknown Folder": "Неизвестная папка",
|
||||
"Unlimited": "Неограниченно",
|
||||
"Unnamed Site": "Сайт без названия",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Декомиссия очищена",
|
||||
"Failed to clear decommission": "Не удалось очистить декомиссию",
|
||||
"Clear Decommission": "Очистить декомиссию",
|
||||
"All": "Все",
|
||||
"All reported servers and disks are online.": "Все указанные серверы и диски доступны.",
|
||||
"Check your connection, then refresh the status page.": "Проверьте подключение и обновите страницу состояния.",
|
||||
"Cluster Health": "Состояние кластера",
|
||||
"Cluster information": "Сведения о кластере",
|
||||
"Current Scan Started": "Текущее сканирование начато",
|
||||
"Degraded": "Деградировано",
|
||||
"Endpoint, ascending": "Конечная точка, по возрастанию",
|
||||
"Endpoint, descending": "Конечная точка, по убыванию",
|
||||
"Filter servers": "Фильтр серверов",
|
||||
"Initializing": "Инициализация",
|
||||
"Inventory": "Инвентаризация",
|
||||
"Last Completed Scan": "Последнее завершённое сканирование",
|
||||
"Last refreshed": "Последнее обновление",
|
||||
"Needs attention": "Требует внимания",
|
||||
"Needs attention first": "Сначала требующие внимания",
|
||||
"No drive information was reported for this server.": "Для этого сервера нет сведений о дисках.",
|
||||
"No server information was reported.": "Сведения о серверах не поступали.",
|
||||
"No servers match the current filter": "Нет серверов, соответствующих текущему фильтру",
|
||||
"Offline or degraded resources require attention.": "Ресурсы вне сети или с ухудшенным состоянием требуют внимания.",
|
||||
"Parity and backend settings reported by the cluster.": "Параметры чётности и бэкенда, полученные от кластера.",
|
||||
"Refresh to try again.": "Обновите страницу, чтобы повторить попытку.",
|
||||
"Refreshing…": "Обновление…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Просматривайте заполнение хранилища и активность сканера в одном месте.",
|
||||
"Scan Duration": "Продолжительность сканирования",
|
||||
"Scanner metrics": "Метрики сканера",
|
||||
"Show all servers": "Показать все серверы",
|
||||
"Some resource health data is unavailable or still initializing.": "Часть данных о состоянии ресурсов недоступна или ещё инициализируется.",
|
||||
"Some status data is unavailable.": "Часть данных о состоянии недоступна.",
|
||||
"Storage Configuration": "Конфигурация хранилища",
|
||||
"Storage Usage and Scanner": "Использование хранилища и сканер",
|
||||
"Storage usage is unavailable for this account.": "Данные об использовании хранилища недоступны для этой учётной записи.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Не удалось загрузить состояние кластера. Проверьте подключение и обновите страницу.",
|
||||
"Unavailable": "Недоступно",
|
||||
"Uptime, longest first": "Время работы: сначала наибольшее",
|
||||
"Uptime, shortest first": "Время работы: сначала наименьшее",
|
||||
"Previously loaded values may be stale.": "Ранее загруженные значения могут быть устаревшими.",
|
||||
"Scanner Status": "Состояние сканера",
|
||||
"Scanning": "Сканирование",
|
||||
"Never run": "Не запускалось",
|
||||
"Access & Sharing": "Доступ и общий доступ",
|
||||
"Add key-value metadata for organization and automation.": "Добавьте метаданные в виде пар «ключ — значение» для организации и автоматизации.",
|
||||
"Automation": "Автоматизация",
|
||||
"Bucket Settings": "Настройки бакета",
|
||||
"Capacity & Metadata": "Ёмкость и метаданные",
|
||||
"Configuration changed. Refresh and try again.": "Конфигурация изменилась. Обновите страницу и повторите попытку.",
|
||||
"Configuration unavailable": "Конфигурация недоступна",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Настройте доставку событий, жизненный цикл объектов и правила репликации.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Укажите, кто может обращаться к бакету и какие источники браузера разрешены.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Управляет анонимным и авторизованным доступом к объектам в этом бакете.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Определяет, какие веб-источники могут отправлять CORS-запросы в этот бакет.",
|
||||
"Copy objects to another bucket or site.": "Копируйте объекты в другой бакет или на другой сайт.",
|
||||
"Data Protection": "Защита данных",
|
||||
"Delete Event Subscription": "Удалить подписку на события",
|
||||
"Delete Rule": "Удалить правило",
|
||||
"Encrypts new objects at rest by default.": "По умолчанию шифрует новые объекты при хранении.",
|
||||
"Enter a valid positive quota.": "Введите допустимую положительную квоту.",
|
||||
"Event subscriptions unavailable": "Подписки на события недоступны",
|
||||
"Expire": "Удалять по сроку",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Перейти к разделу",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Храните предыдущие версии объектов для восстановления после перезаписи или удаления.",
|
||||
"Lifecycle rules unavailable": "Правила жизненного цикла недоступны",
|
||||
"Limits the total data stored in this bucket.": "Ограничивает общий объём данных в этом бакете.",
|
||||
"Loading": "Загрузка",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Управляйте шифрованием, версиями, Object Lock и хранением по умолчанию.",
|
||||
"No event subscriptions": "Нет подписок на события",
|
||||
"No lifecycle rules": "Нет правил жизненного цикла",
|
||||
"No replication rules": "Нет правил репликации",
|
||||
"Notify status unavailable": "Состояние уведомлений недоступно",
|
||||
"Open": "Открыть",
|
||||
"Protects object versions from deletion during a retention period.": "Защищает версии объектов от удаления в течение срока хранения.",
|
||||
"Quota Unit": "Единица квоты",
|
||||
"Replication rules unavailable": "Правила репликации недоступны",
|
||||
"Requires Object Lock": "Требуется Object Lock",
|
||||
"Save CORS": "Сохранить CORS",
|
||||
"Save Encryption": "Сохранить шифрование",
|
||||
"Save Policy": "Сохранить политику",
|
||||
"Save Quota": "Сохранить квоту",
|
||||
"Save Retention": "Сохранить хранение",
|
||||
"Save Tag": "Сохранить метку",
|
||||
"Send notifications when objects change.": "Отправляйте уведомления при изменении объектов.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Задайте лимит хранилища и организуйте бакет с помощью меток.",
|
||||
"Sets the default retention mode and period for new object versions.": "Задаёт режим и срок хранения по умолчанию для новых версий объектов.",
|
||||
"Tag key already exists": "Ключ метки уже существует",
|
||||
"The replication target was created but could not be rolled back.": "Цель репликации создана, но отменить это действие не удалось.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "Правило удалено, но удалить удалённую цель не удалось.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "У этой подписки нет постоянного ID. Обновите страницу перед удалением.",
|
||||
"Transition or expire objects automatically.": "Автоматически перемещайте объекты в другой класс хранения или удаляйте их по сроку.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Не удалось загрузить подписки на события. Обновите страницу перед внесением изменений.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Не удалось загрузить правила жизненного цикла. Обновите страницу перед внесением изменений.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Не удалось загрузить правила репликации. Обновите страницу перед внесением изменений.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Не удалось загрузить эту настройку. Обновите страницу перед внесением изменений.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Не удалось проверить, включены ли уведомления. Обновите страницу перед внесением изменений.",
|
||||
"Unnamed rule": "Правило без имени",
|
||||
"Unnamed subscription": "Подписка без имени",
|
||||
"Versioning": "Управление версиями",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Управление версиями можно приостановить, при этом существующие версии сохранятся.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Управление версиями остаётся включённым, пока активен Object Lock.",
|
||||
"bucket": "бакет",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "Удалённая цель могла быть сохранена. Проверьте конфигурацию репликации перед повторной попыткой.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "Открытый доступ может сделать объекты доступными всем, кому это разрешает политика. Проверьте её перед сохранением.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Удаляет политику бакета. Доступ через IAM и учётные данные продолжает действовать.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Использует указанную ниже политику JSON. Недопустимые правила могут заблокировать доступ.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Использует выбранный ключ KMS для шифрования новых объектов.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Использует ключи под управлением сервера для шифрования новых объектов.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Срок хранения нельзя сократить или обойти до его окончания.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Авторизованные пользователи могут при необходимости обойти или сократить срок хранения.",
|
||||
"Unable to load versioning status.": "Не удалось загрузить состояние управления версиями.",
|
||||
"Unable to load storage tiers.": "Не удалось загрузить классы хранения.",
|
||||
"Unable to load event targets.": "Не удалось загрузить цели событий.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Изменение состояния службы KMS может кратковременно прервать запросы SSE.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS будет остановлен. SSE и управление ключами будут недоступны, пока вы снова не запустите KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "У вас есть несохранённые изменения KMS. Отменить их?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "Результат операции неясен. Текущее состояние обновлено.",
|
||||
"Please enter a Local KMS master key.": "Введите мастер-ключ локальной KMS.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Вводите его при каждом сохранении конфигурации локальной KMS. Он никогда не отображается и не сохраняется.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "Конфигурация KMS локальной файловой системы доступна в консоли только для чтения, пока не появится безопасная ротация мастер-ключа.",
|
||||
"Set a default key for Local KMS in server configuration.": "Задайте ключ по умолчанию для локальной KMS в конфигурации сервера.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Операция ещё выполняется. Дождитесь её завершения, прежде чем покинуть эту страницу.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Сохраните или отмените изменения конфигурации KMS, прежде чем назначить новый ключ ключом по умолчанию.",
|
||||
"Current": "Текущий",
|
||||
"Restore": "Восстановить",
|
||||
"Restore Failed": "Ошибка восстановления",
|
||||
"Restore Success": "Восстановление успешно",
|
||||
"Restore Version": "Версия восстановления",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Новое имя объекта должно отличаться",
|
||||
"Object name cannot contain slashes": "Имя объекта не может содержать косые черты",
|
||||
"Object name is required": "Требуется имя объекта",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Mevcut Site",
|
||||
"Current User Policy": "Mevcut Kullanıcı Politikası",
|
||||
"Current Version": "Mevcut Sürüm",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Çoğaltılan bucket'lar, kullanıcılar, gruplar, politikalar ve yaşam döngüsü sona erme kuralları için geçerli backend durumu.",
|
||||
"Current site + remote peers": "Geçerli site + uzak eşler",
|
||||
"Current user policy": "Mevcut kullanıcı politikası",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "JSON başarıyla formatlandı",
|
||||
"JSON is already formatted": "JSON zaten formatlanmış",
|
||||
"KMS Backend": "KMS arka ucu",
|
||||
"Backend and default key": "Arka uç ve varsayılan anahtar",
|
||||
"Reliability": "Güvenilirlik",
|
||||
"KMS Cache": "KMS önbelleği",
|
||||
"Vault connection": "Vault bağlantısı",
|
||||
"KMS Configuration": "KMS Yapılandırması",
|
||||
"KMS Key": "KMS Anahtarı",
|
||||
"KMS Key Details": "KMS anahtar ayrıntıları",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "KMS sunucusu çalışıyor, yapılandırma ayrıntıları gizli",
|
||||
"KMS server status unknown": "KMS sunucu durumu bilinmiyor",
|
||||
"KMS service has errors": "KMS hizmetinde hatalar var",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Varsayılan SSE anahtarı silinemez. Önce başka bir varsayılan anahtar seçin.",
|
||||
"KMS service is stopped": "KMS hizmeti durduruldu",
|
||||
"KMS service not initialized, please configure it first": "KMS hizmeti başlatılmadı, lütfen önce yapılandırın",
|
||||
"KMS service restarted successfully": "KMS hizmeti yeniden başlatıldı",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "KMS’yi yeniden başlat",
|
||||
"Restart Required": "Yeniden Başlatma Gerekli",
|
||||
"Restore Key": "Anahtarı geri yükle",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Devam Et",
|
||||
"Resync cancel request sent successfully": "Yeniden senkronizasyon iptal isteği başarıyla gönderildi",
|
||||
"Resync request started successfully": "Yeniden senkronizasyon isteği başarıyla başlatıldı",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Geçerli site sunucu tarafından otomatik olarak algılanır. Çoğaltma grubuna katılmak için bir veya daha fazla uzak site ekleyin.",
|
||||
"The directory must be an absolute path on the server.": "Dizin sunucuda mutlak yol olmalıdır.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "Dışa aktarılan dosya hassas bilgiler içerir. Lütfen güvende tutun.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "Gizli anahtar yalnızca bir kez gösterilir. Kapatmadan önce kopyalayın veya dışa aktarın.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Sunucu, yerel site kimliğini etkin RustFS dağıtımından algılar.",
|
||||
"The two passwords are inconsistent": "İki şifre tutarsız",
|
||||
"The value is used only for the initial join request.": "Bu değer yalnızca ilk katılım isteği için kullanılır.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Bu işlem geri alınamaz.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Bu işlem seçilen pool’u devreden çıkarır ve yalnızca yeniden dengeleme tamamlandıktan sonra kullanılmalıdır.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Bu anahtar, SSE-KMS ve SSE-S3 için platformun varsayılan SSE anahtarı olarak kullanılır.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Bu anahtar platformun SSE-KMS varsayılanıdır.",
|
||||
"This link will expire when your session ends or at the specified time.": "Bu bağlantı oturumunuz sona erdiğinde veya belirtilen saatte süresi dolacaktır.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Anahtarı hemen kalıcı olarak siler ve geri alınamaz.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Anahtar Türlerini Anlamak",
|
||||
"Unhealthy": "Sağlıksız",
|
||||
"Unknown": "Bilinmeyen",
|
||||
"Degraded": "Bozulmuş",
|
||||
"Unknown Folder": "Bilinmeyen Klasör",
|
||||
"Unlimited": "Sınırsız",
|
||||
"Unnamed Site": "Adsız Site",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Tümü",
|
||||
"All reported servers and disks are online.": "Bildirilen tüm sunucu ve diskler çevrimiçi.",
|
||||
"Check your connection, then refresh the status page.": "Bağlantınızı kontrol edip durum sayfasını yenileyin.",
|
||||
"Cluster Health": "Küme durumu",
|
||||
"Cluster information": "Küme bilgileri",
|
||||
"Current Scan Started": "Geçerli tarama başlangıcı",
|
||||
"Degraded": "Bozulmuş",
|
||||
"Endpoint, ascending": "Uç nokta, artan",
|
||||
"Endpoint, descending": "Uç nokta, azalan",
|
||||
"Filter servers": "Sunucuları filtrele",
|
||||
"Initializing": "Başlatılıyor",
|
||||
"Inventory": "Envanter",
|
||||
"Last Completed Scan": "Son tamamlanan tarama",
|
||||
"Last refreshed": "Son yenileme",
|
||||
"Needs attention": "İlgilenilmesi gerekiyor",
|
||||
"Needs attention first": "Önce ilgilenilmesi gerekenler",
|
||||
"No drive information was reported for this server.": "Bu sunucu için sürücü bilgisi bildirilmedi.",
|
||||
"No server information was reported.": "Sunucu bilgisi bildirilmedi.",
|
||||
"No servers match the current filter": "Geçerli filtreyle eşleşen sunucu yok",
|
||||
"Offline or degraded resources require attention.": "Çevrimdışı veya performansı düşük kaynaklarla ilgilenilmesi gerekiyor.",
|
||||
"Parity and backend settings reported by the cluster.": "Küme tarafından bildirilen eşlik ve arka uç ayarları.",
|
||||
"Refresh to try again.": "Yeniden denemek için yenileyin.",
|
||||
"Refreshing…": "Yenileniyor…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Kapasite baskısını ve tarayıcı etkinliğini tek yerden inceleyin.",
|
||||
"Scan Duration": "Tarama süresi",
|
||||
"Scanner metrics": "Tarayıcı metrikleri",
|
||||
"Show all servers": "Tüm sunucuları göster",
|
||||
"Some resource health data is unavailable or still initializing.": "Bazı kaynak sağlık verileri kullanılamıyor veya hâlâ başlatılıyor.",
|
||||
"Some status data is unavailable.": "Bazı durum verileri kullanılamıyor.",
|
||||
"Storage Configuration": "Depolama yapılandırması",
|
||||
"Storage Usage and Scanner": "Depolama kullanımı ve tarayıcı",
|
||||
"Storage usage is unavailable for this account.": "Bu hesap için depolama kullanımı bilgisi yok.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Küme durumu yüklenemedi. Bağlantınızı kontrol edip sayfayı yenileyin.",
|
||||
"Unavailable": "Kullanılamıyor",
|
||||
"Uptime, longest first": "Çalışma süresi, önce en uzun",
|
||||
"Uptime, shortest first": "Çalışma süresi, önce en kısa",
|
||||
"Previously loaded values may be stale.": "Önceden yüklenen değerler güncel olmayabilir.",
|
||||
"Scanner Status": "Tarayıcı durumu",
|
||||
"Scanning": "Taranıyor",
|
||||
"Never run": "Hiç çalıştırılmadı",
|
||||
"Access & Sharing": "Erişim ve Paylaşım",
|
||||
"Add key-value metadata for organization and automation.": "Düzenleme ve otomasyon için anahtar-değer meta verileri ekleyin.",
|
||||
"Automation": "Otomasyon",
|
||||
"Bucket Settings": "Bucket Ayarları",
|
||||
"Capacity & Metadata": "Kapasite ve Meta Veriler",
|
||||
"Configuration changed. Refresh and try again.": "Yapılandırma değişti. Yenileyip tekrar deneyin.",
|
||||
"Configuration unavailable": "Yapılandırma kullanılamıyor",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Olay iletimini, nesne yaşam döngüsünü ve çoğaltma kurallarını yapılandırın.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Bu bucket'a kimlerin erişebileceğini ve izin verilen tarayıcı kaynaklarını belirleyin.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Bu bucket'taki nesnelere anonim ve kimliği doğrulanmış erişimi yönetir.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Bu bucket'a hangi web kaynaklarının CORS isteği gönderebileceğini belirler.",
|
||||
"Copy objects to another bucket or site.": "Nesneleri başka bir bucket'a veya siteye kopyalayın.",
|
||||
"Data Protection": "Veri Koruma",
|
||||
"Delete Event Subscription": "Olay Aboneliğini Sil",
|
||||
"Delete Rule": "Kuralı Sil",
|
||||
"Encrypts new objects at rest by default.": "Yeni nesneleri varsayılan olarak depolama sırasında şifreler.",
|
||||
"Enter a valid positive quota.": "Geçerli bir pozitif kota girin.",
|
||||
"Event subscriptions unavailable": "Olay abonelikleri kullanılamıyor",
|
||||
"Expire": "Süresini Doldur",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Bölüme git",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Önceki nesne sürümlerini koruyarak üzerine yazma veya silme işlemlerinden geri dönün.",
|
||||
"Lifecycle rules unavailable": "Yaşam döngüsü kuralları kullanılamıyor",
|
||||
"Limits the total data stored in this bucket.": "Bu bucket'ta depolanan toplam veriyi sınırlar.",
|
||||
"Loading": "Yükleniyor",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Şifreleme, sürüm oluşturma, Object Lock ve varsayılan saklama ayarlarını yönetin.",
|
||||
"No event subscriptions": "Olay aboneliği yok",
|
||||
"No lifecycle rules": "Yaşam döngüsü kuralı yok",
|
||||
"No replication rules": "Çoğaltma kuralı yok",
|
||||
"Notify status unavailable": "Bildirim durumu kullanılamıyor",
|
||||
"Open": "Aç",
|
||||
"Protects object versions from deletion during a retention period.": "Nesne sürümlerini saklama süresi boyunca silinmeye karşı korur.",
|
||||
"Quota Unit": "Kota Birimi",
|
||||
"Replication rules unavailable": "Çoğaltma kuralları kullanılamıyor",
|
||||
"Requires Object Lock": "Object Lock gerektirir",
|
||||
"Save CORS": "CORS'u Kaydet",
|
||||
"Save Encryption": "Şifrelemeyi Kaydet",
|
||||
"Save Policy": "Policy'yi Kaydet",
|
||||
"Save Quota": "Kotayı Kaydet",
|
||||
"Save Retention": "Saklama Ayarını Kaydet",
|
||||
"Save Tag": "Etiketi Kaydet",
|
||||
"Send notifications when objects change.": "Nesneler değiştiğinde bildirim gönderin.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Bir depolama sınırı belirleyin ve bu bucket'ı etiketlerle düzenleyin.",
|
||||
"Sets the default retention mode and period for new object versions.": "Yeni nesne sürümleri için varsayılan saklama modunu ve süresini belirler.",
|
||||
"Tag key already exists": "Etiket anahtarı zaten var",
|
||||
"The replication target was created but could not be rolled back.": "Çoğaltma hedefi oluşturuldu ancak geri alınamadı.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "Kural silindi ancak uzak hedef kaldırılamadı.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Bu aboneliğin kalıcı bir ID'si yok. Silmeden önce yenileyin.",
|
||||
"Transition or expire objects automatically.": "Nesneleri otomatik olarak başka bir depolama sınıfına taşıyın veya sürelerini doldurun.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Olay abonelikleri yüklenemedi. Değişiklik yapmadan önce yenileyin.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Yaşam döngüsü kuralları yüklenemedi. Değişiklik yapmadan önce yenileyin.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Çoğaltma kuralları yüklenemedi. Değişiklik yapmadan önce yenileyin.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Bu ayar yüklenemedi. Değişiklik yapmadan önce yenileyin.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Bildirimlerin etkin olup olmadığı doğrulanamadı. Değişiklik yapmadan önce yenileyin.",
|
||||
"Unnamed rule": "Adsız kural",
|
||||
"Unnamed subscription": "Adsız abonelik",
|
||||
"Versioning": "Sürüm Oluşturma",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Sürüm oluşturma askıya alınabilir; mevcut sürümler korunur.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Object Lock etkinken sürüm oluşturma açık kalır.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "Uzak hedef kaydedilmiş olabilir. Tekrar denemeden önce çoğaltma yapılandırmasını gözden geçirin.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "Genel erişim, nesneleri policy'nin izin verdiği herkese açabilir. Kaydetmeden önce gözden geçirin.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Bucket policy'sini kaldırır. IAM ve kimlik bilgilerine dayalı erişim geçerliliğini korur.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Aşağıdaki JSON policy'sini kullanır. Geçersiz ifadeler erişimi engelleyebilir.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Yeni nesneleri şifrelemek için seçili KMS anahtarını kullanır.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Yeni nesneleri şifrelemek için sunucu tarafından yönetilen anahtarları kullanır.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Saklama süresi dolana kadar kısaltılamaz veya atlanamaz.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Yetkili kullanıcılar gerektiğinde saklama süresini atlayabilir veya kısaltabilir.",
|
||||
"Unable to load versioning status.": "Sürüm oluşturma durumu yüklenemedi.",
|
||||
"Unable to load storage tiers.": "Depolama katmanları yüklenemedi.",
|
||||
"Unable to load event targets.": "Olay hedefleri yüklenemedi.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "KMS hizmetinin durumunu değiştirmek SSE isteklerini kısa süreliğine kesintiye uğratabilir.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS durdurulacak. KMS'yi yeniden başlatana kadar SSE ve anahtar yönetimi kullanılamayacak.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Kaydedilmemiş KMS değişiklikleriniz var. Bunları iptal etmek istiyor musunuz?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "İşlemin sonucu belirsiz. Mevcut durum yenilendi.",
|
||||
"Please enter a Local KMS master key.": "Bir Yerel KMS ana anahtarı girin.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Yerel KMS yapılandırmasını her kaydettiğinizde bunu girin. Hiçbir zaman gösterilmez veya saklanmaz.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "Güvenli ana anahtar döndürme kullanılabilir olana kadar yerel dosya sistemi KMS yapılandırması Konsol'da salt okunurdur.",
|
||||
"Set a default key for Local KMS in server configuration.": "Sunucu yapılandırmasında Yerel KMS için varsayılan bir anahtar belirleyin.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Bir işlem hâlâ devam ediyor. Bu sayfadan ayrılmadan önce tamamlanmasını bekleyin.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Yeni oluşturulan bir anahtarı varsayılan olarak ayarlamadan önce KMS yapılandırma değişikliklerini kaydedin veya iptal edin.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Yeni nesne adı farklı olmalıdır",
|
||||
"Object name cannot contain slashes": "Nesne adı eğik çizgi içeremez",
|
||||
"Object name is required": "Nesne adı gerekli",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "Cụm máy chủ hiện tại",
|
||||
"Current User Policy": "Chính sách người dùng hiện tại",
|
||||
"Current Version": "Phiên bản hiện tại",
|
||||
"Current": "Current",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "Trạng thái backend hiện tại của các bucket, người dùng, nhóm, chính sách và quy tắc hết hạn vòng đời được sao chép.",
|
||||
"Current site + remote peers": "Site hiện tại + các peer từ xa",
|
||||
"Current user policy": "Chính sách người dùng hiện tại",
|
||||
@@ -499,6 +498,10 @@
|
||||
"JSON formatted successfully": "Định dạng JSON thành công",
|
||||
"JSON is already formatted": "JSON đã được định dạng trước đó",
|
||||
"KMS Backend": "Backend KMS",
|
||||
"Backend and default key": "Backend và khóa mặc định",
|
||||
"Reliability": "Độ tin cậy",
|
||||
"KMS Cache": "Bộ nhớ đệm KMS",
|
||||
"Vault connection": "Kết nối Vault",
|
||||
"KMS Configuration": "Cấu hình KMS",
|
||||
"KMS Key": "Khóa KMS",
|
||||
"KMS Key Details": "Chi tiết khóa KMS",
|
||||
@@ -519,6 +522,7 @@
|
||||
"KMS server is running, configuration details are private": "Máy chủ KMS đang hoạt động, chi tiết cấu hình được bảo mật",
|
||||
"KMS server status unknown": "Không rõ trạng thái máy chủ KMS",
|
||||
"KMS service has errors": "Dịch vụ KMS có lỗi",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "Không thể xóa khóa SSE mặc định. Trước tiên, hãy chọn một khóa mặc định khác.",
|
||||
"KMS service is stopped": "Dịch vụ KMS đã dừng",
|
||||
"KMS service not initialized, please configure it first": "Dịch vụ KMS chưa được khởi tạo, vui lòng cấu hình trước",
|
||||
"KMS service restarted successfully": "Đã khởi động lại dịch vụ KMS",
|
||||
@@ -926,10 +930,6 @@
|
||||
"Restart KMS": "Khởi động lại KMS",
|
||||
"Restart Required": "Cần khởi động lại",
|
||||
"Restore Key": "Khôi phục khóa",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"Resume": "Tiếp tục",
|
||||
"Resync cancel request sent successfully": "Đã gửi yêu cầu hủy đồng bộ lại thành công",
|
||||
"Resync request started successfully": "Đã bắt đầu yêu cầu đồng bộ lại thành công",
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "Site hiện tại được máy chủ tự động phát hiện. Thêm một hoặc nhiều site từ xa để tham gia nhóm sao chép.",
|
||||
"The directory must be an absolute path on the server.": "Thư mục phải là đường dẫn tuyệt đối trên máy chủ.",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "Tệp được xuất chứa thông tin nhạy cảm. Vui lòng bảo mật tệp này.",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "Khóa bí mật chỉ được hiển thị một lần. Hãy sao chép hoặc xuất khóa trước khi đóng.",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "Máy chủ phát hiện danh tính site cục bộ từ bản triển khai RustFS đang hoạt động.",
|
||||
"The two passwords are inconsistent": "Hai mật khẩu không khớp nhau",
|
||||
"The value is used only for the initial join request.": "Giá trị này chỉ được dùng cho yêu cầu tham gia ban đầu.",
|
||||
@@ -1138,7 +1139,6 @@
|
||||
"This action cannot be undone.": "Hành động này không thể hoàn tác.",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "Hành động này ngừng sử dụng pool đã chọn và chỉ nên dùng sau khi xác nhận cân bằng lại đã hoàn tất.",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "Khóa này được dùng làm khóa SSE mặc định của nền tảng cho SSE-KMS và SSE-S3.",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"This key is used as the platform default for SSE-KMS.": "Khóa này là mặc định nền tảng cho SSE-KMS.",
|
||||
"This link will expire when your session ends or at the specified time.": "Liên kết này sẽ hết hạn khi phiên làm việc của bạn kết thúc hoặc vào thời gian đã định.",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "Xóa khóa vĩnh viễn ngay lập tức và không thể hoàn tác.",
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"Understanding Key Types": "Tìm hiểu các loại khóa",
|
||||
"Unhealthy": "Không khỏe mạnh",
|
||||
"Unknown": "Không xác định",
|
||||
"Degraded": "Suy giảm",
|
||||
"Unknown Folder": "Thư mục không xác định",
|
||||
"Unlimited": "Không giới hạn",
|
||||
"Unnamed Site": "Site chưa đặt tên",
|
||||
@@ -1354,6 +1353,134 @@
|
||||
"Decommission cleared": "Decommission cleared",
|
||||
"Failed to clear decommission": "Failed to clear decommission",
|
||||
"Clear Decommission": "Clear Decommission",
|
||||
"All": "Tất cả",
|
||||
"All reported servers and disks are online.": "Tất cả máy chủ và ổ đĩa được báo cáo đều đang trực tuyến.",
|
||||
"Check your connection, then refresh the status page.": "Kiểm tra kết nối, sau đó làm mới trang trạng thái.",
|
||||
"Cluster Health": "Tình trạng cụm",
|
||||
"Cluster information": "Thông tin cụm",
|
||||
"Current Scan Started": "Thời điểm bắt đầu quét hiện tại",
|
||||
"Degraded": "Suy giảm",
|
||||
"Endpoint, ascending": "Điểm cuối, tăng dần",
|
||||
"Endpoint, descending": "Điểm cuối, giảm dần",
|
||||
"Filter servers": "Lọc máy chủ",
|
||||
"Initializing": "Đang khởi tạo",
|
||||
"Inventory": "Kiểm kê",
|
||||
"Last Completed Scan": "Lần quét hoàn tất gần nhất",
|
||||
"Last refreshed": "Lần làm mới gần nhất",
|
||||
"Needs attention": "Cần chú ý",
|
||||
"Needs attention first": "Ưu tiên mục cần chú ý",
|
||||
"No drive information was reported for this server.": "Không có thông tin ổ đĩa cho máy chủ này.",
|
||||
"No server information was reported.": "Không có thông tin máy chủ.",
|
||||
"No servers match the current filter": "Không có máy chủ khớp với bộ lọc hiện tại",
|
||||
"Offline or degraded resources require attention.": "Tài nguyên ngoại tuyến hoặc suy giảm cần được chú ý.",
|
||||
"Parity and backend settings reported by the cluster.": "Các thiết lập parity và backend do cụm báo cáo.",
|
||||
"Refresh to try again.": "Làm mới để thử lại.",
|
||||
"Refreshing…": "Đang làm mới…",
|
||||
"Review capacity pressure and scanner activity in one place.": "Theo dõi mức sử dụng dung lượng và hoạt động quét tại một nơi.",
|
||||
"Scan Duration": "Thời lượng quét",
|
||||
"Scanner metrics": "Chỉ số quét",
|
||||
"Show all servers": "Hiển thị tất cả máy chủ",
|
||||
"Some resource health data is unavailable or still initializing.": "Một số dữ liệu tình trạng tài nguyên không khả dụng hoặc vẫn đang khởi tạo.",
|
||||
"Some status data is unavailable.": "Một số dữ liệu trạng thái không khả dụng.",
|
||||
"Storage Configuration": "Cấu hình lưu trữ",
|
||||
"Storage Usage and Scanner": "Mức sử dụng lưu trữ và trình quét",
|
||||
"Storage usage is unavailable for this account.": "Dữ liệu sử dụng lưu trữ không khả dụng cho tài khoản này.",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "Không thể tải trạng thái cụm. Hãy kiểm tra kết nối và làm mới trang.",
|
||||
"Unavailable": "Không khả dụng",
|
||||
"Uptime, longest first": "Thời gian hoạt động, dài nhất trước",
|
||||
"Uptime, shortest first": "Thời gian hoạt động, ngắn nhất trước",
|
||||
"Previously loaded values may be stale.": "Các giá trị đã tải trước đó có thể đã cũ.",
|
||||
"Scanner Status": "Trạng thái trình quét",
|
||||
"Scanning": "Đang quét",
|
||||
"Never run": "Chưa từng chạy",
|
||||
"Access & Sharing": "Truy cập & Chia sẻ",
|
||||
"Add key-value metadata for organization and automation.": "Thêm siêu dữ liệu khóa-giá trị để sắp xếp và tự động hóa.",
|
||||
"Automation": "Tự động hóa",
|
||||
"Bucket Settings": "Cài đặt Bucket",
|
||||
"Capacity & Metadata": "Dung lượng & Siêu dữ liệu",
|
||||
"Configuration changed. Refresh and try again.": "Cấu hình đã thay đổi. Hãy làm mới và thử lại.",
|
||||
"Configuration unavailable": "Cấu hình không khả dụng",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "Cấu hình việc phân phối sự kiện, vòng đời đối tượng và quy tắc sao chép.",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "Kiểm soát người có thể truy cập bucket này và các origin trình duyệt được phép.",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "Kiểm soát quyền truy cập ẩn danh và đã xác thực vào các đối tượng trong bucket này.",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "Kiểm soát các origin web có thể gửi yêu cầu CORS đến bucket này.",
|
||||
"Copy objects to another bucket or site.": "Sao chép đối tượng sang bucket hoặc site khác.",
|
||||
"Data Protection": "Bảo vệ Dữ liệu",
|
||||
"Delete Event Subscription": "Xóa Đăng ký Sự kiện",
|
||||
"Delete Rule": "Xóa Quy tắc",
|
||||
"Encrypts new objects at rest by default.": "Mã hóa các đối tượng mới khi lưu trữ theo mặc định.",
|
||||
"Enter a valid positive quota.": "Nhập hạn mức dương hợp lệ.",
|
||||
"Event subscriptions unavailable": "Đăng ký sự kiện không khả dụng",
|
||||
"Expire": "Hết hạn",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "Chuyển đến phần",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "Giữ các phiên bản đối tượng trước để khôi phục khi bị ghi đè hoặc xóa.",
|
||||
"Lifecycle rules unavailable": "Quy tắc vòng đời không khả dụng",
|
||||
"Limits the total data stored in this bucket.": "Giới hạn tổng dữ liệu được lưu trong bucket này.",
|
||||
"Loading": "Đang tải",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "Quản lý mã hóa, phiên bản, Object Lock và thời gian lưu giữ mặc định.",
|
||||
"No event subscriptions": "Không có đăng ký sự kiện",
|
||||
"No lifecycle rules": "Không có quy tắc vòng đời",
|
||||
"No replication rules": "Không có quy tắc sao chép",
|
||||
"Notify status unavailable": "Trạng thái thông báo không khả dụng",
|
||||
"Open": "Mở",
|
||||
"Protects object versions from deletion during a retention period.": "Bảo vệ các phiên bản đối tượng khỏi bị xóa trong thời gian lưu giữ.",
|
||||
"Quota Unit": "Đơn vị Hạn mức",
|
||||
"Replication rules unavailable": "Quy tắc sao chép không khả dụng",
|
||||
"Requires Object Lock": "Yêu cầu Object Lock",
|
||||
"Save CORS": "Lưu CORS",
|
||||
"Save Encryption": "Lưu Mã hóa",
|
||||
"Save Policy": "Lưu Policy",
|
||||
"Save Quota": "Lưu Hạn mức",
|
||||
"Save Retention": "Lưu Thiết lập Lưu giữ",
|
||||
"Save Tag": "Lưu Thẻ",
|
||||
"Send notifications when objects change.": "Gửi thông báo khi đối tượng thay đổi.",
|
||||
"Set a storage limit and organize this bucket with tags.": "Đặt giới hạn lưu trữ và sắp xếp bucket này bằng thẻ.",
|
||||
"Sets the default retention mode and period for new object versions.": "Đặt chế độ và thời gian lưu giữ mặc định cho các phiên bản đối tượng mới.",
|
||||
"Tag key already exists": "Khóa thẻ đã tồn tại",
|
||||
"The replication target was created but could not be rolled back.": "Đích sao chép đã được tạo nhưng không thể hoàn tác.",
|
||||
"The rule was deleted, but the remote target could not be removed.": "Quy tắc đã bị xóa nhưng không thể xóa đích từ xa.",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "Đăng ký này không có ID ổn định. Hãy làm mới trước khi xóa.",
|
||||
"Transition or expire objects automatically.": "Tự động chuyển lớp lưu trữ hoặc đặt đối tượng hết hạn.",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "Không thể tải đăng ký sự kiện. Hãy làm mới trước khi thay đổi.",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "Không thể tải quy tắc vòng đời. Hãy làm mới trước khi thay đổi.",
|
||||
"Unable to load replication rules. Refresh before making changes.": "Không thể tải quy tắc sao chép. Hãy làm mới trước khi thay đổi.",
|
||||
"Unable to load this setting. Refresh before making changes.": "Không thể tải cài đặt này. Hãy làm mới trước khi thay đổi.",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "Không thể xác minh thông báo đã được bật hay chưa. Hãy làm mới trước khi thay đổi.",
|
||||
"Unnamed rule": "Quy tắc chưa đặt tên",
|
||||
"Unnamed subscription": "Đăng ký chưa đặt tên",
|
||||
"Versioning": "Quản lý Phiên bản",
|
||||
"Versioning can be suspended but existing versions are preserved.": "Có thể tạm dừng quản lý phiên bản nhưng các phiên bản hiện có vẫn được giữ lại.",
|
||||
"Versioning stays enabled while Object Lock is active.": "Quản lý phiên bản vẫn được bật khi Object Lock đang hoạt động.",
|
||||
"bucket": "bucket",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "Đích từ xa có thể đã được lưu. Hãy kiểm tra cấu hình sao chép trước khi thử lại.",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "Quyền truy cập công khai có thể cho phép bất kỳ ai được policy cho phép truy cập đối tượng. Hãy xem lại trước khi lưu.",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "Xóa policy của bucket. Quyền truy cập dựa trên IAM và thông tin xác thực vẫn có hiệu lực.",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "Sử dụng policy JSON bên dưới. Các câu lệnh không hợp lệ có thể chặn quyền truy cập.",
|
||||
"Uses the selected KMS key to encrypt new objects.": "Sử dụng khóa KMS đã chọn để mã hóa các đối tượng mới.",
|
||||
"Uses server-managed keys to encrypt new objects.": "Sử dụng khóa do máy chủ quản lý để mã hóa các đối tượng mới.",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "Không thể rút ngắn hoặc bỏ qua thời gian lưu giữ cho đến khi hết hạn.",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "Người dùng được ủy quyền có thể bỏ qua hoặc rút ngắn thời gian lưu giữ khi cần.",
|
||||
"Unable to load versioning status.": "Không thể tải trạng thái quản lý phiên bản.",
|
||||
"Unable to load storage tiers.": "Không thể tải các tầng lưu trữ.",
|
||||
"Unable to load event targets.": "Không thể tải các đích sự kiện.",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "Việc thay đổi trạng thái dịch vụ KMS có thể tạm thời làm gián đoạn các yêu cầu SSE.",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS sẽ dừng. SSE và tính năng quản lý khóa sẽ không khả dụng cho đến khi bạn khởi động lại KMS.",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "Bạn có các thay đổi KMS chưa lưu. Bạn có muốn hủy chúng không?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "Kết quả thao tác chưa chắc chắn. Trạng thái hiện tại đã được làm mới.",
|
||||
"Please enter a Local KMS master key.": "Vui lòng nhập khóa chính cho KMS cục bộ.",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "Nhập khóa này mỗi khi bạn lưu cấu hình KMS cục bộ. Khóa sẽ không bao giờ được hiển thị hoặc lưu giữ.",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "Cấu hình KMS trên hệ thống tệp cục bộ chỉ có thể đọc trong Console cho đến khi hỗ trợ xoay vòng khóa chính an toàn.",
|
||||
"Set a default key for Local KMS in server configuration.": "Đặt khóa mặc định cho KMS cục bộ trong cấu hình máy chủ.",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "Một thao tác vẫn đang diễn ra. Hãy đợi hoàn tất trước khi rời trang này.",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "Lưu hoặc hủy các thay đổi cấu hình KMS trước khi đặt khóa mới tạo làm khóa mặc định.",
|
||||
"Current": "Current",
|
||||
"Restore": "Restore",
|
||||
"Restore Failed": "Restore Failed",
|
||||
"Restore Success": "Restore Success",
|
||||
"Restore Version": "Restore Version",
|
||||
"This will copy the selected historical version and make it the current object version.": "This will copy the selected historical version and make it the current object version.",
|
||||
"New object name must be different": "Tên đối tượng mới phải khác",
|
||||
"Object name cannot contain slashes": "Tên đối tượng không được chứa dấu gạch chéo",
|
||||
"Object name is required": "Tên đối tượng là bắt buộc",
|
||||
|
||||
+134
-7
@@ -247,7 +247,6 @@
|
||||
"Current Site": "当前站点",
|
||||
"Current User Policy": "当前用户策略",
|
||||
"Current Version": "当前版本",
|
||||
"Current": "当前",
|
||||
"Current backend status for replicated buckets, users, groups, policies, and lifecycle expiry rules.": "复制存储桶、用户、用户组、策略和生命周期过期规则的当前后端状态。",
|
||||
"Current site + remote peers": "当前站点 + 远程对等站点",
|
||||
"Current user policy": "当前用户策略",
|
||||
@@ -926,10 +925,6 @@
|
||||
"Restart KMS": "重启 KMS",
|
||||
"Restart Required": "需要重启",
|
||||
"Restore Key": "恢复密钥",
|
||||
"Restore": "恢复",
|
||||
"Restore Failed": "恢复失败",
|
||||
"Restore Success": "恢复成功",
|
||||
"Restore Version": "恢复版本",
|
||||
"Resume": "恢复",
|
||||
"Resync cancel request sent successfully": "取消重同步请求已成功发送",
|
||||
"Resync request started successfully": "重同步请求已成功启动",
|
||||
@@ -1131,6 +1126,7 @@
|
||||
"The current site is detected automatically by the server. Add one or more remote sites to join the replication group.": "当前站点由服务器自动检测。请添加一个或多个远程站点以加入复制组。",
|
||||
"The directory must be an absolute path on the server.": "该目录必须是服务端的绝对路径。",
|
||||
"The exported file contains sensitive information. Please keep it secure.": "导出的文件包含敏感信息,请妥善保管。",
|
||||
"The secret key is shown only once. Copy or export it before closing.": "Secret Key 只会显示一次,请在关闭前复制或导出。",
|
||||
"The server detects the local site identity from the active RustFS deployment.": "服务器会根据当前 RustFS 部署自动识别本地站点。",
|
||||
"The two passwords are inconsistent": "两个密码不一致",
|
||||
"The value is used only for the initial join request.": "该值仅用于初次加入请求。",
|
||||
@@ -1138,7 +1134,6 @@
|
||||
"This action cannot be undone.": "此操作无法撤销。",
|
||||
"This action retires the selected pool and should be used only after verifying rebalance has completed.": "此操作会退役所选存储池,仅应在确认再平衡已完成后执行。",
|
||||
"This key is used as the platform default SSE key for SSE-KMS and SSE-S3.": "此密钥将作为平台默认 SSE 密钥,用于 SSE-KMS 和 SSE-S3。",
|
||||
"This will copy the selected historical version and make it the current object version.": "这会复制选中的历史版本,并将其设为当前对象版本。",
|
||||
"This key is used as the platform default for SSE-KMS.": "该密钥将作为平台默认的 SSE-KMS 密钥。",
|
||||
"This link will expire when your session ends or at the specified time.": "此链接将会在您的登录会话结束或达到指定时间后失效。",
|
||||
"This permanently deletes the key immediately and cannot be undone.": "这会立即永久删除该密钥,且无法撤销。",
|
||||
@@ -1182,7 +1177,6 @@
|
||||
"Understanding Key Types": "了解密钥类型",
|
||||
"Unhealthy": "异常",
|
||||
"Unknown": "未知",
|
||||
"Degraded": "降级",
|
||||
"Unknown Folder": "未知文件夹",
|
||||
"Unlimited": "无限制",
|
||||
"Unnamed Site": "未命名站点",
|
||||
@@ -1354,6 +1348,139 @@
|
||||
"Decommission cleared": "退役记录已清理",
|
||||
"Failed to clear decommission": "清理退役记录失败",
|
||||
"Clear Decommission": "清理退役",
|
||||
"All": "全部",
|
||||
"All reported servers and disks are online.": "已上报的服务器和磁盘均在线。",
|
||||
"Check your connection, then refresh the status page.": "请检查连接,然后刷新状态页。",
|
||||
"Cluster Health": "集群健康状态",
|
||||
"Cluster information": "集群信息",
|
||||
"Current Scan Started": "当前扫描开始于",
|
||||
"Degraded": "降级",
|
||||
"Endpoint, ascending": "端点,升序",
|
||||
"Endpoint, descending": "端点,降序",
|
||||
"Filter servers": "筛选服务器",
|
||||
"Initializing": "初始化中",
|
||||
"Inventory": "资源概览",
|
||||
"Last Completed Scan": "上次完成扫描",
|
||||
"Last refreshed": "上次刷新",
|
||||
"Needs attention": "需要关注",
|
||||
"Needs attention first": "需要关注的优先",
|
||||
"No drive information was reported for this server.": "该服务器未上报磁盘信息。",
|
||||
"No server information was reported.": "未上报服务器信息。",
|
||||
"No servers match the current filter": "当前筛选条件下没有服务器",
|
||||
"Offline or degraded resources require attention.": "离线或降级资源需要处理。",
|
||||
"Parity and backend settings reported by the cluster.": "集群上报的校验与后端配置。",
|
||||
"Refresh to try again.": "请刷新后重试。",
|
||||
"Refreshing…": "正在刷新…",
|
||||
"Review capacity pressure and scanner activity in one place.": "在一个位置查看容量压力和扫描活动。",
|
||||
"Scan Duration": "扫描时长",
|
||||
"Scanner metrics": "扫描器指标",
|
||||
"Show all servers": "显示全部服务器",
|
||||
"Some resource health data is unavailable or still initializing.": "部分资源健康数据不可用或仍在初始化。",
|
||||
"Some status data is unavailable.": "部分状态数据不可用。",
|
||||
"Storage Configuration": "存储配置",
|
||||
"Storage Usage and Scanner": "存储用量与扫描器",
|
||||
"Storage usage is unavailable for this account.": "当前账号无权查看存储用量。",
|
||||
"Unable to load cluster status. Check your connection and refresh.": "无法加载集群状态。请检查连接后刷新。",
|
||||
"Unavailable": "不可用",
|
||||
"Uptime, longest first": "运行时间最长优先",
|
||||
"Uptime, shortest first": "运行时间最短优先",
|
||||
"Previously loaded values may be stale.": "之前加载的值可能已过期。",
|
||||
"Scanner Status": "扫描器状态",
|
||||
"Scanning": "扫描中",
|
||||
"Never run": "从未运行",
|
||||
"Access & Sharing": "访问与共享",
|
||||
"Add key-value metadata for organization and automation.": "添加用于管理和自动化的键值元数据。",
|
||||
"Automation": "自动化",
|
||||
"Bucket Settings": "存储桶设置",
|
||||
"Capacity & Metadata": "容量与元数据",
|
||||
"Configuration changed. Refresh and try again.": "配置已变更,请刷新后重试。",
|
||||
"Configuration unavailable": "配置不可用",
|
||||
"Configure event delivery, object lifecycle, and replication rules.": "配置事件通知、对象生命周期和复制规则。",
|
||||
"Control who can access this bucket and which browser origins are allowed.": "控制谁可以访问此存储桶,以及允许哪些浏览器来源。",
|
||||
"Controls anonymous and authenticated access to objects in this bucket.": "控制对此存储桶中对象的匿名访问和身份验证访问。",
|
||||
"Controls which web origins can send cross-origin requests to this bucket.": "控制哪些 Web 来源可以向此存储桶发送跨源请求。",
|
||||
"Copy objects to another bucket or site.": "将对象复制到其他存储桶或站点。",
|
||||
"Data Protection": "数据保护",
|
||||
"Delete Event Subscription": "删除事件订阅",
|
||||
"Delete Rule": "删除规则",
|
||||
"Encrypts new objects at rest by default.": "默认对新对象进行静态加密。",
|
||||
"Enter a valid positive quota.": "请输入有效的正数配额。",
|
||||
"Event subscriptions unavailable": "事件订阅不可用",
|
||||
"Expire": "过期",
|
||||
"Gi": "Gi",
|
||||
"GiB": "GiB",
|
||||
"Jump to section": "跳转到分区",
|
||||
"Keep previous object versions and recover from overwrites or deletes.": "保留对象的历史版本,并可从覆盖或删除中恢复。",
|
||||
"Lifecycle rules unavailable": "生命周期规则不可用",
|
||||
"Limits the total data stored in this bucket.": "限制此存储桶可存储的数据总量。",
|
||||
"Loading": "加载中",
|
||||
"Manage encryption, versioning, object lock, and default retention.": "管理加密、版本控制、对象锁定和默认保留策略。",
|
||||
"No event subscriptions": "暂无事件订阅",
|
||||
"No lifecycle rules": "暂无生命周期规则",
|
||||
"No replication rules": "暂无复制规则",
|
||||
"Notify status unavailable": "通知状态不可用",
|
||||
"Open": "打开",
|
||||
"Protects object versions from deletion during a retention period.": "防止在保留期内删除对象版本。",
|
||||
"Quota Unit": "配额单位",
|
||||
"Replication rules unavailable": "复制规则不可用",
|
||||
"Requires Object Lock": "需要启用对象锁定",
|
||||
"Save CORS": "保存 CORS",
|
||||
"Save Encryption": "保存加密设置",
|
||||
"Save Policy": "保存策略",
|
||||
"Save Quota": "保存配额",
|
||||
"Save Retention": "保存保留策略",
|
||||
"Save Tag": "保存标签",
|
||||
"Send notifications when objects change.": "对象发生变化时发送通知。",
|
||||
"Set a storage limit and organize this bucket with tags.": "设置存储容量上限,并使用标签管理此存储桶。",
|
||||
"Sets the default retention mode and period for new object versions.": "设置新对象版本的默认保留模式和保留期。",
|
||||
"Tag key already exists": "标签键已存在",
|
||||
"The replication target was created but could not be rolled back.": "已创建复制目标,但无法回滚。",
|
||||
"The rule was deleted, but the remote target could not be removed.": "规则已删除,但无法移除远程目标。",
|
||||
"This subscription has no stable ID. Refresh before deleting it.": "此订阅没有稳定 ID,请刷新后再删除。",
|
||||
"Transition or expire objects automatically.": "自动转换对象存储类型或使其过期。",
|
||||
"Unable to load event subscriptions. Refresh before making changes.": "无法加载事件订阅,请刷新后再进行更改。",
|
||||
"Unable to load lifecycle rules. Refresh before making changes.": "无法加载生命周期规则,请刷新后再进行更改。",
|
||||
"Unable to load replication rules. Refresh before making changes.": "无法加载复制规则,请刷新后再进行更改。",
|
||||
"Unable to load this setting. Refresh before making changes.": "无法加载此设置,请刷新后再进行更改。",
|
||||
"Unable to verify whether notify is enabled. Refresh before making changes.": "无法验证通知是否已启用,请刷新后再进行更改。",
|
||||
"Unnamed rule": "未命名规则",
|
||||
"Unnamed subscription": "未命名订阅",
|
||||
"Versioning": "版本控制",
|
||||
"Versioning can be suspended but existing versions are preserved.": "可以暂停版本控制,但现有版本将保留。",
|
||||
"Versioning stays enabled while Object Lock is active.": "启用对象锁定期间,版本控制将保持启用。",
|
||||
"bucket": "存储桶",
|
||||
"The remote target may have been saved. Review the replication configuration before retrying.": "远程目标可能已保存。重试前请检查复制配置。",
|
||||
"Public access can expose objects to anyone allowed by the policy. Review it before saving.": "公共访问可能会向策略允许的任何人公开对象,保存前请仔细检查。",
|
||||
"Removes the bucket policy. IAM and credential-based access still apply.": "移除存储桶策略。IAM 和基于凭证的访问仍然有效。",
|
||||
"Uses the JSON policy below. Invalid statements can block access.": "使用下方的 JSON 策略。无效的语句可能会阻止访问。",
|
||||
"Uses the selected KMS key to encrypt new objects.": "使用所选 KMS 密钥加密新对象。",
|
||||
"Uses server-managed keys to encrypt new objects.": "使用服务器管理的密钥加密新对象。",
|
||||
"Retention cannot be shortened or bypassed until it expires.": "保留期到期前无法缩短或绕过。",
|
||||
"Authorized users can bypass or shorten retention when necessary.": "授权用户可在必要时绕过或缩短保留期。",
|
||||
"Unable to load versioning status.": "无法加载版本控制状态。",
|
||||
"Unable to load storage tiers.": "无法加载存储层级。",
|
||||
"Unable to load event targets.": "无法加载事件目标。",
|
||||
"Backend and default key": "后端与默认密钥",
|
||||
"Cannot delete the default SSE key. Choose another default key first.": "无法删除默认 SSE 密钥。请先将其他密钥设为默认密钥。",
|
||||
"KMS Cache": "KMS 缓存",
|
||||
"Reliability": "可靠性",
|
||||
"Vault connection": "Vault 连接",
|
||||
"Changing KMS service state may briefly interrupt SSE requests.": "更改 KMS 服务状态可能会短暂中断 SSE 请求。",
|
||||
"KMS will be stopped. SSE and key management will be unavailable until you start KMS again.": "KMS 将停止。在重新启动 KMS 之前,SSE 和密钥管理将不可用。",
|
||||
"You have unsaved KMS changes. Do you want to discard them?": "KMS 有未保存的更改。是否放弃这些更改?",
|
||||
"The operation result is uncertain. Current status has been refreshed.": "操作结果尚不确定,当前状态已刷新。",
|
||||
"Please enter a Local KMS master key.": "请输入本地 KMS 主密钥。",
|
||||
"Enter this every time you save Local KMS configuration. It is never shown or retained.": "每次保存本地 KMS 配置时都需要输入。该密钥不会显示或保留。",
|
||||
"Local filesystem KMS configuration is read-only in Console until safe master-key rotation is available.": "在支持安全轮换主密钥之前,控制台中的本地文件系统 KMS 配置为只读。",
|
||||
"Set a default key for Local KMS in server configuration.": "请在服务器配置中为本地 KMS 设置默认密钥。",
|
||||
"An operation is still in progress. Wait for it to finish before leaving this page.": "操作仍在进行中,请等待完成后再离开此页面。",
|
||||
"Save or discard KMS configuration changes before setting a newly created default key.": "将新建密钥设为默认密钥前,请先保存或放弃 KMS 配置更改。",
|
||||
"Current": "当前",
|
||||
"Restore": "恢复",
|
||||
"Restore Failed": "恢复失败",
|
||||
"Restore Success": "恢复成功",
|
||||
"Restore Version": "恢复版本",
|
||||
"This will copy the selected historical version and make it the current object version.": "这会复制选中的历史版本,并将其设为当前对象版本。",
|
||||
"New object name must be different": "新对象名称必须不同",
|
||||
"Object name cannot contain slashes": "对象名称不能包含斜杠",
|
||||
"Object name is required": "对象名称不能为空",
|
||||
|
||||
+41
-7
@@ -21,14 +21,44 @@ interface RequestOptions {
|
||||
* This allows components to handle permission errors gracefully
|
||||
*/
|
||||
suppress403Redirect?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const inflightGetRequests = new Map<string, Promise<unknown>>()
|
||||
const SENSITIVE_LOG_KEY = /(?:token|secret|password|authorization|cookie|api[_-]?key|master[_-]?key)/i
|
||||
|
||||
function redactLogValue(value: unknown, key?: string): unknown {
|
||||
if (key && SENSITIVE_LOG_KEY.test(key)) return "[REDACTED]"
|
||||
if (Array.isArray(value)) return value.map((item) => redactLogValue(item))
|
||||
if (!value || typeof value !== "object") return value
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([entryKey, entryValue]) => [
|
||||
entryKey,
|
||||
redactLogValue(entryValue, entryKey),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function redactRequestOptionsForLog(options: RequestOptions) {
|
||||
const body =
|
||||
typeof options.body === "string"
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.stringify(redactLogValue(JSON.parse(options.body)))
|
||||
} catch {
|
||||
return options.body
|
||||
}
|
||||
})()
|
||||
: options.body
|
||||
|
||||
return { ...options, body }
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private $api: AwsClient
|
||||
private config?: ApiClientOptions
|
||||
private errorHandler?: IApiErrorHandler
|
||||
private inflightGetRequests = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(api: AwsClient, options?: ApiClientOptions) {
|
||||
this.$api = api
|
||||
@@ -71,7 +101,7 @@ export class ApiClient {
|
||||
|
||||
const executeRequest = async () => {
|
||||
logger.log("[request] url:", url)
|
||||
logger.log("[request] options:", options)
|
||||
logger.log("[request] options:", redactRequestOptionsForLog(options))
|
||||
|
||||
const response = await this.$api.fetch(
|
||||
url,
|
||||
@@ -91,7 +121,9 @@ export class ApiClient {
|
||||
// This allows components to handle permission errors gracefully
|
||||
if (options.suppress403Redirect) {
|
||||
const errorMsg = await parseApiError(response)
|
||||
throw new Error(errorMsg)
|
||||
const error = new Error(errorMsg) as Error & { status: number }
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -133,7 +165,9 @@ export class ApiClient {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = await parseApiError(response)
|
||||
throw new Error(errorMsg)
|
||||
const error = new Error(errorMsg) as Error & { status: number }
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.headers.get("content-length") === "0" || !response.body) {
|
||||
@@ -151,17 +185,17 @@ export class ApiClient {
|
||||
return executeRequest()
|
||||
}
|
||||
|
||||
const inflight = inflightGetRequests.get(dedupeKey)
|
||||
const inflight = this.inflightGetRequests.get(dedupeKey)
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
|
||||
const requestPromise = executeRequest()
|
||||
inflightGetRequests.set(dedupeKey, requestPromise)
|
||||
this.inflightGetRequests.set(dedupeKey, requestPromise)
|
||||
try {
|
||||
return await requestPromise
|
||||
} finally {
|
||||
inflightGetRequests.delete(dedupeKey)
|
||||
this.inflightGetRequests.delete(dedupeKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export type BucketConfigurationKind =
|
||||
| "lifecycle"
|
||||
| "replication"
|
||||
| "policy"
|
||||
| "encryption"
|
||||
| "cors"
|
||||
| "tagging"
|
||||
| "objectLock"
|
||||
| "quota"
|
||||
|
||||
const MISSING_CONFIGURATION_CODES: Record<BucketConfigurationKind, Set<string>> = {
|
||||
lifecycle: new Set(["NoSuchLifecycleConfiguration"]),
|
||||
replication: new Set(["ReplicationConfigurationNotFoundError", "NoSuchReplicationConfiguration"]),
|
||||
policy: new Set(["NoSuchBucketPolicy"]),
|
||||
encryption: new Set(["ServerSideEncryptionConfigurationNotFoundError"]),
|
||||
cors: new Set(["NoSuchCORSConfiguration"]),
|
||||
tagging: new Set(["NoSuchTagSet"]),
|
||||
objectLock: new Set(["ObjectLockConfigurationNotFoundError", "NoSuchObjectLockConfiguration"]),
|
||||
quota: new Set(["NoSuchBucketQuota"]),
|
||||
}
|
||||
|
||||
export function isMissingBucketConfiguration(error: unknown, kind: BucketConfigurationKind): boolean {
|
||||
if (!error || typeof error !== "object") return false
|
||||
|
||||
const candidate = error as {
|
||||
name?: unknown
|
||||
code?: unknown
|
||||
Code?: unknown
|
||||
Error?: { Code?: unknown }
|
||||
status?: unknown
|
||||
statusCode?: unknown
|
||||
$metadata?: { httpStatusCode?: unknown }
|
||||
}
|
||||
const codes = [candidate.name, candidate.code, candidate.Code, candidate.Error?.Code].filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
)
|
||||
|
||||
if (codes.some((code) => MISSING_CONFIGURATION_CODES[kind].has(code))) return true
|
||||
|
||||
if (kind !== "quota") return false
|
||||
const statuses = [candidate.status, candidate.statusCode, candidate.$metadata?.httpStatusCode]
|
||||
return statuses.some((status) => status === 404)
|
||||
}
|
||||
|
||||
export function removeMatchingBucketRule<T extends { ID?: string }>(rules: T[], target: T): T[] | null {
|
||||
const matches = target.ID
|
||||
? rules.filter((rule) => rule.ID === target.ID)
|
||||
: rules.filter((rule) => JSON.stringify(rule) === JSON.stringify(target))
|
||||
|
||||
if (matches.length !== 1) return null
|
||||
|
||||
return target.ID
|
||||
? rules.filter((rule) => rule.ID !== target.ID)
|
||||
: rules.filter((rule) => JSON.stringify(rule) !== JSON.stringify(target))
|
||||
}
|
||||
|
||||
export function normalizeReplicationRulesForRolelessConfig<T extends { Destination?: { Bucket?: string } }>(
|
||||
rules: T[],
|
||||
currentRole?: string,
|
||||
): T[] {
|
||||
const role = currentRole?.trim()
|
||||
if (!role) return rules
|
||||
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
Destination: {
|
||||
...rule.Destination,
|
||||
Bucket: role,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function createLatestRequestGate() {
|
||||
let version = 0
|
||||
return {
|
||||
begin: () => ++version,
|
||||
isCurrent: (candidate: number) => candidate === version,
|
||||
invalidate: () => {
|
||||
version += 1
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface LifecycleFilterTag {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function buildLifecycleFilter(prefix: string, tags: LifecycleFilterTag[]): Record<string, unknown> {
|
||||
const validTags = tags.filter((tag) => tag.key && tag.value)
|
||||
if (!prefix && validTags.length === 0) return { Prefix: "" }
|
||||
|
||||
const [firstTag] = validTags
|
||||
if (firstTag && validTags.length === 1 && !prefix) {
|
||||
return { Tag: { Key: firstTag.key, Value: firstTag.value } }
|
||||
}
|
||||
|
||||
if (validTags.length > 0) {
|
||||
return {
|
||||
And: {
|
||||
Tags: validTags.map((tag) => ({ Key: tag.key, Value: tag.value })),
|
||||
...(prefix ? { Prefix: prefix } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { Prefix: prefix }
|
||||
}
|
||||
|
||||
export function buildCurrentVersionExpiration(days: number, expiredDeleteMarker: boolean): Record<string, unknown> {
|
||||
return expiredDeleteMarker ? { ExpiredObjectDeleteMarker: true } : { Days: days }
|
||||
}
|
||||
@@ -25,6 +25,24 @@ const getXmlTagText = (xml: string, tagName: string): string | null => {
|
||||
return normalizeErrorText(match?.[1])
|
||||
}
|
||||
|
||||
export const getXmlErrorCode = (xml: string): string | null => {
|
||||
const trimmed = xml.trim()
|
||||
if (!trimmed.startsWith("<")) return null
|
||||
|
||||
if (typeof DOMParser !== "undefined") {
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(trimmed, "text/xml")
|
||||
if (!doc.querySelector("parsererror")) {
|
||||
return normalizeErrorText(doc.querySelector("Code")?.textContent)
|
||||
}
|
||||
} catch {
|
||||
// Fall through to regex-based parsing.
|
||||
}
|
||||
}
|
||||
|
||||
return getXmlTagText(trimmed, "Code")
|
||||
}
|
||||
|
||||
export const getXmlErrorMessage = (xml: string): string | null => {
|
||||
const trimmed = xml.trim()
|
||||
if (!trimmed.startsWith("<")) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string
|
||||
sourceId?: string
|
||||
type: "Lambda" | "SQS" | "SNS" | "Topic"
|
||||
arn: string
|
||||
events: string[]
|
||||
|
||||
@@ -9,6 +9,10 @@ export function shouldShowDeleteAllVersions(state: BucketVersioningState): boole
|
||||
}
|
||||
|
||||
export function shouldForceDeleteObjects(state: BucketVersioningState, deleteAllVersions: boolean): boolean {
|
||||
if (state === "unknown") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state === "enabled") {
|
||||
return deleteAllVersions
|
||||
}
|
||||
|
||||
+164
-19
@@ -1,6 +1,8 @@
|
||||
export type ServerHealthState = "online" | "offline" | "degraded" | "initializing" | "unknown"
|
||||
|
||||
export interface ServerInfo {
|
||||
endpoint?: string
|
||||
state?: string
|
||||
state?: ServerHealthState
|
||||
version?: string
|
||||
uptime?: number
|
||||
drives?: Array<{
|
||||
@@ -43,6 +45,7 @@ export interface StorageInfo {
|
||||
export interface MetricsInfo {
|
||||
aggregated?: {
|
||||
scanner?: {
|
||||
current_cycle?: number
|
||||
current_started?: string
|
||||
cycle_complete_times?: string[]
|
||||
}
|
||||
@@ -51,8 +54,6 @@ export interface MetricsInfo {
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
export type ServerHealthState = "online" | "offline" | "unknown" | "degraded"
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}
|
||||
}
|
||||
@@ -70,18 +71,71 @@ function asNumber(value: unknown): number | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function asNonNegativeNumber(value: unknown): number | undefined {
|
||||
const number = asNumber(value)
|
||||
return number !== undefined && number >= 0 ? number : undefined
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() !== "" ? value : undefined
|
||||
}
|
||||
|
||||
export function normalizeServerHealthState(state?: string | null): ServerHealthState {
|
||||
const normalized = state?.trim().toLowerCase()
|
||||
if (normalized === "online" || normalized === "offline" || normalized === "unknown" || normalized === "degraded") {
|
||||
return normalized
|
||||
function asStringOrNumber(value: unknown): string | number | undefined {
|
||||
return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) ? value : undefined
|
||||
}
|
||||
|
||||
function asTimestamp(value: unknown): string | undefined {
|
||||
const timestamp = asString(value)
|
||||
return timestamp && !Number.isNaN(Date.parse(timestamp)) ? timestamp : undefined
|
||||
}
|
||||
|
||||
export function normalizeServerHealthState(value: unknown): ServerHealthState {
|
||||
const state = asString(value)?.toLowerCase()
|
||||
if (state === "online" || state === "offline" || state === "degraded" || state === "initializing") {
|
||||
return state
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function normalizeDrive(value: unknown): NonNullable<ServerInfo["drives"]>[number] | undefined {
|
||||
const source = asRecord(value)
|
||||
if (!Object.keys(source).length) return undefined
|
||||
|
||||
return {
|
||||
uuid: asString(source.uuid ?? source.UUID),
|
||||
drive_path: asString(source.drive_path ?? source.drivePath ?? source.DrivePath),
|
||||
path: asString(source.path ?? source.Path),
|
||||
usedspace: asNonNegativeNumber(source.usedspace ?? source.usedSpace ?? source.UsedSpace),
|
||||
totalspace: asNonNegativeNumber(source.totalspace ?? source.totalSpace ?? source.TotalSpace),
|
||||
availspace: asNonNegativeNumber(source.availspace ?? source.availableSpace ?? source.AvailableSpace),
|
||||
state: asString(source.state ?? source.State)?.toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServer(value: unknown): ServerInfo | undefined {
|
||||
const source = asRecord(value)
|
||||
if (!Object.keys(source).length) return undefined
|
||||
|
||||
const network = Object.fromEntries(
|
||||
Object.entries(asRecord(source.network ?? source.Network)).flatMap(([key, state]) => {
|
||||
const normalizedState = asString(state)?.toLowerCase()
|
||||
return normalizedState ? [[key, normalizedState]] : []
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
endpoint: asString(source.endpoint ?? source.Endpoint),
|
||||
state: normalizeServerHealthState(source.state ?? source.State),
|
||||
version: asString(source.version ?? source.Version),
|
||||
uptime: asNonNegativeNumber(source.uptime ?? source.Uptime),
|
||||
drives: asArray<unknown>(source.drives ?? source.Drives).flatMap((drive) => {
|
||||
const normalized = normalizeDrive(drive)
|
||||
return normalized ? [normalized] : []
|
||||
}),
|
||||
network,
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapInfoRecord(value: unknown): JsonRecord {
|
||||
const record = asRecord(value)
|
||||
const wrapped = record.info ?? record.Info
|
||||
@@ -90,36 +144,127 @@ function unwrapInfoRecord(value: unknown): JsonRecord {
|
||||
|
||||
function normalizeCountInfo(value: unknown): { count?: number } | undefined {
|
||||
const record = asRecord(value)
|
||||
const count = asNumber(record.count ?? record.Count)
|
||||
if (record.error ?? record.Error) return undefined
|
||||
const count = asNonNegativeNumber(record.count ?? record.Count)
|
||||
return count === undefined ? undefined : { count }
|
||||
}
|
||||
|
||||
export function normalizeSystemInfo(value: unknown): SystemInfo {
|
||||
const source = unwrapInfoRecord(value)
|
||||
const backend = asRecord(source.backend ?? source.Backend)
|
||||
const buckets = normalizeCountInfo(source.buckets ?? source.Buckets)
|
||||
const objects = normalizeCountInfo(source.objects ?? source.Objects)
|
||||
const rawServers = source.servers ?? source.Servers
|
||||
const servers = Array.isArray(rawServers)
|
||||
? rawServers.flatMap((server) => {
|
||||
const normalized = normalizeServer(server)
|
||||
return normalized ? [normalized] : []
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
buckets: normalizeCountInfo(source.buckets ?? source.Buckets),
|
||||
objects: normalizeCountInfo(source.objects ?? source.Objects),
|
||||
servers: asArray<ServerInfo>(source.servers ?? source.Servers),
|
||||
backend: {
|
||||
backendType: asString(backend.backendType ?? backend.BackendType),
|
||||
onlineDisks: asNumber(backend.onlineDisks ?? backend.OnlineDisks),
|
||||
offlineDisks: asNumber(backend.offlineDisks ?? backend.OfflineDisks),
|
||||
unknownDisks: asNumber(backend.unknownDisks ?? backend.UnknownDisks),
|
||||
},
|
||||
...(buckets ? { buckets } : {}),
|
||||
...(objects ? { objects } : {}),
|
||||
...(servers ? { servers } : {}),
|
||||
...(Object.keys(backend).length
|
||||
? {
|
||||
backend: {
|
||||
backendType: asString(backend.backendType ?? backend.BackendType),
|
||||
onlineDisks: asNonNegativeNumber(backend.onlineDisks ?? backend.OnlineDisks),
|
||||
offlineDisks: asNonNegativeNumber(backend.offlineDisks ?? backend.OfflineDisks),
|
||||
unknownDisks: asNonNegativeNumber(backend.unknownDisks ?? backend.UnknownDisks),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStorageInfo(value: unknown): StorageInfo {
|
||||
const source = unwrapInfoRecord(value)
|
||||
const backend = asRecord(source.backend ?? source.Backend)
|
||||
if (!Object.keys(backend).length) return {}
|
||||
|
||||
return {
|
||||
backend: {
|
||||
BackendType: asString(backend.BackendType ?? backend.backendType),
|
||||
StandardSCParity: (backend.StandardSCParity ?? backend.standardSCParity) as string | number | undefined,
|
||||
RRSCParity: (backend.RRSCParity ?? backend.rrSCParity) as string | number | undefined,
|
||||
StandardSCParity: asStringOrNumber(backend.StandardSCParity ?? backend.standardSCParity),
|
||||
RRSCParity: asStringOrNumber(backend.RRSCParity ?? backend.rrSCParity),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDataUsageInfo(value: unknown): DataUsageInfo {
|
||||
const source = unwrapInfoRecord(value)
|
||||
return {
|
||||
...(asNonNegativeNumber(source.total_capacity ?? source.totalCapacity ?? source.TotalCapacity) !== undefined
|
||||
? { total_capacity: asNonNegativeNumber(source.total_capacity ?? source.totalCapacity ?? source.TotalCapacity) }
|
||||
: {}),
|
||||
...(asNonNegativeNumber(source.total_used_capacity ?? source.totalUsedCapacity ?? source.TotalUsedCapacity) !==
|
||||
undefined
|
||||
? {
|
||||
total_used_capacity: asNonNegativeNumber(
|
||||
source.total_used_capacity ?? source.totalUsedCapacity ?? source.TotalUsedCapacity,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMetricsInfo(value: unknown): MetricsInfo {
|
||||
const source = unwrapInfoRecord(value)
|
||||
const aggregated = asRecord(source.aggregated ?? source.Aggregated)
|
||||
const scanner = asRecord(aggregated.scanner ?? aggregated.Scanner)
|
||||
const currentCycle = asNonNegativeNumber(scanner.current_cycle ?? scanner.currentCycle)
|
||||
const currentStarted = asTimestamp(scanner.current_started ?? scanner.currentStarted)
|
||||
const cycleCompleteTimes = asArray<unknown>(scanner.cycle_complete_times ?? scanner.cycleCompleteTimes).flatMap(
|
||||
(item) => {
|
||||
const timestamp = asTimestamp(item)
|
||||
return timestamp ? [timestamp] : []
|
||||
},
|
||||
)
|
||||
|
||||
if (currentCycle === undefined && !currentStarted && !cycleCompleteTimes.length) return {}
|
||||
|
||||
return {
|
||||
aggregated: {
|
||||
scanner: {
|
||||
...(currentCycle !== undefined ? { current_cycle: currentCycle } : {}),
|
||||
...(currentStarted ? { current_started: currentStarted } : {}),
|
||||
...(cycleCompleteTimes.length ? { cycle_complete_times: cycleCompleteTimes } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeServerStates(servers: ServerInfo[] | undefined): Record<ServerHealthState, number> {
|
||||
const summary: Record<ServerHealthState, number> = {
|
||||
online: 0,
|
||||
offline: 0,
|
||||
degraded: 0,
|
||||
initializing: 0,
|
||||
unknown: 0,
|
||||
}
|
||||
|
||||
for (const server of servers ?? []) {
|
||||
summary[normalizeServerHealthState(server.state)] += 1
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: string | Date, locale: string | undefined, now = Date.now()) {
|
||||
const time = timestamp instanceof Date ? timestamp.getTime() : Date.parse(timestamp)
|
||||
if (!Number.isFinite(time)) return undefined
|
||||
|
||||
const difference = time - now
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31_536_000_000],
|
||||
["month", 2_592_000_000],
|
||||
["day", 86_400_000],
|
||||
["hour", 3_600_000],
|
||||
["minute", 60_000],
|
||||
["second", 1_000],
|
||||
]
|
||||
const [unit, size] = units.find(([, unitSize]) => Math.abs(difference) >= unitSize) ?? units.at(-1)!
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(Math.round(difference / size), unit)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export function buildAddUsersToGroupsRequests(users, groups) {
|
||||
const normalizedUsers = users.map((user) => user.trim()).filter(Boolean)
|
||||
const normalizedGroups = groups.map((group) => group.trim()).filter(Boolean)
|
||||
const normalizedUsers = Array.from(new Set(users.map((user) => user.trim()).filter(Boolean)))
|
||||
const normalizedGroups = Array.from(new Set(groups.map((group) => group.trim()).filter(Boolean)))
|
||||
if (!normalizedUsers.length) return []
|
||||
|
||||
return normalizedUsers.flatMap((user) =>
|
||||
normalizedGroups.map((group) => ({
|
||||
group,
|
||||
members: [user],
|
||||
isRemove: false,
|
||||
groupStatus: "enabled",
|
||||
})),
|
||||
)
|
||||
return normalizedGroups.map((group) => ({
|
||||
group,
|
||||
members: normalizedUsers,
|
||||
isRemove: false,
|
||||
groupStatus: "enabled",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,15 +6,14 @@ export interface UpdateGroupMembersRequest {
|
||||
}
|
||||
|
||||
export function buildAddUsersToGroupsRequests(users: string[], groups: string[]): UpdateGroupMembersRequest[] {
|
||||
const normalizedUsers = users.map((user) => user.trim()).filter(Boolean)
|
||||
const normalizedGroups = groups.map((group) => group.trim()).filter(Boolean)
|
||||
const normalizedUsers = Array.from(new Set(users.map((user) => user.trim()).filter(Boolean)))
|
||||
const normalizedGroups = Array.from(new Set(groups.map((group) => group.trim()).filter(Boolean)))
|
||||
if (!normalizedUsers.length) return []
|
||||
|
||||
return normalizedUsers.flatMap((user) =>
|
||||
normalizedGroups.map((group) => ({
|
||||
group,
|
||||
members: [user],
|
||||
isRemove: false,
|
||||
groupStatus: "enabled",
|
||||
})),
|
||||
)
|
||||
return normalizedGroups.map((group) => ({
|
||||
group,
|
||||
members: normalizedUsers,
|
||||
isRemove: false,
|
||||
groupStatus: "enabled",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: rustfs-console-design-guide
|
||||
description: Design and review RustFS Console interfaces with a consistent visual hierarchy, interaction model, responsive behavior, and operational safety. Use this skill whenever creating or changing Console pages, settings, forms, dialogs, tables, status views, destructive flows, mobile layouts, or UI screenshots—even when the request is described only as frontend implementation or visual polish.
|
||||
---
|
||||
|
||||
# RustFS Console Design Guide
|
||||
|
||||
Use this guide while shaping the interface, before implementation details harden. Treat Console as an operational tool: the design should help people understand system state, make a deliberate change, and recover when reality is uncertain.
|
||||
|
||||
## Design objectives
|
||||
|
||||
Every screen should make four things obvious:
|
||||
|
||||
1. Where the user is.
|
||||
2. What the system currently knows.
|
||||
3. What the user can safely do next.
|
||||
4. How to recover when a read or write fails.
|
||||
|
||||
Prefer calm hierarchy over decoration. Density is acceptable when relationships remain clear; visual noise is not.
|
||||
|
||||
## 1. Page composition
|
||||
|
||||
Build pages in this order:
|
||||
|
||||
1. Page identity: title, concise purpose, and scope such as the active bucket or node.
|
||||
2. Current state: trusted status, important constraints, and freshness when relevant.
|
||||
3. Primary task: the action or configuration the page exists to support.
|
||||
4. Supporting detail: history, diagnostics, advanced controls, and secondary actions.
|
||||
|
||||
Keep the primary action near the content it affects. Avoid detached action bars whose target is ambiguous.
|
||||
|
||||
On scoped detail pages, keep Back navigation and page-level Add/Refresh actions in the same page header. Do not repeat a second toolbar immediately above the table unless it controls only table-local state such as selection or filtering.
|
||||
|
||||
For short authentication forms, constrain the form measure independently from the surrounding split panel. A wider available pane should not make credential fields unnecessarily wide.
|
||||
|
||||
Use cards only when content is a distinct surface with its own identity or interaction. Do not turn every section into a card.
|
||||
|
||||
## 2. Visual hierarchy and surfaces
|
||||
|
||||
Use visual weight to express containment, not to decorate every group.
|
||||
|
||||
### Border depth
|
||||
|
||||
- Give one visual region at most one complete outline.
|
||||
- Keep at most two structural frame levels. A nested level should normally use spacing, a muted background, or a one-way separator instead of another box.
|
||||
- Reserve complete borders for the outer surface, form controls, warnings/errors, and explicit interactive choices.
|
||||
- Use `fieldset`/`legend`, `section`/heading, or `dl`/`dt`/`dd` to express structure without adding frames.
|
||||
- Do not place a bordered card inside a bordered fieldset inside a bordered dialog merely to create grouping.
|
||||
- Use dividers only between peer sections. Keep one aligned divider system per surface; do not add dividers recursively to an ancestor and its descendants.
|
||||
- A divider is not a generic replacement for a removed box. If it does not clarify which sections are peers, use spacing or a heading instead.
|
||||
|
||||
Before adding a border, ask: “Would spacing or a heading communicate the same relationship?” If yes, do not add the border.
|
||||
|
||||
### Spacing
|
||||
|
||||
- Use consistent vertical rhythm to show section boundaries.
|
||||
- Keep labels close to their controls and descriptions close to the content they explain.
|
||||
- Give major sections more separation than fields within a section.
|
||||
- Avoid large empty areas that push the main action below the fold.
|
||||
|
||||
### Typography
|
||||
|
||||
- Use real heading levels for page and section hierarchy.
|
||||
- Keep descriptions short and visually secondary without reducing contrast below an accessible level.
|
||||
- Use monospaced text only for identifiers, paths, keys, and code-like values.
|
||||
- Allow identifiers and filenames to wrap or truncate with a way to inspect the full value.
|
||||
|
||||
### Color
|
||||
|
||||
- Use color to communicate status and action consequence, not as the only differentiator.
|
||||
- Keep neutral surfaces visually quiet so warnings, errors, and active selections retain meaning.
|
||||
- Pair status color with text or an icon that has an accessible label.
|
||||
|
||||
## 3. Settings and forms
|
||||
|
||||
Organize settings around user decisions rather than API payload structure.
|
||||
|
||||
### Form anatomy
|
||||
|
||||
1. Explain what the setting controls and its operational scope.
|
||||
2. Show required identity and connection fields first.
|
||||
3. Group related fields under a semantic heading or `fieldset`.
|
||||
4. Move expert-only retry, cache, mapping, or diagnostic controls into Advanced Settings.
|
||||
5. Keep Save and Cancel reachable without scrolling the entire page back to the top.
|
||||
|
||||
Do not hide required fields, the current state, or the primary action inside progressive disclosure. Reopen an advanced section automatically when validation targets a field inside it.
|
||||
|
||||
### Controls
|
||||
|
||||
- Give every input a visible label unless the surrounding pattern supplies an unambiguous accessible name.
|
||||
- Keep avatar artwork smaller than its button or menu trigger so the trigger retains a visible inset and focus/hover surface. The artwork must not consume the complete interactive target.
|
||||
- Use helper text for format or consequence, not to restate the label.
|
||||
- Use switches for immediate binary settings and checkboxes for independent selections. Do not use a switch when the change still requires a separate Save unless the surrounding form makes that deferred behavior clear.
|
||||
- Use bordered radio tiles only when each option is a meaningful selectable object with supporting content. Plain radio groups need no card around every item.
|
||||
- Preserve entered values after validation errors or failed requests.
|
||||
|
||||
### Validation
|
||||
|
||||
- Place the error next to the affected field and announce a concise summary.
|
||||
- Focus the first invalid field after submission.
|
||||
- Explain how to correct the value; do not show only a generic failure toast.
|
||||
- Keep server uncertainty distinct from client validation.
|
||||
|
||||
## 4. Dialogs and layered flows
|
||||
|
||||
A dialog should represent one focused decision or task.
|
||||
|
||||
- Use a stable header, one scrolling body, and a stable footer for long forms.
|
||||
- Keep the title, target, and consequence visible while the body scrolls.
|
||||
- For a form dominated by one large editor, keep the surrounding metadata and actions fixed and make the editor the bounded scrolling surface. Do not scroll the whole form just because the editor content is long.
|
||||
- Size the dialog for its content but keep it within the dynamic viewport on mobile.
|
||||
- Avoid nested dialogs. When a subtask must open another layer, preserve the parent state and restore focus deliberately.
|
||||
- Disable accidental dismissal while a submission or destructive operation is in flight.
|
||||
- Do not repeat the dialog outline with bordered wrappers around every subsection.
|
||||
|
||||
For destructive dialogs, name the exact target, state the consequence in plain language, and visually separate the destructive action from Cancel. Prefer staged or reversible actions when the product supports them.
|
||||
|
||||
## 5. System state and feedback
|
||||
|
||||
Design these as distinct states:
|
||||
|
||||
- Loading: the answer is not known yet.
|
||||
- Empty or unconfigured: a trusted response confirms absence.
|
||||
- Error: the read or action failed.
|
||||
- Stale: a previous trusted value remains visible but may no longer be current.
|
||||
- Partial: some independent data sources succeeded and others failed.
|
||||
- Success: the result has been confirmed, not merely requested.
|
||||
|
||||
Never make a failed read look like an empty configuration. Keep stale values visible when useful, mark them as stale, and disable actions that depend on fresh state.
|
||||
|
||||
Place retry next to the failure it addresses. Use toasts as supplemental feedback, not as the only durable explanation for a broken page section.
|
||||
|
||||
For long operations, show the target, current phase, progress when meaningful, and the next safe action. A progress strip needs an accessible name and announced updates; it does not need another full frame inside its container.
|
||||
|
||||
## 6. Tables, lists, and selection
|
||||
|
||||
- Use the shared `DataTable` for tabular relationships and give each table an accessible caption.
|
||||
- Keep row actions visible and identifiable; do not rely only on hover.
|
||||
- Avoid nested horizontal scroll containers.
|
||||
- On narrow screens, convert dense rows into cards or a deliberate reduced-column layout without dropping actions.
|
||||
- Show loading, error, empty, and filtered-empty states separately.
|
||||
- Keep bulk selection status and bulk actions close to the selected content.
|
||||
|
||||
Use definition lists for compact key/value metadata. A grid of individually bordered cards is usually too heavy for passive details.
|
||||
|
||||
## 7. Responsive behavior
|
||||
|
||||
Start with the smallest supported viewport, then expand.
|
||||
|
||||
- Maintain desktop/mobile action parity.
|
||||
- Use dynamic viewport units where browser chrome can reduce usable height.
|
||||
- Keep touch targets at least 44px where practical.
|
||||
- Preserve 44px touch targets on small screens, but let desktop toolbar buttons and selects return to the compact control height. Do not force mobile minimum heights onto desktop action rows.
|
||||
- Let toolbars stack; do not force two-column controls into a narrow row.
|
||||
- Keep one predictable scrolling surface per dialog or panel.
|
||||
- Test long translations, identifiers, filenames, error messages, and selected-item lists.
|
||||
|
||||
Responsive design may change composition, but it must not remove meaning, safety context, or recovery actions.
|
||||
|
||||
## 8. Accessibility as part of the design
|
||||
|
||||
- Use semantic headings, regions, fieldsets, legends, tables, captions, and definition lists before adding ARIA.
|
||||
- Make every control reachable and operable by keyboard with visible focus.
|
||||
- Give search, progress, listbox, and icon-only controls explicit accessible names.
|
||||
- Return focus to the initiating control when a dialog or editing mode closes.
|
||||
- Associate field errors and descriptions programmatically.
|
||||
- Do not encode state through color alone.
|
||||
|
||||
Accessibility is a composition constraint, not a cleanup pass after the visual design is finished.
|
||||
|
||||
## 9. Design review workflow
|
||||
|
||||
For broad UI work, inspect the whole task flow before polishing individual components.
|
||||
|
||||
1. Capture the current state at the same desktop and mobile viewports that will be reviewed later.
|
||||
2. Identify the user task, trusted system state, primary action, failure path, and narrow-screen composition.
|
||||
3. Review from at least three perspectives: visual hierarchy, interaction/recovery, and accessibility.
|
||||
4. Reconcile conflicts explicitly. For example, removing borders must not remove the affordance of a clickable choice or the prominence of an error.
|
||||
5. Implement the smallest coherent design change.
|
||||
6. Recapture the exact state and compare it with the original.
|
||||
|
||||
Record each concrete finding in `docs/ui-review/register.md` and map screenshots in the Console UI review manifest. Label static fixtures as illustrative evidence; do not present them as runtime proof.
|
||||
|
||||
## 10. Common failure patterns
|
||||
|
||||
Avoid these patterns:
|
||||
|
||||
- Box inside box inside box for ordinary grouping.
|
||||
- A failed settings request rendered as an empty bordered panel.
|
||||
- Advanced fields mixed into the required first-time setup path.
|
||||
- Dialog actions pushed outside the viewport.
|
||||
- Desktop row actions missing from mobile cards.
|
||||
- Destructive confirmation that does not name the target.
|
||||
- Generic toast with no persistent local recovery path.
|
||||
- Passive metadata styled like selectable cards.
|
||||
- Multiple independent scroll areas inside one dialog.
|
||||
- Screenshot comparisons captured from different states or viewports.
|
||||
|
||||
## Review prompts
|
||||
|
||||
Before approving a design, ask:
|
||||
|
||||
- Is the current system state trustworthy and visibly distinct from loading, empty, stale, or failed states?
|
||||
- Is the primary task obvious without scanning every control?
|
||||
- Does every complete border communicate a real surface or affordance?
|
||||
- Could spacing, a heading, or a separator replace any nested box?
|
||||
- Are expert controls separated without hiding required decisions?
|
||||
- Can the user recover in context after every meaningful failure?
|
||||
- Does mobile preserve every meaningful action and safety cue?
|
||||
- Can keyboard and screen-reader users understand and complete the same flow?
|
||||
- Do the before/after images depict the exact implementation and state being claimed?
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "RustFS Console Design Guide"
|
||||
short_description: "Design consistent Console pages, settings, forms, dialogs, tables, and responsive flows"
|
||||
default_prompt: "Use $rustfs-console-design-guide to design or review this RustFS Console interface, applying the project hierarchy, state, form, dialog, responsive, and accessibility patterns."
|
||||
@@ -9,6 +9,8 @@ description: Audit and fix UI quality issues across web apps, dashboards, landin
|
||||
|
||||
Treat the request as implementation work unless the user asks for analysis only. Read local instructions first, preserve unrelated changes, and keep edits surgical. Do not create project summary documents or reports unless explicitly requested.
|
||||
|
||||
In this repository, read `skills/rustfs-console-design-guide/SKILL.md` before auditing or changing Console UI. Use that guide as the source of design decisions; use this skill for audit execution, browser validation, evidence capture, and verification.
|
||||
|
||||
If the user asks to pull latest, do it before editing after checking `git status`. If local unrelated work is present, preserve it and avoid destructive git commands.
|
||||
|
||||
## Classify The Surface
|
||||
|
||||
@@ -9,9 +9,28 @@ test("ApiClient exposes URL resolution against its configured base URL", () => {
|
||||
assert.match(source, /new URL\(url, this\.config\?\.baseUrl\)\.toString\(\)/)
|
||||
})
|
||||
|
||||
test("ApiClient scopes GET deduplication to one authenticated client instance", () => {
|
||||
const source = fs.readFileSync("lib/api-client.ts", "utf8")
|
||||
|
||||
assert.match(source, /private inflightGetRequests = new Map/)
|
||||
assert.match(source, /this\.inflightGetRequests\.get/)
|
||||
assert.doesNotMatch(source, /^const inflightGetRequests/m)
|
||||
assert.match(source, /signal\?: AbortSignal/)
|
||||
assert.match(source, /error\.status = response\.status/)
|
||||
})
|
||||
|
||||
test("object bulk download resolves relative download URLs through the API client", () => {
|
||||
const source = fs.readFileSync("components/object/list.tsx", "utf8")
|
||||
|
||||
assert.match(source, /normalizeObjectZipDownloadUrl\(response\.download_url, api\.resolveUrl\("\/"\)\)/)
|
||||
assert.doesNotMatch(source, /normalizeObjectZipDownloadUrl\(response\.download_url, window\.location\.origin\)/)
|
||||
})
|
||||
|
||||
test("ApiClient redacts credential fields before development logging", () => {
|
||||
const source = fs.readFileSync("lib/api-client.ts", "utf8")
|
||||
|
||||
assert.match(source, /SENSITIVE_LOG_KEY/)
|
||||
assert.match(source, /master\[_-\]\?key/)
|
||||
assert.match(source, /redactRequestOptionsForLog\(options\)/)
|
||||
assert.match(source, /JSON\.stringify\(redactLogValue\(JSON\.parse\(options\.body\)\)\)/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { isMissingBucketConfiguration } from "../../lib/bucket-configuration.ts"
|
||||
|
||||
const lifecycleSource = await readFile(new URL("../../components/lifecycle/new-form.tsx", import.meta.url), "utf8")
|
||||
const replicationSource = await readFile(new URL("../../components/replication/new-form.tsx", import.meta.url), "utf8")
|
||||
const eventsSource = await readFile(new URL("../../components/events/new-form.tsx", import.meta.url), "utf8")
|
||||
|
||||
test("only explicit missing-configuration errors may be treated as empty", () => {
|
||||
assert.equal(isMissingBucketConfiguration({ name: "NoSuchLifecycleConfiguration" }, "lifecycle"), true)
|
||||
assert.equal(isMissingBucketConfiguration({ Code: "ReplicationConfigurationNotFoundError" }, "replication"), true)
|
||||
assert.equal(isMissingBucketConfiguration(new Error("network unavailable"), "lifecycle"), false)
|
||||
assert.equal(
|
||||
isMissingBucketConfiguration({ name: "AccessDenied", $metadata: { httpStatusCode: 403 } }, "replication"),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("lifecycle creation aborts on unknown reads and keeps actions visible", () => {
|
||||
assert.match(lifecycleSource, /isMissingBucketConfiguration\(error, "lifecycle"\)/)
|
||||
assert.match(lifecycleSource, /role="alert"/)
|
||||
assert.match(lifecycleSource, /grid-rows-\[auto_minmax\(0,1fr\)_auto\]/)
|
||||
assert.match(lifecycleSource, /<form[\s\S]{0,120}onSubmit=/)
|
||||
})
|
||||
|
||||
test("replication reads existing rules before creating a remote target", () => {
|
||||
const readIndex = replicationSource.indexOf("await getBucketReplication(bucketName)")
|
||||
const targetIndex = replicationSource.indexOf("await setRemoteReplicationTarget(bucketName, config)")
|
||||
assert.notEqual(readIndex, -1)
|
||||
assert.notEqual(targetIndex, -1)
|
||||
assert.ok(readIndex < targetIndex)
|
||||
assert.match(replicationSource, /isMissingBucketConfiguration\(error, "replication"\)/)
|
||||
assert.match(replicationSource, /role="alert"/)
|
||||
})
|
||||
|
||||
test("event subscription never replaces unread configuration and cannot double-submit", () => {
|
||||
assert.doesNotMatch(eventsSource, /catch\s*\{[\s\S]{0,100}currentNotifications\s*=\s*\{\}/)
|
||||
assert.match(eventsSource, /if \(disabled \|\| submitting \|\| arnLoading \|\| arnError\)/)
|
||||
assert.match(eventsSource, /disabled=\{disabled \|\| submitting\}/)
|
||||
assert.match(eventsSource, /disabled=\{disabled \|\| submitting \|\| arnLoading \|\| Boolean\(arnError\)\}/)
|
||||
assert.match(eventsSource, /role="alert"/)
|
||||
assert.match(eventsSource, /grid-rows-\[auto_minmax\(0,1fr\)_auto\]/)
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import {
|
||||
createLatestRequestGate,
|
||||
isMissingBucketConfiguration,
|
||||
normalizeReplicationRulesForRolelessConfig,
|
||||
removeMatchingBucketRule,
|
||||
} from "../../lib/bucket-configuration.ts"
|
||||
import { buildCurrentVersionExpiration, buildLifecycleFilter } from "../../lib/bucket-lifecycle.ts"
|
||||
import { getXmlErrorCode } from "../../lib/error-handler.ts"
|
||||
|
||||
const bucketInfoSource = await readFile(new URL("../../components/buckets/info.tsx", import.meta.url), "utf8")
|
||||
const bucketNewFormSource = await readFile(new URL("../../components/buckets/new-form.tsx", import.meta.url), "utf8")
|
||||
const lifecycleTabSource = await readFile(
|
||||
new URL("../../components/buckets/lifecycle-tab.tsx", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
const replicationTabSource = await readFile(
|
||||
new URL("../../components/buckets/replication-tab.tsx", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
const eventsTabSource = await readFile(new URL("../../components/buckets/events-tab.tsx", import.meta.url), "utf8")
|
||||
const replicationFormSource = await readFile(
|
||||
new URL("../../components/replication/new-form.tsx", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
const lifecycleFormSource = await readFile(new URL("../../components/lifecycle/new-form.tsx", import.meta.url), "utf8")
|
||||
const eventsFormSource = await readFile(new URL("../../components/events/new-form.tsx", import.meta.url), "utf8")
|
||||
const bucketHookSource = await readFile(new URL("../../hooks/use-bucket.ts", import.meta.url), "utf8")
|
||||
|
||||
test("bucket settings distinguish known missing configurations from read failures", () => {
|
||||
assert.equal(isMissingBucketConfiguration({ name: "NoSuchBucketPolicy" }, "policy"), true)
|
||||
assert.equal(
|
||||
isMissingBucketConfiguration({ Code: "ServerSideEncryptionConfigurationNotFoundError" }, "encryption"),
|
||||
true,
|
||||
)
|
||||
assert.equal(isMissingBucketConfiguration({ Error: { Code: "NoSuchCORSConfiguration" } }, "cors"), true)
|
||||
assert.equal(isMissingBucketConfiguration({ name: "NoSuchTagSet" }, "tagging"), true)
|
||||
assert.equal(isMissingBucketConfiguration({ status: 404 }, "quota"), true)
|
||||
assert.equal(isMissingBucketConfiguration({ status: 500 }, "quota"), false)
|
||||
assert.equal(isMissingBucketConfiguration({ name: "AccessDenied" }, "policy"), false)
|
||||
})
|
||||
|
||||
test("structured XML error codes survive the normalization boundary", () => {
|
||||
const xml = "<Error><Code>NoSuchLifecycleConfiguration</Code><Message>missing</Message></Error>"
|
||||
assert.equal(getXmlErrorCode(xml), "NoSuchLifecycleConfiguration")
|
||||
})
|
||||
|
||||
test("fresh rule deletion preserves concurrent additions and rejects ambiguous targets", () => {
|
||||
const target = { ID: "rule-a", Status: "Enabled" }
|
||||
const concurrent = { ID: "rule-b", Status: "Enabled" }
|
||||
assert.deepEqual(removeMatchingBucketRule([target, concurrent], target), [concurrent])
|
||||
assert.equal(removeMatchingBucketRule([target, target], target), null)
|
||||
|
||||
const idless = { Status: "Enabled", Filter: { Prefix: "logs/" } }
|
||||
assert.deepEqual(removeMatchingBucketRule([idless, concurrent], idless), [concurrent])
|
||||
assert.equal(removeMatchingBucketRule([idless, idless], idless), null)
|
||||
})
|
||||
|
||||
test("legacy replication roles are preserved when migrating to roleless rules", () => {
|
||||
const rules = [
|
||||
{ ID: "enabled", Status: "Enabled", Destination: { Bucket: "arn:stale:first", StorageClass: "STANDARD" } },
|
||||
{ ID: "disabled", Status: "Disabled", Destination: { Bucket: "arn:stale:second" } },
|
||||
]
|
||||
|
||||
const normalized = normalizeReplicationRulesForRolelessConfig(rules, " arn:legacy:target ")
|
||||
assert.deepEqual(
|
||||
normalized.map((rule) => rule.Destination?.Bucket),
|
||||
["arn:legacy:target", "arn:legacy:target"],
|
||||
)
|
||||
assert.equal(normalized[0]?.Destination?.StorageClass, "STANDARD")
|
||||
assert.equal(rules[0]?.Destination?.Bucket, "arn:stale:first")
|
||||
assert.equal(normalizeReplicationRulesForRolelessConfig(rules, ""), rules)
|
||||
})
|
||||
|
||||
test("latest request gate invalidates stale and unmounted responses", () => {
|
||||
const gate = createLatestRequestGate()
|
||||
const first = gate.begin()
|
||||
const second = gate.begin()
|
||||
assert.equal(gate.isCurrent(first), false)
|
||||
assert.equal(gate.isCurrent(second), true)
|
||||
gate.invalidate()
|
||||
assert.equal(gate.isCurrent(second), false)
|
||||
})
|
||||
|
||||
test("lifecycle payload helpers always emit a valid all-bucket filter and exclusive expiration fields", () => {
|
||||
assert.deepEqual(buildLifecycleFilter("", [{ key: "", value: "" }]), { Prefix: "" })
|
||||
assert.deepEqual(buildLifecycleFilter("logs/", []), { Prefix: "logs/" })
|
||||
assert.deepEqual(buildCurrentVersionExpiration(30, false), { Days: 30 })
|
||||
assert.deepEqual(buildCurrentVersionExpiration(30, true), { ExpiredObjectDeleteMarker: true })
|
||||
})
|
||||
|
||||
test("bucket reads fail closed and stale responses cannot update the active bucket", () => {
|
||||
assert.match(bucketInfoSource, /Promise\.allSettled/)
|
||||
assert.match(bucketInfoSource, /requestVersionRef/)
|
||||
assert.match(bucketInfoSource, /readErrors/)
|
||||
assert.match(bucketInfoSource, /Unable to load this setting\. Refresh before making changes\./)
|
||||
assert.doesNotMatch(bucketInfoSource, /getBucketPolicy\(bucketName\)\.catch\(\(\) => null\)/)
|
||||
})
|
||||
|
||||
test("ordinary versioning updates do not silently enable MFA Delete", () => {
|
||||
const versioningBlock = bucketHookSource.slice(
|
||||
bucketHookSource.indexOf("const putBucketVersioning"),
|
||||
bucketHookSource.indexOf("const getBucketVersioning"),
|
||||
)
|
||||
assert.match(versioningBlock, /Status:/)
|
||||
assert.doesNotMatch(versioningBlock, /MFADelete/)
|
||||
})
|
||||
|
||||
test("rule pages reread authoritative configuration and provide mobile-first summaries", () => {
|
||||
assert.match(lifecycleTabSource, /removeMatchingBucketRule/)
|
||||
assert.match(replicationTabSource, /removeMatchingBucketRule/)
|
||||
assert.match(eventsTabSource, /Configuration changed\. Refresh and try again\./)
|
||||
assert.match(lifecycleTabSource, /md:hidden/)
|
||||
assert.match(replicationTabSource, /md:hidden/)
|
||||
assert.match(eventsTabSource, /md:hidden/)
|
||||
assert.match(lifecycleTabSource, /loadError/)
|
||||
assert.match(replicationTabSource, /loadError/)
|
||||
assert.match(eventsTabSource, /loadError/)
|
||||
})
|
||||
|
||||
test("replication creation rereads rules and never removes an uncertain remote target", () => {
|
||||
const targetIndex = replicationFormSource.indexOf("await setRemoteReplicationTarget")
|
||||
const secondReadIndex = replicationFormSource.indexOf("await getBucketReplication", targetIndex)
|
||||
const putIndex = replicationFormSource.indexOf("await putBucketReplication")
|
||||
assert.ok(targetIndex >= 0)
|
||||
assert.ok(secondReadIndex > targetIndex)
|
||||
assert.ok(putIndex > secondReadIndex)
|
||||
assert.doesNotMatch(replicationFormSource, /deleteRemoteReplicationTarget/)
|
||||
assert.doesNotMatch(replicationFormSource, /Destination\?\.Bucket !== targetResponse/)
|
||||
assert.match(replicationFormSource, /normalizeReplicationRulesForRolelessConfig/)
|
||||
assert.match(replicationFormSource, /Role: ""/)
|
||||
assert.match(replicationTabSource, /Role: role/)
|
||||
assert.match(replicationTabSource, /remaining\.length > 0 &&[\s\S]{0,80}!role &&/)
|
||||
assert.doesNotMatch(replicationTabSource, /Replication configuration Role is missing/)
|
||||
})
|
||||
|
||||
test("bucket setting controls expose names, field errors, and unresolved dependency states", () => {
|
||||
assert.match(bucketInfoSource, /aria-label=\{t\("Versioning"\)\}/)
|
||||
assert.match(bucketInfoSource, /bucket-policy-error/)
|
||||
assert.match(bucketInfoSource, /bucket-quota-error/)
|
||||
assert.match(bucketInfoSource, /bucket-retention-error/)
|
||||
assert.match(bucketInfoSource, /kmsKeyError/)
|
||||
assert.match(bucketInfoSource, /setKmsReloadVersion/)
|
||||
|
||||
assert.match(lifecycleFormSource, /Unable to load versioning status\./)
|
||||
assert.match(lifecycleFormSource, /Unable to load storage tiers\./)
|
||||
assert.match(lifecycleFormSource, /aria-invalid=\{Boolean\(fieldErrors\.days\)\}/)
|
||||
|
||||
assert.match(eventsFormSource, /Unable to load event targets\./)
|
||||
assert.match(eventsFormSource, /setArnReloadVersion/)
|
||||
|
||||
assert.match(replicationFormSource, /aria-invalid=\{Boolean\(fieldErrors\.endpoint\)\}/)
|
||||
assert.match(replicationFormSource, /aria-invalid=\{Boolean\(fieldErrors\.timecheck\)\}/)
|
||||
|
||||
assert.match(eventsFormSource, /<fieldset[\s\S]{0,120}aria-describedby=\{eventsError/)
|
||||
assert.match(eventsFormSource, /if \(isChecked\) setEventsError\(""\)/)
|
||||
assert.match(eventsFormSource, /if \(value\) setResourceNameError\(""\)/)
|
||||
|
||||
const lifecycleReset = lifecycleFormSource.slice(
|
||||
lifecycleFormSource.indexOf("const resetForm"),
|
||||
lifecycleFormSource.indexOf("const addTag"),
|
||||
)
|
||||
assert.match(lifecycleReset, /setStorageType\(""\)/)
|
||||
assert.doesNotMatch(lifecycleReset, /\[tiers\]/)
|
||||
})
|
||||
|
||||
test("bucket forms preserve semantic groups without nested framing", () => {
|
||||
assert.match(bucketNewFormSource, /<fieldset className="space-y-4 border-t pt-4">/)
|
||||
assert.match(bucketNewFormSource, /aria-label=\{t\("Quota Unit"\)\}/)
|
||||
assert.match(bucketNewFormSource, /aria-label=\{t\("Retention Mode"\)\}/)
|
||||
assert.doesNotMatch(bucketNewFormSource, /<div className="space-y-4 border p-4">/)
|
||||
assert.doesNotMatch(bucketInfoSource, /quotaFormEnabled[\s\S]{0,120}<div className="space-y-4 border p-4">/)
|
||||
assert.match(eventsFormSource, /<div className="bg-muted\/20 p-4">/)
|
||||
assert.doesNotMatch(eventsFormSource, /<div className="border p-4">/)
|
||||
})
|
||||
|
||||
test("mobile rule toolbars stack controls instead of forcing two columns", () => {
|
||||
for (const source of [lifecycleTabSource, replicationTabSource, eventsTabSource]) {
|
||||
assert.doesNotMatch(source, /grid-cols-2[^\n]*items-center[^\n]*justify-between/)
|
||||
assert.match(source, /flex-col[^\n]*sm:flex-row/)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
|
||||
const read = (file) => fs.readFileSync(file, "utf8")
|
||||
|
||||
test("access key dialogs keep fixed headers and actions around one scrolling body", () => {
|
||||
for (const file of ["components/access-keys/new-item.tsx", "components/access-keys/edit-item.tsx"]) {
|
||||
const source = read(file)
|
||||
|
||||
assert.match(source, /max-h-\[min\(90dvh,52rem\)\]/)
|
||||
assert.match(source, /grid-rows-\[auto_minmax\(0,1fr\)_auto\]/)
|
||||
assert.match(source, /gap-0 overflow-hidden p-0/)
|
||||
assert.match(source, /<DialogHeader className="border-b px-4 py-3 pe-12">/)
|
||||
assert.match(source, /<form\s+className="contents"\s+onSubmit=/)
|
||||
assert.match(source, /min-h-0[^\"]*overflow-y-auto overscroll-contain p-4/)
|
||||
assert.match(source, /<DialogFooter className="border-t bg-muted\/20 px-4 py-3">/)
|
||||
assert.doesNotMatch(source, /max-h-\[(?:80|60)vh\]/)
|
||||
}
|
||||
})
|
||||
|
||||
test("access key creation exposes expiry guidance and associated field errors", () => {
|
||||
const source = read("components/access-keys/new-item.tsx")
|
||||
|
||||
assert.match(source, /<FieldDescription id="create-expiry-description">/)
|
||||
assert.match(source, /aria-describedby="create-expiry-description"/)
|
||||
assert.match(source, /aria-describedby=\{errors\.accessKey \? "create-access-key-error" : undefined\}/)
|
||||
assert.match(source, /<FieldError id="create-access-key-error">/)
|
||||
assert.match(source, /type="submit"/)
|
||||
})
|
||||
|
||||
test("access key editing clears stale state and blocks submission until loading succeeds", () => {
|
||||
const source = read("components/access-keys/edit-item.tsx")
|
||||
|
||||
assert.match(source, /const \[loading, setLoading\] = React\.useState\(false\)/)
|
||||
assert.match(source, /const \[loadError, setLoadError\] = React\.useState\(""\)/)
|
||||
assert.match(source, /setAccesskey\(currentAccessKey\)/)
|
||||
assert.match(source, /let cancelled = false/)
|
||||
assert.match(source, /if \(!accesskey \|\| loading \|\| loadError\) return/)
|
||||
assert.match(source, /disabled=\{loading \|\| submitting\}/)
|
||||
assert.match(source, /disabled=\{loading \|\| submitting \|\| Boolean\(loadError\)\}/)
|
||||
assert.match(source, /aria-busy=\{loading \|\| submitting\}/)
|
||||
})
|
||||
|
||||
test("policy editor keeps the form fixed while the policy textarea scrolls within the viewport", () => {
|
||||
const source = read("components/policies/form.tsx")
|
||||
|
||||
assert.match(source, /const isEditing = Boolean\(policy\?\.name\)/)
|
||||
assert.match(source, /isEditing \? t\("Edit Policy"\) : t\("New Policy"\)/)
|
||||
assert.match(source, /disabled=\{isEditing \|\| submitting\}/)
|
||||
assert.match(source, /sm:max-w-3xl/)
|
||||
assert.match(source, /max-h-\[min\(90dvh,52rem\)\]/)
|
||||
assert.match(source, /grid-rows-\[auto_minmax\(0,1fr\)_auto\]/)
|
||||
assert.match(source, /<form\s+className="contents"\s+onSubmit=/)
|
||||
assert.match(source, /flex min-h-0 flex-col gap-4 overflow-hidden p-4/)
|
||||
assert.match(source, /<Field className="flex min-h-0 flex-1 flex-col">/)
|
||||
assert.match(source, /<FieldContent className="flex min-h-0 flex-1">/)
|
||||
assert.match(source, /min-h-\[12rem\] flex-1 resize-none overflow-y-auto font-mono text-xs/)
|
||||
assert.match(source, /<FieldError id="policy-content-error">/)
|
||||
assert.match(source, /aria-describedby=\{errors\.content \? "policy-content-error" : undefined\}/)
|
||||
assert.doesNotMatch(source, /space-y-4 overflow-y-auto overscroll-contain p-4/)
|
||||
assert.doesNotMatch(source, /min-h-\[24rem\] resize-y/)
|
||||
assert.doesNotMatch(source, /max-h-\[(?:80|60)vh\]/)
|
||||
assert.doesNotMatch(source, /<DialogTitle>\{t\("Policy Original"\)\}<\/DialogTitle>/)
|
||||
})
|
||||
|
||||
test("IAM multi-selectors expose their current selection and listbox semantics", () => {
|
||||
for (const file of [
|
||||
"components/user/selector.tsx",
|
||||
"components/user/edit/groups.tsx",
|
||||
"components/user-group/policies.tsx",
|
||||
]) {
|
||||
const source = read(file)
|
||||
|
||||
assert.match(source, /CommandList[^>]+role="listbox"[^>]+aria-label=/)
|
||||
assert.match(source, /aria-multiselectable="true"/)
|
||||
assert.match(source, /CommandInput[^>]+aria-label=/)
|
||||
assert.match(source, /MultiSelectCommandItem/)
|
||||
assert.match(source, /aria-label=\{.*\$\{/s)
|
||||
}
|
||||
|
||||
const itemSource = read("components/user/multi-select-command-item.tsx")
|
||||
assert.match(itemSource, /MutationObserver/)
|
||||
assert.match(itemSource, /setAttribute\("aria-selected", String\(selected\)\)/)
|
||||
})
|
||||
|
||||
test("IAM edit controls restore focus after switching between view and edit modes", () => {
|
||||
for (const file of ["components/user-group/members.tsx", "components/user-group/policies.tsx"]) {
|
||||
const source = read(file)
|
||||
|
||||
assert.match(source, /editButtonRef = React\.useRef<HTMLButtonElement>/)
|
||||
assert.match(source, /editHeadingRef = React\.useRef<HTMLHeadingElement>/)
|
||||
assert.match(source, /editHeadingRef\.current\?\.focus\(\)/)
|
||||
assert.match(source, /editButtonRef\.current\?\.focus\(\)/)
|
||||
assert.match(source, /tabIndex=\{-1\}/)
|
||||
assert.match(source, /previousEditStatus === null/)
|
||||
}
|
||||
})
|
||||
|
||||
test("IAM group tables expose localized captions through the shared table primitive", () => {
|
||||
const tableSource = read("components/data-table/data-table.tsx")
|
||||
assert.match(tableSource, /caption\?: string/)
|
||||
assert.match(tableSource, /<caption className="sr-only">\{caption\}<\/caption>/)
|
||||
|
||||
assert.match(read("components/user-group/members.tsx"), /DataTable table=\{table\} caption=\{t\("Members"\)\}/)
|
||||
assert.match(read("components/user-group/policies.tsx"), /DataTable table=\{table\} caption=\{t\("Policies"\)\}/)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user