mirror of
https://github.com/rustfs/console.git
synced 2026-09-01 15:17:45 +08:00
fix: status home redirect (#47)
* feat: move status route and format repo * fix: resolve lint warnings * fix: load bucket usage asynchronously * fix: sign version config request * fix: show spinner for bucket usage loading * fix: prevent object list fetch loop * fix: dedupe list requests * feat: add sidebar version footer
This commit is contained in:
@@ -38,7 +38,7 @@ function LoginPageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace(buildRoute("/browser"))
|
||||
router.replace(buildRoute("/"))
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
@@ -52,15 +52,14 @@ function LoginPageContent() {
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const credentials =
|
||||
method === "accessKeyAndSecretKey" ? accessKeyAndSecretKey : sts
|
||||
const credentials = method === "accessKeyAndSecretKey" ? accessKeyAndSecretKey : sts
|
||||
|
||||
try {
|
||||
const currentConfig = await configManager.loadConfig()
|
||||
await login(credentials, currentConfig)
|
||||
|
||||
message.success(t("Login Success"))
|
||||
window.location.href = buildRoute("/browser")
|
||||
window.location.href = buildRoute("/")
|
||||
} catch {
|
||||
message.error(t("Login Failed"))
|
||||
}
|
||||
|
||||
+13
-51
@@ -6,12 +6,7 @@ import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher"
|
||||
import { LanguageSwitcher } from "@/components/language-switcher"
|
||||
@@ -35,7 +30,7 @@ function ConfigPageContent() {
|
||||
const message = useMessage()
|
||||
|
||||
const [serverHost, setServerHost] = useState(() =>
|
||||
typeof window !== "undefined" ? localStorage.getItem("rustfs-server-host") ?? "" : ""
|
||||
typeof window !== "undefined" ? (localStorage.getItem("rustfs-server-host") ?? "") : "",
|
||||
)
|
||||
|
||||
const validateAndSave = async (e: React.FormEvent) => {
|
||||
@@ -50,9 +45,7 @@ function ConfigPageContent() {
|
||||
|
||||
new URL(urlToValidate)
|
||||
|
||||
const urlToSave = serverHost.match(/^https?:\/\//)
|
||||
? serverHost
|
||||
: urlToValidate
|
||||
const urlToSave = serverHost.match(/^https?:\/\//) ? serverHost : urlToValidate
|
||||
localStorage.setItem("rustfs-server-host", urlToSave)
|
||||
|
||||
if (!serverHost.match(/^https?:\/\//)) {
|
||||
@@ -70,11 +63,7 @@ function ConfigPageContent() {
|
||||
window.location.href = getLoginRoute()
|
||||
}, 200)
|
||||
} catch (error) {
|
||||
message.error(
|
||||
t("Invalid server address format") +
|
||||
": " +
|
||||
(error as Error).message
|
||||
)
|
||||
message.error(t("Invalid server address format") + ": " + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,17 +96,9 @@ function ConfigPageContent() {
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center justify-center bg-white dark:border-neutral-700 dark:bg-neutral-900 lg:w-1/2">
|
||||
<div className="max-w-sm w-full space-y-6 p-4 sm:p-7">
|
||||
<Image
|
||||
src={logoImage}
|
||||
alt="RustFS"
|
||||
width={112}
|
||||
height={24}
|
||||
className="max-w-28"
|
||||
/>
|
||||
<Image src={logoImage} alt="RustFS" width={112} height={24} className="max-w-28" />
|
||||
<div className="py-6">
|
||||
<h1 className="block text-2xl font-bold text-gray-800 dark:text-white">
|
||||
{t("Server Configuration")}
|
||||
</h1>
|
||||
<h1 className="block text-2xl font-bold text-gray-800 dark:text-white">{t("Server Configuration")}</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-neutral-400">
|
||||
{t("Please configure your RustFS server address")}
|
||||
</p>
|
||||
@@ -127,27 +108,19 @@ function ConfigPageContent() {
|
||||
<form onSubmit={validateAndSave} autoComplete="off">
|
||||
<div className="grid gap-y-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="serverHost">
|
||||
{t("Server Address")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{t("Leave empty to use current host as default")}
|
||||
</FieldDescription>
|
||||
<FieldLabel htmlFor="serverHost">{t("Server Address")}</FieldLabel>
|
||||
<FieldDescription>{t("Leave empty to use current host as default")}</FieldDescription>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="serverHost"
|
||||
value={serverHost}
|
||||
onChange={(e) => setServerHost(e.target.value)}
|
||||
type="text"
|
||||
placeholder={t(
|
||||
"Please enter server address (e.g., http://localhost:9000)"
|
||||
)}
|
||||
placeholder={t("Please enter server address (e.g., http://localhost:9000)")}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t(
|
||||
"Example: http://localhost:9000 or https://your-domain.com"
|
||||
)}
|
||||
{t("Example: http://localhost:9000 or https://your-domain.com")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
@@ -156,19 +129,11 @@ function ConfigPageContent() {
|
||||
{t("Save Configuration")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetToCurrentHost}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={resetToCurrentHost}>
|
||||
{t("Reset")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={skipConfig}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={skipConfig}>
|
||||
{t("Skip")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,10 +144,7 @@ function ConfigPageContent() {
|
||||
<div className="my-8">
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
{t("Need help?")}{" "}
|
||||
<Link
|
||||
href="https://docs.rustfs.com"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<Link href="https://docs.rustfs.com" className="text-blue-600 hover:underline">
|
||||
{t("View Documentation")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen">{children}</div>
|
||||
}
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
} from "@/components/ui/empty"
|
||||
import { Empty, EmptyContent, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
@@ -26,19 +19,8 @@ export default function ForbiddenPage() {
|
||||
<EmptyContent className="max-w-sm text-center">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon" className="mx-auto">
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
className="h-12 w-12"
|
||||
>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="22"
|
||||
fill="#F3F4F6"
|
||||
stroke="#E5E7EB"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<svg viewBox="0 0 48 48" fill="none" className="h-12 w-12">
|
||||
<circle cx="24" cy="24" r="22" fill="#F3F4F6" stroke="#E5E7EB" strokeWidth="2" />
|
||||
<path
|
||||
d="M16 21V19a8 8 0 0116 0v2"
|
||||
stroke="#A3A3A3"
|
||||
@@ -46,28 +28,14 @@ export default function ForbiddenPage() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
x="16"
|
||||
y="28"
|
||||
width="16"
|
||||
height="4"
|
||||
rx="2"
|
||||
fill="#A3A3A3"
|
||||
/>
|
||||
<rect
|
||||
x="22"
|
||||
y="32"
|
||||
width="4"
|
||||
height="4"
|
||||
rx="2"
|
||||
fill="#A3A3A3"
|
||||
/>
|
||||
<rect x="16" y="28" width="16" height="4" rx="2" fill="#A3A3A3" />
|
||||
<rect x="22" y="32" width="4" height="4" rx="2" fill="#A3A3A3" />
|
||||
</svg>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("Access Denied")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t(
|
||||
"You do not have permission to access this page. This may be due to insufficient permissions or not being logged in."
|
||||
"You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.",
|
||||
)}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export interface BackendInfoItem {
|
||||
icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>
|
||||
@@ -14,45 +8,25 @@ export interface BackendInfoItem {
|
||||
value?: string
|
||||
}
|
||||
|
||||
export function PerformanceBackendCard({
|
||||
items,
|
||||
t,
|
||||
}: {
|
||||
items: BackendInfoItem[]
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
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."
|
||||
)
|
||||
: ""}
|
||||
{items.length ? t("Key services and configuration values reported by the cluster.") : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.title}
|
||||
className="border bg-muted/40 shadow-none"
|
||||
>
|
||||
<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
|
||||
/>
|
||||
<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>
|
||||
<p className="text-xl font-semibold text-foreground">{item.value ?? "-"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function PerformanceInfrastructureCard({
|
||||
onlineServers,
|
||||
@@ -25,57 +19,33 @@ export function PerformanceInfrastructureCard({
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t("Infrastructure Health")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"Real-time status of cluster servers and backend storage devices."
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardDescription>{t("Real-time status of cluster servers and backend storage devices.")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border bg-muted/40 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("Servers")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("Servers")}</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-md 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>
|
||||
<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="rounded-md 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>
|
||||
<p className="text-xs text-muted-foreground">{t("Offline")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{offlineServers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/40 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("Disks")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("Disks")}</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-md 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>
|
||||
<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="rounded-md 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>
|
||||
<p className="text-xs text-muted-foreground">{t("Offline")}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-foreground">{offlineDisks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
@@ -28,27 +17,17 @@ function countOnlineDrives(server: ServerInfo, type: string) {
|
||||
}
|
||||
|
||||
function countOnlineNetworks(server: ServerInfo, type: string) {
|
||||
return Object.values(server?.network || {}).filter(
|
||||
(state) => state === type
|
||||
).length
|
||||
return Object.values(server?.network || {}).filter((state) => state === type).length
|
||||
}
|
||||
|
||||
export function PerformanceServerList({
|
||||
servers,
|
||||
t,
|
||||
}: {
|
||||
servers: ServerInfo[]
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
export function PerformanceServerList({ servers, t }: { servers: ServerInfo[]; t: (key: string) => string }) {
|
||||
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."
|
||||
)}
|
||||
{t("Inspect individual server health, disk utilization, and network status.")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -58,43 +37,29 @@ export function PerformanceServerList({
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{servers.map((server, index) => (
|
||||
<AccordionItem
|
||||
key={server.endpoint ?? index}
|
||||
value={String(index)}
|
||||
>
|
||||
<AccordionItem key={server.endpoint ?? index} value={String(index)}>
|
||||
<AccordionTrigger>
|
||||
<div className="flex flex-col gap-2 text-left 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",
|
||||
server.state === "online"
|
||||
? "bg-emerald-500"
|
||||
: "bg-rose-500"
|
||||
server.state === "online" ? "bg-emerald-500" : "bg-rose-500",
|
||||
)}
|
||||
/>
|
||||
<span className="font-semibold">
|
||||
{server.endpoint ?? "--"}
|
||||
</span>
|
||||
<span className="font-semibold">{server.endpoint ?? "--"}</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}
|
||||
{t("Disks")}: {countOnlineDrives(server, "ok")} / {server.drives?.length ?? 0}
|
||||
</span>
|
||||
<span>
|
||||
{t("Network")}:{" "}
|
||||
{countOnlineNetworks(server, "online")} /{" "}
|
||||
{t("Network")}: {countOnlineNetworks(server, "online")} /{" "}
|
||||
{Object.keys(server.network ?? {}).length}
|
||||
</span>
|
||||
<span>
|
||||
{t("Uptime")}:{" "}
|
||||
{server.uptime != null
|
||||
? dayjs()
|
||||
.subtract(server.uptime, "second")
|
||||
.fromNow()
|
||||
: "--"}
|
||||
{server.uptime != null ? dayjs().subtract(server.uptime, "second").fromNow() : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,27 +71,18 @@ export function PerformanceServerList({
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex gap-4 pb-2">
|
||||
{(server.drives || []).map((drive) => (
|
||||
<Card
|
||||
key={drive.uuid ?? drive.drive_path}
|
||||
className="min-w-[260px] shadow-none"
|
||||
>
|
||||
<Card key={drive.uuid ?? drive.drive_path} className="min-w-[260px] shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{drive.drive_path ?? "--"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{niceBytes(String(drive.usedspace ?? 0))} /{" "}
|
||||
{niceBytes(String(drive.totalspace ?? 0))}
|
||||
{niceBytes(String(drive.usedspace ?? 0))} / {niceBytes(String(drive.totalspace ?? 0))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress
|
||||
value={
|
||||
drive.totalspace
|
||||
? ((drive.usedspace ?? 0) / drive.totalspace) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
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">
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface SummaryMetric {
|
||||
@@ -18,41 +13,23 @@ export interface SummaryMetric {
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function PerformanceSummaryCards({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: SummaryMetric[]
|
||||
}) {
|
||||
export function PerformanceSummaryCards({ metrics }: { metrics: SummaryMetric[] }) {
|
||||
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"
|
||||
)}
|
||||
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
|
||||
/>
|
||||
<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}
|
||||
<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>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RiDatabase2Line } from "@remixicon/react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
|
||||
@@ -38,48 +32,28 @@ export function PerformanceUsageCard({
|
||||
{t("Last Scan Activity")}: {lastUpdatedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"Monitor overall storage usage and recent scanner activity at a glance."
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardDescription>{t("Monitor overall storage usage and recent scanner activity at a glance.")}</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="flex items-center gap-4">
|
||||
<RiDatabase2Line
|
||||
className="size-6 text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<RiDatabase2Line className="size-6 text-primary" aria-hidden />
|
||||
<div>
|
||||
<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-sm text-muted-foreground">{t("Used Capacity")}</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{niceBytes(String(totalUsedCapacity))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<Progress value={usedPercent} className="h-2" />
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{usedPercent.toFixed(0)}%
|
||||
</p>
|
||||
<p className="text-right text-xs text-muted-foreground">{usedPercent.toFixed(0)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{usageStats.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg 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="rounded-lg 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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,10 @@ export default function AccessKeysPage() {
|
||||
const [editItemOpen, setEditItemOpen] = useState(false)
|
||||
const [editItemRow, setEditItemRow] = useState<RowData | null>(null)
|
||||
const [noticeOpen, setNoticeOpen] = useState(false)
|
||||
const [noticeData, setNoticeData] = useState<{ credentials?: { accessKey?: string; secretKey?: string }; url?: string } | null>(null)
|
||||
const [noticeData, setNoticeData] = useState<{
|
||||
credentials?: { accessKey?: string; secretKey?: string }
|
||||
url?: string
|
||||
} | null>(null)
|
||||
|
||||
const listUserAccounts = async () => {
|
||||
setLoading(true)
|
||||
@@ -72,16 +75,13 @@ export default function AccessKeysPage() {
|
||||
{
|
||||
accessorKey: "accessKey",
|
||||
header: () => t("Access Key"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.accessKey}</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.accessKey}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "expiration",
|
||||
header: () => t("Expiration"),
|
||||
cell: ({ row }) =>
|
||||
row.original.expiration &&
|
||||
row.original.expiration !== "9999-01-01T00:00:00Z"
|
||||
row.original.expiration && row.original.expiration !== "9999-01-01T00:00:00Z"
|
||||
? dayjs(row.original.expiration).format("YYYY-MM-DD HH:mm")
|
||||
: "-",
|
||||
},
|
||||
@@ -89,14 +89,8 @@ export default function AccessKeysPage() {
|
||||
accessorKey: "accountStatus",
|
||||
header: () => t("Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.accountStatus === "on" ? "secondary" : "destructive"
|
||||
}
|
||||
>
|
||||
{row.original.accountStatus === "on"
|
||||
? t("Available")
|
||||
: t("Disabled")}
|
||||
<Badge variant={row.original.accountStatus === "on" ? "secondary" : "destructive"}>
|
||||
{row.original.accountStatus === "on" ? t("Available") : t("Disabled")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -118,19 +112,11 @@ export default function AccessKeysPage() {
|
||||
meta: { width: 200 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditItem(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDeleteSingle(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDeleteSingle(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -222,11 +208,7 @@ export default function AccessKeysPage() {
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{selectedKeys.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<Button variant="outline" disabled={!selectedKeys.length} onClick={deleteSelected}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete Selected")}</span>
|
||||
</Button>
|
||||
@@ -267,12 +249,7 @@ export default function AccessKeysPage() {
|
||||
onSuccess={listUserAccounts}
|
||||
/>
|
||||
|
||||
<UserNotice
|
||||
open={noticeOpen}
|
||||
onOpenChange={setNoticeOpen}
|
||||
data={noticeData}
|
||||
onClose={handleNoticeClose}
|
||||
/>
|
||||
<UserNotice open={noticeOpen} onOpenChange={setNoticeOpen} data={noticeData} onClose={handleNoticeClose} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,10 +59,7 @@ export default function BucketBrowserPage({ params }: PageProps) {
|
||||
})
|
||||
}, [bucketName, headBucket, message, router, t])
|
||||
|
||||
const bucketPath = React.useCallback(
|
||||
(path?: string | string[]) => buildBucketPath(bucketName, path),
|
||||
[bucketName]
|
||||
)
|
||||
const bucketPath = React.useCallback((path?: string | string[]) => buildBucketPath(bucketName, path), [bucketName])
|
||||
|
||||
const handlePathClick = (path: string) => {
|
||||
router.push(bucketPath(path))
|
||||
@@ -99,11 +96,7 @@ export default function BucketBrowserPage({ params }: PageProps) {
|
||||
>
|
||||
{bucketName}
|
||||
</h1>
|
||||
<ObjectPathLinks
|
||||
objectKey={keyPath}
|
||||
bucketName={bucketName}
|
||||
onClick={handlePathClick}
|
||||
/>
|
||||
<ObjectPathLinks objectKey={keyPath} bucketName={bucketName} onClick={handlePathClick} />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
@@ -118,10 +111,7 @@ export default function BucketBrowserPage({ params }: PageProps) {
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
) : (
|
||||
<ObjectView
|
||||
bucketName={bucketName}
|
||||
objectKey={keyPath}
|
||||
/>
|
||||
<ObjectView bucketName={bucketName} objectKey={keyPath} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -12,6 +12,7 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { BucketNewForm } from "@/components/buckets/new-form"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useObject } from "@/hooks/use-object"
|
||||
import { useSystem } from "@/hooks/use-system"
|
||||
@@ -38,68 +39,107 @@ export default function BrowserPage() {
|
||||
const dialog = useDialog()
|
||||
const { isAdmin } = useAuth()
|
||||
const { listBuckets, deleteBucket } = useBucket()
|
||||
const systemApi = useSystem()
|
||||
const { getDataUsageInfo } = useSystem()
|
||||
|
||||
const [formVisible, setFormVisible] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [data, setData] = useState<BucketRow[]>([])
|
||||
const [pending, setPending] = useState(true)
|
||||
const [usageLoading, setUsageLoading] = useState(false)
|
||||
const fetchIdRef = useRef(0)
|
||||
|
||||
const fetchBuckets = async () => {
|
||||
setPending(true)
|
||||
try {
|
||||
const response = await listBuckets()
|
||||
let bucketUsage: BucketUsageMap = {}
|
||||
|
||||
if (isAdmin) {
|
||||
try {
|
||||
const usage = (await systemApi.getDataUsageInfo()) as { buckets_usage?: BucketUsageMap }
|
||||
bucketUsage = usage?.buckets_usage ?? {}
|
||||
} catch {
|
||||
// Non-admin or API not available
|
||||
}
|
||||
const loadBucketUsage = useCallback(
|
||||
async (fetchId: number, bucketNames: string[]) => {
|
||||
if (!isAdmin || bucketNames.length === 0) {
|
||||
setUsageLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const buckets = ((response as { Buckets?: Array<{ Name?: string; CreationDate?: string }> })?.Buckets ?? [])
|
||||
.map((item) => {
|
||||
const name = item?.Name
|
||||
if (!name) return null
|
||||
try {
|
||||
const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap }
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
const bucketUsage = usage?.buckets_usage ?? {}
|
||||
|
||||
const bucketRow: BucketRow = {
|
||||
Name: name,
|
||||
CreationDate: item?.CreationDate ? new Date(item.CreationDate).toISOString() : "",
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
const stats = bucketUsage[name]
|
||||
setData((prev) =>
|
||||
prev.map((row) => {
|
||||
const stats = bucketUsage[row.Name]
|
||||
const objectsCount = typeof stats?.objects_count === "number" ? stats.objects_count : 0
|
||||
const totalSize = typeof stats?.size === "number" ? stats.size : 0
|
||||
bucketRow.Count = objectsCount
|
||||
bucketRow.Size = niceBytes(String(totalSize))
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
Count: objectsCount,
|
||||
Size: niceBytes(String(totalSize)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
} finally {
|
||||
if (fetchId === fetchIdRef.current) {
|
||||
setUsageLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDataUsageInfo, isAdmin],
|
||||
)
|
||||
|
||||
return bucketRow
|
||||
})
|
||||
.filter((bucket): bucket is BucketRow => bucket !== null)
|
||||
.sort((a, b) => a.Name.localeCompare(b.Name))
|
||||
const fetchBuckets = useCallback(
|
||||
async (options?: { force?: boolean }) => {
|
||||
const fetchId = fetchIdRef.current + 1
|
||||
fetchIdRef.current = fetchId
|
||||
setPending(true)
|
||||
try {
|
||||
const response = await listBuckets(options)
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
|
||||
setData(buckets)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch buckets:", error)
|
||||
setData([])
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
const buckets = ((response as { Buckets?: Array<{ Name?: string; CreationDate?: string }> })?.Buckets ?? [])
|
||||
.map((item) => {
|
||||
const name = item?.Name
|
||||
if (!name) return null
|
||||
|
||||
const bucketRow: BucketRow = {
|
||||
Name: name,
|
||||
CreationDate: item?.CreationDate ? new Date(item.CreationDate).toISOString() : "",
|
||||
}
|
||||
|
||||
return bucketRow
|
||||
})
|
||||
.filter((bucket): bucket is BucketRow => bucket !== null)
|
||||
.sort((a, b) => a.Name.localeCompare(b.Name))
|
||||
|
||||
setData(buckets)
|
||||
setPending(false)
|
||||
|
||||
if (isAdmin) {
|
||||
setUsageLoading(true)
|
||||
void loadBucketUsage(
|
||||
fetchId,
|
||||
buckets.map((bucket) => bucket.Name),
|
||||
)
|
||||
} else {
|
||||
setUsageLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
console.error("Failed to fetch buckets:", error)
|
||||
setData([])
|
||||
} finally {
|
||||
if (fetchId === fetchIdRef.current) {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAdmin, listBuckets, loadBucketUsage],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuckets()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run when isAdmin changes
|
||||
}, [isAdmin])
|
||||
}, [fetchBuckets])
|
||||
|
||||
const filteredData = searchTerm
|
||||
? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: data
|
||||
const filteredData = useMemo(
|
||||
() => (searchTerm ? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) : data),
|
||||
[data, searchTerm],
|
||||
)
|
||||
|
||||
const objectApi = useObject("")
|
||||
|
||||
@@ -120,8 +160,7 @@ export default function BrowserPage() {
|
||||
{
|
||||
header: () => t("Creation Date"),
|
||||
accessorKey: "CreationDate",
|
||||
cell: ({ row }) =>
|
||||
dayjs(row.original.CreationDate).format("YYYY-MM-DD HH:mm:ss"),
|
||||
cell: ({ row }) => dayjs(row.original.CreationDate).format("YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -130,12 +169,21 @@ export default function BrowserPage() {
|
||||
{
|
||||
header: () => t("Object Count"),
|
||||
accessorKey: "Count",
|
||||
cell: ({ row }) => row.original.Count?.toLocaleString() ?? "0",
|
||||
cell: ({ row }) =>
|
||||
typeof row.original.Count === "number" ? (
|
||||
row.original.Count.toLocaleString()
|
||||
) : usageLoading ? (
|
||||
<Spinner className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
"--"
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => t("Size"),
|
||||
accessorKey: "Size",
|
||||
}
|
||||
cell: ({ row }) =>
|
||||
row.original.Size ?? (usageLoading ? <Spinner className="size-3 text-muted-foreground" /> : "--"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,18 +197,12 @@ export default function BrowserPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/buckets/${encodeURIComponent(row.original.Name)}`)
|
||||
}
|
||||
onClick={() => router.push(`/buckets/${encodeURIComponent(row.original.Name)}`)}
|
||||
>
|
||||
<RiSettings5Line className="size-4" />
|
||||
<span>{t("Settings")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -177,7 +219,7 @@ export default function BrowserPage() {
|
||||
|
||||
const handleFormClosed = (value: boolean) => {
|
||||
setFormVisible(value)
|
||||
if (!value) fetchBuckets()
|
||||
if (!value) fetchBuckets({ force: true })
|
||||
}
|
||||
|
||||
const confirmDelete = (row: BucketRow) => {
|
||||
@@ -204,9 +246,11 @@ export default function BrowserPage() {
|
||||
try {
|
||||
await deleteBucket(row.Name)
|
||||
message.success(t("Delete Success"))
|
||||
await fetchBuckets()
|
||||
await fetchBuckets({ force: true })
|
||||
} catch (error: unknown) {
|
||||
message.error((error as { response?: { data?: { message?: string } } })?.response?.data?.message || t("Delete Failed"))
|
||||
message.error(
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message || t("Delete Failed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +270,7 @@ export default function BrowserPage() {
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Create Bucket")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => fetchBuckets()}>
|
||||
<Button variant="outline" onClick={() => fetchBuckets({ force: true })}>
|
||||
<RiRefreshLine className="size-4" />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
|
||||
@@ -64,25 +64,19 @@ export default function BucketSettingsPage() {
|
||||
|
||||
{canViewLifecycle && (
|
||||
<TabsContent value="lifecycle" className="space-y-4 outline-none">
|
||||
{bucketName ? (
|
||||
<BucketLifecycleTab bucketName={bucketName} />
|
||||
) : null}
|
||||
{bucketName ? <BucketLifecycleTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewReplication && (
|
||||
<TabsContent value="replication" className="space-y-4 outline-none">
|
||||
{bucketName ? (
|
||||
<BucketReplicationTab bucketName={bucketName} />
|
||||
) : null}
|
||||
{bucketName ? <BucketReplicationTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewEvents && (
|
||||
<TabsContent value="events" className="space-y-4 outline-none">
|
||||
{bucketName ? (
|
||||
<BucketEventsTab bucketName={bucketName} />
|
||||
) : null}
|
||||
{bucketName ? <BucketEventsTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
RiAddLine,
|
||||
RiRefreshLine,
|
||||
RiDeleteBin5Line,
|
||||
} from "@remixicon/react"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Page } from "@/components/page"
|
||||
@@ -59,9 +55,7 @@ export default function EventsTargetPage() {
|
||||
if (!searchTerm) return data
|
||||
const term = searchTerm.toLowerCase()
|
||||
return data.filter(
|
||||
(row) =>
|
||||
row.account_id?.toLowerCase().includes(term) ||
|
||||
row.service?.toLowerCase().includes(term)
|
||||
(row) => row.account_id?.toLowerCase().includes(term) || row.service?.toLowerCase().includes(term),
|
||||
)
|
||||
}, [data, searchTerm])
|
||||
|
||||
@@ -70,31 +64,19 @@ export default function EventsTargetPage() {
|
||||
{
|
||||
accessorKey: "account_id",
|
||||
header: () => t("Event Destinations"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">
|
||||
{row.original.account_id}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.account_id}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "service",
|
||||
header: () => t("Type"),
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.service}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.service}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: () => t("Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.status === "enable" ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{row.original.status === "enable"
|
||||
? t("Enabled")
|
||||
: row.original.status || "-"}
|
||||
<Badge variant={row.original.status === "enable" ? "secondary" : "outline"}>
|
||||
{row.original.status === "enable" ? t("Enabled") : row.original.status || "-"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -106,11 +88,7 @@ export default function EventsTargetPage() {
|
||||
meta: { width: 90 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -119,7 +97,7 @@ export default function EventsTargetPage() {
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- confirmDelete used in cell, stable ref
|
||||
[t]
|
||||
[t],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<RowData>({
|
||||
@@ -163,10 +141,7 @@ export default function EventsTargetPage() {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Destination")}</span>
|
||||
</Button>
|
||||
@@ -177,25 +152,17 @@ export default function EventsTargetPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("Event Destinations")}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold">{t("Event Destinations")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Destinations")}
|
||||
emptyDescription={t(
|
||||
"Create an event destination to forward notifications."
|
||||
)}
|
||||
emptyDescription={t("Create an event destination to forward notifications.")}
|
||||
/>
|
||||
|
||||
<EventsTargetNewForm
|
||||
open={newFormOpen}
|
||||
onOpenChange={setNewFormOpen}
|
||||
onSuccess={loadData}
|
||||
/>
|
||||
<EventsTargetNewForm open={newFormOpen} onOpenChange={setNewFormOpen} onSuccess={loadData} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,26 +47,28 @@ export default function EventsPage() {
|
||||
Filter?: { Key?: { FilterRules?: Array<{ Name: string; Value: string }> } }
|
||||
}>,
|
||||
type: NotificationItem["type"],
|
||||
arnKey: "LambdaFunctionArn" | "QueueArn" | "TopicArn"
|
||||
arnKey: "LambdaFunctionArn" | "QueueArn" | "TopicArn",
|
||||
) => {
|
||||
;(configs ?? []).forEach((config: { Id?: string; Filter?: { Key?: { FilterRules?: Array<{ Name: string; Value: string }> } }; Events?: string[] }) => {
|
||||
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]
|
||||
notifications.push({
|
||||
id: config.Id ?? "",
|
||||
type,
|
||||
arn: arn ?? "",
|
||||
events: config.Events ?? [],
|
||||
prefix,
|
||||
suffix,
|
||||
filterRules: config.Filter?.Key?.FilterRules ?? [],
|
||||
})
|
||||
})
|
||||
;(configs ?? []).forEach(
|
||||
(config: {
|
||||
Id?: string
|
||||
Filter?: { Key?: { FilterRules?: Array<{ Name: string; Value: string }> } }
|
||||
Events?: string[]
|
||||
}) => {
|
||||
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]
|
||||
notifications.push({
|
||||
id: config.Id ?? "",
|
||||
type,
|
||||
arn: arn ?? "",
|
||||
events: config.Events ?? [],
|
||||
prefix,
|
||||
suffix,
|
||||
filterRules: config.Filter?.Key?.FilterRules ?? [],
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const r = response as {
|
||||
@@ -75,21 +77,9 @@ export default function EventsPage() {
|
||||
TopicConfigurations?: unknown[]
|
||||
}
|
||||
|
||||
addFromConfig(
|
||||
(r?.LambdaFunctionConfigurations ?? []) as never[],
|
||||
"Lambda",
|
||||
"LambdaFunctionArn"
|
||||
)
|
||||
addFromConfig(
|
||||
(r?.QueueConfigurations ?? []) as never[],
|
||||
"SQS",
|
||||
"QueueArn"
|
||||
)
|
||||
addFromConfig(
|
||||
(r?.TopicConfigurations ?? []) as never[],
|
||||
"SNS",
|
||||
"TopicArn"
|
||||
)
|
||||
addFromConfig((r?.LambdaFunctionConfigurations ?? []) as never[], "Lambda", "LambdaFunctionArn")
|
||||
addFromConfig((r?.QueueConfigurations ?? []) as never[], "SQS", "QueueArn")
|
||||
addFromConfig((r?.TopicConfigurations ?? []) as never[], "SNS", "TopicArn")
|
||||
|
||||
setData(notifications)
|
||||
} catch (error) {
|
||||
@@ -109,9 +99,7 @@ export default function EventsPage() {
|
||||
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?"
|
||||
),
|
||||
content: t("Are you sure you want to delete this notification configuration?"),
|
||||
positiveText: t("Delete"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => resolve(true),
|
||||
@@ -124,10 +112,7 @@ export default function EventsPage() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const currentResponse = await listBucketNotifications(bucketName)
|
||||
const currentNotifications = (currentResponse ?? {}) as unknown as Record<
|
||||
string,
|
||||
unknown[]
|
||||
>
|
||||
const currentNotifications = (currentResponse ?? {}) as unknown as Record<string, unknown[]>
|
||||
|
||||
const configKey =
|
||||
row.type === "Lambda"
|
||||
@@ -135,9 +120,7 @@ export default function EventsPage() {
|
||||
: row.type === "SQS"
|
||||
? "QueueConfigurations"
|
||||
: "TopicConfigurations"
|
||||
const configs = (
|
||||
currentNotifications as Record<string, Array<{ Id?: string }>>
|
||||
)[configKey]
|
||||
const configs = (currentNotifications as Record<string, Array<{ Id?: string }>>)[configKey]
|
||||
const updated = configs?.filter((c) => c.Id !== row.id) ?? []
|
||||
|
||||
const newConfig = {
|
||||
@@ -154,28 +137,15 @@ export default function EventsPage() {
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error(t("Delete Failed"), error)
|
||||
message.error(
|
||||
`${t("Delete Failed")}: ${(error as Error).message ?? error}`
|
||||
)
|
||||
message.error(`${t("Delete Failed")}: ${(error as Error).message ?? error}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
bucketName,
|
||||
dialog,
|
||||
listBucketNotifications,
|
||||
loadData,
|
||||
message,
|
||||
putBucketNotifications,
|
||||
t,
|
||||
]
|
||||
[bucketName, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t],
|
||||
)
|
||||
|
||||
const columns = useMemo(
|
||||
() => getEventsColumns(t, handleRowDelete),
|
||||
[t, handleRowDelete]
|
||||
)
|
||||
const columns = useMemo(() => getEventsColumns(t, handleRowDelete), [t, handleRowDelete])
|
||||
|
||||
const { table } = useDataTable<NotificationItem>({
|
||||
data,
|
||||
@@ -194,21 +164,11 @@ export default function EventsPage() {
|
||||
placeholder={t("Please select bucket")}
|
||||
selectorClass="w-full"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setNewFormOpen(true)} disabled={!bucketName}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Subscription")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={loadData} disabled={loading}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
@@ -226,12 +186,7 @@ export default function EventsPage() {
|
||||
/>
|
||||
|
||||
{bucketName && (
|
||||
<EventsNewForm
|
||||
open={newFormOpen}
|
||||
onOpenChange={setNewFormOpen}
|
||||
bucketName={bucketName}
|
||||
onSuccess={loadData}
|
||||
/>
|
||||
<EventsNewForm open={newFormOpen} onOpenChange={setNewFormOpen} bucketName={bucketName} onSuccess={loadData} />
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -16,19 +16,8 @@ import { Button } from "@/components/ui/button"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { UploadZone } from "@/components/upload-zone"
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useImportExport } from "@/hooks/use-import-export"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
@@ -59,7 +48,7 @@ export default function ImportExportPage() {
|
||||
{ key: "export" as const, label: t("Export") },
|
||||
{ key: "import" as const, label: t("Import") },
|
||||
],
|
||||
[t]
|
||||
[t],
|
||||
)
|
||||
|
||||
const exportHighlights = useMemo(
|
||||
@@ -73,7 +62,7 @@ export default function ImportExportPage() {
|
||||
},
|
||||
{ label: "AK/SK", icon: RiKeyLine, iconClass: "text-orange-500" },
|
||||
],
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024
|
||||
@@ -86,9 +75,7 @@ export default function ImportExportPage() {
|
||||
const validateFile = (file: File | null) => {
|
||||
if (!file) return false
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
setUploadError(
|
||||
t("Only ZIP files are supported, and file size should not exceed 10MB")
|
||||
)
|
||||
setUploadError(t("Only ZIP files are supported, and file size should not exceed 10MB"))
|
||||
return false
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
@@ -144,11 +131,7 @@ export default function ImportExportPage() {
|
||||
>
|
||||
<TabsList className="justify-start overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="capitalize"
|
||||
>
|
||||
<TabsTrigger key={tab.key} value={tab.key} className="capitalize">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -159,25 +142,15 @@ export default function ImportExportPage() {
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle>{t("IAM Configuration Export")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"Export all IAM configurations including users, groups, policies, and access keys in a ZIP file."
|
||||
)}
|
||||
{t("Export all IAM configurations including users, groups, policies, and access keys in a ZIP file.")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{exportHighlights.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-center gap-3 rounded-md border bg-muted/40 p-3"
|
||||
>
|
||||
<item.icon
|
||||
className={`size-5 ${item.iconClass}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t(item.label)}
|
||||
</span>
|
||||
<div key={item.label} className="flex items-center gap-3 rounded-md border bg-muted/40 p-3">
|
||||
<item.icon className={`size-5 ${item.iconClass}`} aria-hidden />
|
||||
<span className="text-sm font-medium text-foreground">{t(item.label)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -186,9 +159,7 @@ export default function ImportExportPage() {
|
||||
<RiInformationLine className="size-4" aria-hidden />
|
||||
<AlertTitle>{t("Notice")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"The exported file contains sensitive information. Please keep it secure."
|
||||
)}
|
||||
{t("The exported file contains sensitive information. Please keep it secure.")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
@@ -196,16 +167,9 @@ export default function ImportExportPage() {
|
||||
<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" disabled={isLoading} onClick={handleExportIam}>
|
||||
<RiDownload2Line className="size-4" aria-hidden />
|
||||
<span>
|
||||
{isLoading ? t("Exporting...") : t("Export Now")}
|
||||
</span>
|
||||
<span>{isLoading ? t("Exporting...") : t("Export Now")}</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -215,50 +179,26 @@ export default function ImportExportPage() {
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle>{t("IAM Configuration Import")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"Import IAM configurations from a previously exported ZIP file."
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardDescription>{t("Import IAM configurations from a previously exported ZIP file.")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<UploadZone
|
||||
accept=".zip"
|
||||
disabled={isLoading}
|
||||
className="border-dashed"
|
||||
onChange={handleFileSelect}
|
||||
>
|
||||
<p className="text-base font-medium">
|
||||
{t("Click or drag ZIP file to this area to upload")}
|
||||
</p>
|
||||
<UploadZone accept=".zip" disabled={isLoading} className="border-dashed" onChange={handleFileSelect}>
|
||||
<p className="text-base font-medium">{t("Click or drag ZIP file to this area to upload")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"Only ZIP files are supported, and file size should not exceed 10MB"
|
||||
)}
|
||||
{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">{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>
|
||||
<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"
|
||||
onClick={clearSelectedFile}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="text-destructive" onClick={clearSelectedFile}>
|
||||
<RiCloseLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -274,16 +214,9 @@ export default function ImportExportPage() {
|
||||
})
|
||||
: t("Please select a ZIP file to import")}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading || !selectedFile}
|
||||
onClick={handleImportIam}
|
||||
>
|
||||
<Button variant="default" size="lg" disabled={isLoading || !selectedFile} onClick={handleImportIam}>
|
||||
<RiUpload2Line className="size-4" aria-hidden />
|
||||
<span>
|
||||
{isLoading ? t("Importing...") : t("Import Now")}
|
||||
</span>
|
||||
<span>{isLoading ? t("Importing...") : t("Import Now")}</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -6,11 +6,7 @@ import { DashboardAuthGuard } from "@/components/dashboard-auth-guard"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies()
|
||||
const sidebarState = cookieStore.get(SIDEBAR_COOKIE_NAME)?.value
|
||||
const defaultOpen = sidebarState !== "false"
|
||||
|
||||
@@ -1,94 +1,87 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Page } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { BucketSelector } from "@/components/buckets/selector";
|
||||
import { DataTable } from "@/components/data-table/data-table";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { useBucket } from "@/hooks/use-bucket";
|
||||
import { LifecycleNewForm } from "@/components/lifecycle/new-form";
|
||||
import { useDialog } from "@/lib/feedback/dialog";
|
||||
import { useMessage } from "@/lib/feedback/message";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { BucketSelector } from "@/components/buckets/selector"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { LifecycleNewForm } from "@/components/lifecycle/new-form"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface LifecycleRule {
|
||||
ID?: string;
|
||||
Status?: string;
|
||||
ID?: string
|
||||
Status?: string
|
||||
Filter?: {
|
||||
Prefix?: string;
|
||||
Tag?: { Key: string; Value: string };
|
||||
Prefix?: string
|
||||
Tag?: { Key: string; Value: string }
|
||||
And?: {
|
||||
Prefix?: string;
|
||||
Tags?: Array<{ Key: string; Value: string }>;
|
||||
};
|
||||
};
|
||||
Prefix?: string
|
||||
Tags?: Array<{ Key: string; Value: string }>
|
||||
}
|
||||
}
|
||||
Expiration?: {
|
||||
Days?: number;
|
||||
Date?: string;
|
||||
StorageClass?: string;
|
||||
ExpiredObjectDeleteMarker?: boolean;
|
||||
};
|
||||
NoncurrentVersionExpiration?: { NoncurrentDays?: number };
|
||||
Transitions?: Array<{ Days?: number; StorageClass?: string }>;
|
||||
Days?: number
|
||||
Date?: string
|
||||
StorageClass?: string
|
||||
ExpiredObjectDeleteMarker?: boolean
|
||||
}
|
||||
NoncurrentVersionExpiration?: { NoncurrentDays?: number }
|
||||
Transitions?: Array<{ Days?: number; StorageClass?: string }>
|
||||
NoncurrentVersionTransitions?: Array<{
|
||||
NoncurrentDays?: number;
|
||||
StorageClass?: string;
|
||||
}>;
|
||||
NoncurrentDays?: number
|
||||
StorageClass?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default function LifecyclePage() {
|
||||
const { t } = useTranslation();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const {
|
||||
getBucketLifecycleConfiguration,
|
||||
deleteBucketLifecycle,
|
||||
putBucketLifecycleConfiguration,
|
||||
} = useBucket();
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { getBucketLifecycleConfiguration, deleteBucketLifecycle, putBucketLifecycleConfiguration } = useBucket()
|
||||
|
||||
const [bucketName, setBucketName] = useState<string | null>(null);
|
||||
const [data, setData] = useState<LifecycleRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newFormOpen, setNewFormOpen] = useState(false);
|
||||
const [bucketName, setBucketName] = useState<string | null>(null)
|
||||
const [data, setData] = useState<LifecycleRule[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newFormOpen, setNewFormOpen] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!bucketName) {
|
||||
setData([]);
|
||||
return;
|
||||
setData([])
|
||||
return
|
||||
}
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getBucketLifecycleConfiguration(bucketName);
|
||||
const response = await getBucketLifecycleConfiguration(bucketName)
|
||||
const rules = [...(response?.Rules ?? [])]
|
||||
.map((r) => r as LifecycleRule)
|
||||
.sort((a, b) => (a.ID ?? "").localeCompare(b.ID ?? ""));
|
||||
setData(rules);
|
||||
.sort((a, b) => (a.ID ?? "").localeCompare(b.ID ?? ""))
|
||||
setData(rules)
|
||||
} catch {
|
||||
setData([]);
|
||||
setData([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, [bucketName, getBucketLifecycleConfiguration]);
|
||||
}, [bucketName, getBucketLifecycleConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const columns: ColumnDef<LifecycleRule>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "type",
|
||||
header: () => t("Type"),
|
||||
accessorFn: (row) =>
|
||||
row.Transitions || row.NoncurrentVersionTransitions
|
||||
? "Transition"
|
||||
: "Expire",
|
||||
accessorFn: (row) => (row.Transitions || row.NoncurrentVersionTransitions ? "Transition" : "Expire"),
|
||||
},
|
||||
{
|
||||
id: "version",
|
||||
@@ -101,22 +94,18 @@ export default function LifecyclePage() {
|
||||
{
|
||||
id: "deleteMarker",
|
||||
header: () => t("Expiration Delete Mark"),
|
||||
accessorFn: (row) =>
|
||||
row.Expiration?.ExpiredObjectDeleteMarker ? t("On") : t("Off"),
|
||||
accessorFn: (row) => (row.Expiration?.ExpiredObjectDeleteMarker ? t("On") : t("Off")),
|
||||
},
|
||||
{
|
||||
id: "tier",
|
||||
header: () => t("Tier"),
|
||||
accessorFn: (row) =>
|
||||
row.Transitions?.[0]?.StorageClass ||
|
||||
row.NoncurrentVersionTransitions?.[0]?.StorageClass ||
|
||||
"--",
|
||||
row.Transitions?.[0]?.StorageClass || row.NoncurrentVersionTransitions?.[0]?.StorageClass || "--",
|
||||
},
|
||||
{
|
||||
id: "prefix",
|
||||
header: () => t("Prefix"),
|
||||
accessorFn: (row) =>
|
||||
row.Filter?.Prefix || row.Filter?.And?.Prefix || "",
|
||||
accessorFn: (row) => row.Filter?.Prefix || row.Filter?.And?.Prefix || "",
|
||||
},
|
||||
{
|
||||
id: "timeCycle",
|
||||
@@ -133,11 +122,7 @@ export default function LifecyclePage() {
|
||||
header: () => t("Status"),
|
||||
accessorFn: (row) => row.Status ?? "-",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.Status === "Enabled" ? "secondary" : "destructive"
|
||||
}
|
||||
>
|
||||
<Badge variant={row.original.Status === "Enabled" ? "secondary" : "destructive"}>
|
||||
{row.original.Status ?? "-"}
|
||||
</Badge>
|
||||
),
|
||||
@@ -148,11 +133,7 @@ export default function LifecyclePage() {
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -162,13 +143,13 @@ export default function LifecyclePage() {
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- confirmDelete used in cell, stable ref
|
||||
[t],
|
||||
);
|
||||
)
|
||||
|
||||
const { table } = useDataTable<LifecycleRule>({
|
||||
data,
|
||||
columns,
|
||||
getRowId: (row) => row.ID ?? JSON.stringify(row),
|
||||
});
|
||||
})
|
||||
|
||||
const confirmDelete = (row: LifecycleRule) => {
|
||||
dialog.error({
|
||||
@@ -177,43 +158,35 @@ export default function LifecyclePage() {
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => handleRowDelete(row),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const handleRowDelete = async (row: LifecycleRule) => {
|
||||
const remaining = data.filter((item) => item.ID !== row.ID);
|
||||
if (!bucketName) return;
|
||||
const remaining = data.filter((item) => item.ID !== row.ID)
|
||||
if (!bucketName) return
|
||||
|
||||
try {
|
||||
if (remaining.length === 0) {
|
||||
await deleteBucketLifecycle(bucketName);
|
||||
await deleteBucketLifecycle(bucketName)
|
||||
} else {
|
||||
await putBucketLifecycleConfiguration(bucketName, {
|
||||
Rules: remaining,
|
||||
});
|
||||
})
|
||||
}
|
||||
message.success(t("Delete Success"));
|
||||
loadData();
|
||||
message.success(t("Delete Success"))
|
||||
loadData()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Delete Failed"));
|
||||
message.error((error as Error).message || t("Delete Failed"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<BucketSelector
|
||||
value={bucketName}
|
||||
onChange={setBucketName}
|
||||
placeholder={t("Please select bucket")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
<BucketSelector value={bucketName} onChange={setBucketName} placeholder={t("Please select bucket")} />
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!bucketName}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Lifecycle Rule")}</span>
|
||||
</Button>
|
||||
@@ -231,9 +204,7 @@ export default function LifecyclePage() {
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Data")}
|
||||
emptyDescription={t(
|
||||
"Create lifecycle rules to automate object transitions and expiration.",
|
||||
)}
|
||||
emptyDescription={t("Create lifecycle rules to automate object transitions and expiration.")}
|
||||
/>
|
||||
|
||||
{bucketName && (
|
||||
@@ -245,5 +216,5 @@ export default function LifecyclePage() {
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+3
-232
@@ -1,234 +1,5 @@
|
||||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import * as React from "react"
|
||||
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 { 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 { 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)
|
||||
|
||||
export default function PerformancePage() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
systemInfo,
|
||||
metricsInfo,
|
||||
datausageinfo,
|
||||
storageinfo,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = usePerformanceData()
|
||||
|
||||
const numberFormatter = useMemo(() => new Intl.NumberFormat(), [])
|
||||
|
||||
const storageBackend = useMemo(
|
||||
() =>
|
||||
storageinfo?.backend ??
|
||||
(storageinfo as { Backend?: typeof storageinfo.backend })?.Backend,
|
||||
[storageinfo]
|
||||
)
|
||||
|
||||
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])
|
||||
|
||||
const summaryMetrics = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("Buckets"),
|
||||
display: numberFormatter.format(systemInfo?.buckets?.count ?? 0),
|
||||
icon: RiArchiveLine,
|
||||
caption: null as string | null,
|
||||
href: "/browser",
|
||||
},
|
||||
{
|
||||
label: t("Objects"),
|
||||
display: numberFormatter.format(systemInfo?.objects?.count ?? 0),
|
||||
icon: RiStackLine,
|
||||
caption: null as string | null,
|
||||
href: "/browser",
|
||||
},
|
||||
{
|
||||
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]
|
||||
)
|
||||
|
||||
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 usageStats = useMemo(
|
||||
() => [
|
||||
{ label: t("Last Normal Operation"), value: fromLastStartTime },
|
||||
{ label: t("Last Scan Activity"), value: fromLastScanTime },
|
||||
{ label: t("Uptime"), value: lastScanTime },
|
||||
],
|
||||
[t, fromLastStartTime, fromLastScanTime, lastScanTime]
|
||||
)
|
||||
|
||||
const onlineServers = useMemo(
|
||||
() => (systemInfo?.servers || []).filter((s) => s.state === "online").length,
|
||||
[systemInfo]
|
||||
)
|
||||
|
||||
const offlineServers = useMemo(
|
||||
() =>
|
||||
(systemInfo?.servers || []).filter((s) => s.state === "offline").length,
|
||||
[systemInfo]
|
||||
)
|
||||
|
||||
const backendInfo = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: RiArchiveDrawerFill,
|
||||
title: t("Backend Type"),
|
||||
value: systemInfo?.backend?.backendType,
|
||||
},
|
||||
{
|
||||
icon: RiSecurePaymentFill,
|
||||
title: t("Standard Storage Parity"),
|
||||
value: storageBackend?.StandardSCParity,
|
||||
},
|
||||
{
|
||||
icon: RiListSettingsFill,
|
||||
title: t("Reduced Redundancy Parity"),
|
||||
value: storageBackend?.RRSCParity,
|
||||
},
|
||||
],
|
||||
[systemInfo, storageBackend, t]
|
||||
)
|
||||
|
||||
if (
|
||||
loading &&
|
||||
!Object.keys(systemInfo).length &&
|
||||
!Object.keys(datausageinfo).length
|
||||
) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("Server Information")}</h1>
|
||||
</PageHeader>
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" onClick={refetch}>
|
||||
<RiRefreshLine className="mr-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Server Information")}</h1>
|
||||
</PageHeader>
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline" onClick={refetch} disabled={loading}>
|
||||
<RiRefreshLine className="mr-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Server Information")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-8">
|
||||
<PerformanceSummaryCards metrics={summaryMetrics} />
|
||||
|
||||
<PerformanceUsageCard
|
||||
lastUpdatedLabel={lastUpdatedLabel}
|
||||
totalUsedCapacity={datausageinfo.total_used_capacity ?? 0}
|
||||
usedPercent={usedPercent}
|
||||
usageStats={usageStats}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PerformanceInfrastructureCard
|
||||
onlineServers={onlineServers}
|
||||
offlineServers={offlineServers}
|
||||
onlineDisks={systemInfo?.backend?.onlineDisks ?? 0}
|
||||
offlineDisks={systemInfo?.backend?.offlineDisks ?? 0}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PerformanceBackendCard items={backendInfo} t={t} />
|
||||
|
||||
<PerformanceServerList
|
||||
servers={systemInfo?.servers ?? []}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
export default function HomePage() {
|
||||
redirect("/browser")
|
||||
}
|
||||
|
||||
@@ -88,19 +88,11 @@ export default function PoliciesPage() {
|
||||
meta: { width: 200 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(row.original)}>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -168,12 +160,7 @@ export default function PoliciesPage() {
|
||||
<DataTablePagination table={table} className="px-2 py-3" />
|
||||
</div>
|
||||
|
||||
<PolicyForm
|
||||
show={showPolicyForm}
|
||||
onShowChange={handleShowChange}
|
||||
policy={currentPolicy}
|
||||
onSaved={listPolicies}
|
||||
/>
|
||||
<PolicyForm show={showPolicyForm} onShowChange={handleShowChange} policy={currentPolicy} onSaved={listPolicies} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +1,61 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin7Line } from "@remixicon/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Page } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { BucketSelector } from "@/components/buckets/selector";
|
||||
import { DataTable } from "@/components/data-table/data-table";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { useBucket } from "@/hooks/use-bucket";
|
||||
import { ReplicationNewForm } from "@/components/replication/new-form";
|
||||
import { useDialog } from "@/lib/feedback/dialog";
|
||||
import { useMessage } from "@/lib/feedback/message";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin7Line } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { BucketSelector } from "@/components/buckets/selector"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { ReplicationNewForm } from "@/components/replication/new-form"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface ReplicationRule {
|
||||
ID?: string;
|
||||
Status?: string;
|
||||
Priority?: number;
|
||||
Filter?: { Prefix?: string };
|
||||
Destination?: { Bucket?: string; StorageClass?: string };
|
||||
ID?: string
|
||||
Status?: string
|
||||
Priority?: number
|
||||
Filter?: { Prefix?: string }
|
||||
Destination?: { Bucket?: string; StorageClass?: string }
|
||||
}
|
||||
|
||||
export default function ReplicationPage() {
|
||||
const { t } = useTranslation();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const {
|
||||
getBucketReplication,
|
||||
putBucketReplication,
|
||||
deleteBucketReplication,
|
||||
deleteRemoteReplicationTarget,
|
||||
} = useBucket();
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } =
|
||||
useBucket()
|
||||
|
||||
const [bucketName, setBucketName] = useState<string | null>(null);
|
||||
const [data, setData] = useState<ReplicationRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newFormOpen, setNewFormOpen] = useState(false);
|
||||
const [bucketName, setBucketName] = useState<string | null>(null)
|
||||
const [data, setData] = useState<ReplicationRule[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newFormOpen, setNewFormOpen] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!bucketName) {
|
||||
setData([]);
|
||||
return;
|
||||
setData([])
|
||||
return
|
||||
}
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getBucketReplication(bucketName);
|
||||
setData(res?.ReplicationConfiguration?.Rules ?? []);
|
||||
const res = await getBucketReplication(bucketName)
|
||||
setData(res?.ReplicationConfiguration?.Rules ?? [])
|
||||
} catch {
|
||||
setData([]);
|
||||
setData([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, [bucketName, getBucketReplication]);
|
||||
}, [bucketName, getBucketReplication])
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const columns: ColumnDef<ReplicationRule>[] = useMemo(
|
||||
() => [
|
||||
@@ -72,11 +68,7 @@ export default function ReplicationPage() {
|
||||
accessorKey: "Status",
|
||||
header: () => t("Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.Status === "Enabled" ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
<Badge variant={row.original.Status === "Enabled" ? "secondary" : "outline"}>
|
||||
{row.original.Status === "Enabled" ? t("Enabled") : t("Disabled")}
|
||||
</Badge>
|
||||
),
|
||||
@@ -95,16 +87,14 @@ export default function ReplicationPage() {
|
||||
id: "destination-bucket",
|
||||
header: () => t("Destination Bucket"),
|
||||
cell: ({ row }) => {
|
||||
const bucketArn = row.original.Destination?.Bucket || "";
|
||||
return <span>{bucketArn.replace(/^arn:aws:s3:::/, "") || "-"}</span>;
|
||||
const bucketArn = row.original.Destination?.Bucket || ""
|
||||
return <span>{bucketArn.replace(/^arn:aws:s3:::/, "") || "-"}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "destination-storage",
|
||||
header: () => t("Storage Class"),
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.Destination?.StorageClass || "-"}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.Destination?.StorageClass || "-"}</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -112,11 +102,7 @@ export default function ReplicationPage() {
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -126,13 +112,13 @@ export default function ReplicationPage() {
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- confirmDelete used in cell, stable ref
|
||||
[t],
|
||||
);
|
||||
)
|
||||
|
||||
const { table } = useDataTable<ReplicationRule>({
|
||||
data,
|
||||
columns,
|
||||
getRowId: (row) => row.ID ?? JSON.stringify(row),
|
||||
});
|
||||
})
|
||||
|
||||
const confirmDelete = (rule: ReplicationRule) => {
|
||||
dialog.error({
|
||||
@@ -141,37 +127,34 @@ export default function ReplicationPage() {
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => handleRowDelete(rule),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const handleRowDelete = async (rule: ReplicationRule) => {
|
||||
const remaining = data.filter((item) => item !== rule);
|
||||
if (!bucketName) return;
|
||||
const remaining = data.filter((item) => item !== rule)
|
||||
if (!bucketName) return
|
||||
|
||||
try {
|
||||
if (remaining.length === 0) {
|
||||
await deleteBucketReplication(bucketName);
|
||||
await deleteRemoteReplicationTarget(
|
||||
bucketName,
|
||||
rule.Destination?.Bucket ?? "",
|
||||
);
|
||||
await deleteBucketReplication(bucketName)
|
||||
await deleteRemoteReplicationTarget(bucketName, rule.Destination?.Bucket ?? "")
|
||||
} else {
|
||||
const currentConfig = await getBucketReplication(bucketName);
|
||||
const role = currentConfig?.ReplicationConfiguration?.Role;
|
||||
const currentConfig = await getBucketReplication(bucketName)
|
||||
const role = currentConfig?.ReplicationConfiguration?.Role
|
||||
if (!role) {
|
||||
throw new Error("Replication configuration Role is missing");
|
||||
throw new Error("Replication configuration Role is missing")
|
||||
}
|
||||
await putBucketReplication(bucketName, {
|
||||
Role: role,
|
||||
Rules: remaining,
|
||||
});
|
||||
})
|
||||
}
|
||||
message.success(t("Delete Success"));
|
||||
loadData();
|
||||
message.success(t("Delete Success"))
|
||||
loadData()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Delete Failed"));
|
||||
message.error((error as Error).message || t("Delete Failed"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -184,11 +167,7 @@ export default function ReplicationPage() {
|
||||
placeholder={t("Please select bucket")}
|
||||
selectorClass="sm:w-56"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!bucketName}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Replication Rule")}</span>
|
||||
</Button>
|
||||
@@ -206,9 +185,7 @@ export default function ReplicationPage() {
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Data")}
|
||||
emptyDescription={t(
|
||||
"Add replication rules to sync objects across buckets.",
|
||||
)}
|
||||
emptyDescription={t("Add replication rules to sync objects across buckets.")}
|
||||
/>
|
||||
|
||||
{bucketName && (
|
||||
@@ -220,5 +197,5 @@ export default function ReplicationPage() {
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { configManager } from "@/lib/config"
|
||||
@@ -38,7 +29,7 @@ export default function SettingsPage() {
|
||||
const [formData, setFormData] = useState({ serverHost: "" })
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadCurrentConfig = async () => {
|
||||
const loadCurrentConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await configManager.loadConfig()
|
||||
setCurrentConfig({
|
||||
@@ -53,11 +44,11 @@ export default function SettingsPage() {
|
||||
} catch {
|
||||
message.error(t("Failed to load current configuration"))
|
||||
}
|
||||
}
|
||||
}, [message, t])
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentConfig()
|
||||
}, [])
|
||||
}, [loadCurrentConfig])
|
||||
|
||||
const currentItems = useMemo(
|
||||
() => [
|
||||
@@ -78,7 +69,7 @@ export default function SettingsPage() {
|
||||
value: currentConfig.s3.region || t("Not configured"),
|
||||
},
|
||||
],
|
||||
[currentConfig, t]
|
||||
[currentConfig, t],
|
||||
)
|
||||
|
||||
const saveConfig = async (e: React.FormEvent) => {
|
||||
@@ -96,9 +87,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
new URL(urlToValidate)
|
||||
const urlToSave = /^https?:\/\//.test(formData.serverHost)
|
||||
? formData.serverHost
|
||||
: urlToValidate
|
||||
const urlToSave = /^https?:\/\//.test(formData.serverHost) ? formData.serverHost : urlToValidate
|
||||
|
||||
localStorage.setItem("rustfs-server-host", urlToSave)
|
||||
|
||||
@@ -136,18 +125,11 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("Current Configuration")}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold">{t("Current Configuration")}</h2>
|
||||
<dl className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{currentItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-md border p-3"
|
||||
>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{item.label}
|
||||
</dt>
|
||||
<div key={item.label} className="rounded-md border p-3">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{item.label}</dt>
|
||||
<dd className="mt-1 text-sm">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
@@ -155,40 +137,26 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("Server Configuration")}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold">{t("Server Configuration")}</h2>
|
||||
<form className="space-y-4" onSubmit={saveConfig}>
|
||||
<Field>
|
||||
<FieldLabel>{t("Server Address")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
value={formData.serverHost}
|
||||
onChange={(e) =>
|
||||
setFormData({ serverHost: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"Please enter server address (e.g., http://localhost:9000)"
|
||||
)}
|
||||
onChange={(e) => setFormData({ serverHost: e.target.value })}
|
||||
placeholder={t("Please enter server address (e.g., http://localhost:9000)")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t(
|
||||
"Example: http://localhost:9000 or https://your-domain.com"
|
||||
)}
|
||||
</FieldDescription>
|
||||
<FieldDescription>{t("Example: http://localhost:9000 or https://your-domain.com")}</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" variant="default" disabled={loading}>
|
||||
{t("Save Configuration")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetConfig}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={resetConfig}>
|
||||
{t("Reset to Default")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -199,16 +167,8 @@ export default function SettingsPage() {
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc space-y-1 text-sm">
|
||||
<li>{t("Configuration is saved locally in your browser")}</li>
|
||||
<li>
|
||||
{t(
|
||||
"Page will refresh automatically after saving configuration"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"Make sure the server address is accessible from your network"
|
||||
)}
|
||||
</li>
|
||||
<li>{t("Page will refresh automatically after saving configuration")}</li>
|
||||
<li>{t("Make sure the server address is accessible from your network")}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Page } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { SiteReplicationNewForm } from "@/components/site-replication/new-form";
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { SiteReplicationNewForm } from "@/components/site-replication/new-form"
|
||||
|
||||
export default function SiteReplicationPage() {
|
||||
const { t } = useTranslation();
|
||||
const [newFormOpen, setNewFormOpen] = useState(false);
|
||||
const { t } = useTranslation()
|
||||
const [newFormOpen, setNewFormOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -40,10 +40,7 @@ export default function SiteReplicationPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SiteReplicationNewForm
|
||||
open={newFormOpen}
|
||||
onOpenChange={setNewFormOpen}
|
||||
/>
|
||||
<SiteReplicationNewForm open={newFormOpen} onOpenChange={setNewFormOpen} />
|
||||
</Page>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,7 @@ import { useState, useEffect, useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
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, CardTitle } from "@/components/ui/card"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { useSSE } from "@/hooks/use-sse"
|
||||
@@ -20,13 +14,7 @@ import { useMessage } from "@/lib/feedback/message"
|
||||
export default function SSEPage() {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const {
|
||||
getKMSStatus,
|
||||
clearCache,
|
||||
getDetailedStatus,
|
||||
startKMS,
|
||||
stopKMS,
|
||||
} = useSSE()
|
||||
const { getKMSStatus, clearCache, startKMS, stopKMS } = useSSE()
|
||||
|
||||
const [status, setStatus] = useState<{
|
||||
status?: string
|
||||
@@ -136,9 +124,7 @@ export default function SSEPage() {
|
||||
const getKmsStatusDescription = () => {
|
||||
if (!status) return t("KMS server is not configured")
|
||||
if (status.status === "Running") {
|
||||
return status.healthy
|
||||
? t("KMS server is running and healthy")
|
||||
: t("KMS server is running but unhealthy")
|
||||
return status.healthy ? t("KMS server is running and healthy") : t("KMS server is running but unhealthy")
|
||||
}
|
||||
if (status.status === "Configured") return t("KMS server is configured but not running")
|
||||
return t("KMS server status unknown")
|
||||
@@ -151,31 +137,23 @@ export default function SSEPage() {
|
||||
<PageHeader
|
||||
description={
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
"Configure server-side encryption for your objects using external key management services."
|
||||
)}
|
||||
{t("Configure server-side encryption for your objects using external key management services.")}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("Server-Side Encryption (SSE) Configuration")}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold">{t("Server-Side Encryption (SSE) Configuration")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-8">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<CardTitle className="text-base sm:text-lg">
|
||||
{t("KMS Status Overview")}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base sm:text-lg">{t("KMS Status Overview")}</CardTitle>
|
||||
<Badge variant="secondary" className="text-sm uppercase">
|
||||
{loading ? t("Loading...") : getKmsStatusText()}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{getKmsStatusDescription()}
|
||||
</CardDescription>
|
||||
<CardDescription>{getKmsStatusDescription()}</CardDescription>
|
||||
{status?.backend_type && (
|
||||
<CardDescription>
|
||||
{t("Backend")}: {status.backend_type}
|
||||
@@ -185,37 +163,20 @@ export default function SSEPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Backend Type")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{status?.backend_type ?? t("Not configured")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("Backend Type")}</p>
|
||||
<p className="text-sm font-medium text-foreground">{status?.backend_type ?? t("Not configured")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Status")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{status?.status ?? t("Not configured")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("Status")}</p>
|
||||
<p className="text-sm font-medium text-foreground">{status?.status ?? t("Not configured")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Health")}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{status?.healthy ? t("Healthy") : t("Unhealthy")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("Health")}</p>
|
||||
<p className="text-sm font-medium text-foreground">{status?.healthy ? t("Healthy") : t("Unhealthy")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={refreshingStatus}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<Button size="sm" variant="outline" disabled={refreshingStatus} onClick={handleRefresh}>
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -227,24 +188,13 @@ export default function SSEPage() {
|
||||
>
|
||||
{t("Clear Cache")}
|
||||
</Button>
|
||||
{hasConfiguration &&
|
||||
(status?.status === "Configured" || status?.status === "Error") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleStartKMS}
|
||||
aria-disabled={startingKMS}
|
||||
>
|
||||
{t("Start KMS")}
|
||||
</Button>
|
||||
)}
|
||||
{hasConfiguration && (status?.status === "Configured" || status?.status === "Error") && (
|
||||
<Button size="sm" variant="default" onClick={handleStartKMS} aria-disabled={startingKMS}>
|
||||
{t("Start KMS")}
|
||||
</Button>
|
||||
)}
|
||||
{hasConfiguration && status?.status === "Running" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStopKMS}
|
||||
aria-disabled={stoppingKMS}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={handleStopKMS} aria-disabled={stoppingKMS}>
|
||||
{t("Stop KMS")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -255,9 +205,7 @@ export default function SSEPage() {
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("KMS Configuration")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("Full KMS configuration form - coming soon")}
|
||||
</CardDescription>
|
||||
<CardDescription>{t("Full KMS configuration form - coming soon")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function PerformancePage() {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("Server Information")}</h1>
|
||||
<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" />
|
||||
@@ -167,7 +167,7 @@ export default function PerformancePage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Server Information")}</h1>
|
||||
<h1 className="text-2xl font-bold">{t("Running Status")}</h1>
|
||||
</PageHeader>
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
@@ -186,7 +186,7 @@ export default function PerformancePage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Server Information")}</h1>
|
||||
<h1 className="text-2xl font-bold">{t("Running Status")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-8">
|
||||
@@ -69,14 +69,39 @@ export default function TiersPage() {
|
||||
loadTiers()
|
||||
}, [loadTiers])
|
||||
|
||||
const deleteTier = useCallback(
|
||||
async (row: TierRow) => {
|
||||
try {
|
||||
const name = getConfig(row)?.name || ""
|
||||
await removeTiers(name)
|
||||
message.success(t("Delete Success"))
|
||||
loadTiers()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Delete Failed"))
|
||||
}
|
||||
},
|
||||
[removeTiers, message, t, loadTiers],
|
||||
)
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(row: TierRow) => {
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this tier?"),
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => deleteTier(row),
|
||||
})
|
||||
},
|
||||
[dialog, t, deleteTier],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<TierRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: () => t("Tier Type"),
|
||||
accessorKey: "type",
|
||||
cell: ({ row }) => (
|
||||
<span className="capitalize">{row.original.type || "-"}</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="capitalize">{row.original.type || "-"}</span>,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
@@ -121,11 +146,7 @@ export default function TiersPage() {
|
||||
<RiKey2Line className="size-4" aria-hidden />
|
||||
<span>{t("Update Key")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -133,7 +154,7 @@ export default function TiersPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
[t, confirmDelete],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<TierRow>({
|
||||
@@ -142,29 +163,6 @@ export default function TiersPage() {
|
||||
getRowId: (row) => `${row.type}-${getConfig(row)?.name}`,
|
||||
})
|
||||
|
||||
const confirmDelete = (row: TierRow) => {
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this tier?"),
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => deleteTier(row),
|
||||
})
|
||||
}
|
||||
|
||||
const deleteTier = async (row: TierRow) => {
|
||||
try {
|
||||
const name = getConfig(row)?.name || ""
|
||||
await removeTiers(name)
|
||||
message.success(t("Delete Success"))
|
||||
loadTiers()
|
||||
} catch (error) {
|
||||
message.error(
|
||||
(error as Error).message || t("Delete Failed")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
@@ -188,16 +186,10 @@ export default function TiersPage() {
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Tiers")}
|
||||
emptyDescription={t(
|
||||
"Add tiers to configure remote storage destinations."
|
||||
)}
|
||||
emptyDescription={t("Add tiers to configure remote storage destinations.")}
|
||||
/>
|
||||
|
||||
<TiersNewForm
|
||||
open={newFormOpen}
|
||||
onOpenChange={setNewFormOpen}
|
||||
onSuccess={loadTiers}
|
||||
/>
|
||||
<TiersNewForm open={newFormOpen} onOpenChange={setNewFormOpen} onSuccess={loadTiers} />
|
||||
<TiersChangeKey
|
||||
open={changeKeyOpen}
|
||||
onOpenChange={setChangeKeyOpen}
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
RiAddLine,
|
||||
RiDeleteBin5Line,
|
||||
RiEdit2Line,
|
||||
RiGroup2Fill,
|
||||
} from "@remixicon/react"
|
||||
import { RiAddLine, RiDeleteBin5Line, RiEdit2Line, RiGroup2Fill } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { Page } from "@/components/page"
|
||||
@@ -42,26 +37,25 @@ export default function UserGroupsPage() {
|
||||
const [editRow, setEditRow] = useState<GroupRow | null>(null)
|
||||
const [policiesDialogOpen, setPoliciesDialogOpen] = useState(false)
|
||||
|
||||
const getDataList = async () => {
|
||||
const getDataList = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = (await listGroup()) as string[] | undefined
|
||||
setData(
|
||||
(res ?? []).map((name) => ({
|
||||
name,
|
||||
}))
|
||||
})),
|
||||
)
|
||||
} catch {
|
||||
message.error(t("Failed to get data"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
table.resetRowSelection()
|
||||
}
|
||||
}
|
||||
}, [listGroup, message, t])
|
||||
|
||||
useEffect(() => {
|
||||
getDataList()
|
||||
}, [])
|
||||
}, [getDataList])
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!searchTerm) return data
|
||||
@@ -69,14 +63,48 @@ export default function UserGroupsPage() {
|
||||
return data.filter((row) => row.name.toLowerCase().includes(term))
|
||||
}, [data, searchTerm])
|
||||
|
||||
const openEditItem = React.useCallback((row: GroupRow) => {
|
||||
setEditRow(row)
|
||||
setEditFormOpen(true)
|
||||
}, [])
|
||||
|
||||
const deleteItem = React.useCallback(
|
||||
async (row: GroupRow) => {
|
||||
try {
|
||||
const info = (await getGroup(row.name)) as { members?: string[] }
|
||||
if (info?.members?.length) {
|
||||
message.error(t("Please remove members first"))
|
||||
return
|
||||
}
|
||||
await removeGroup(row.name)
|
||||
message.success(t("Delete Success"))
|
||||
await getDataList()
|
||||
} catch {
|
||||
message.error(t("Delete Failed"))
|
||||
}
|
||||
},
|
||||
[getGroup, removeGroup, message, t, getDataList],
|
||||
)
|
||||
|
||||
const confirmDelete = React.useCallback(
|
||||
(row: GroupRow) => {
|
||||
dialog.error({
|
||||
title: t("Confirm Delete"),
|
||||
content: "",
|
||||
positiveText: t("Delete"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => deleteItem(row),
|
||||
})
|
||||
},
|
||||
[dialog, t, deleteItem],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<GroupRow>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => t("Name"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
filterFn: "includesString",
|
||||
},
|
||||
{
|
||||
@@ -87,21 +115,11 @@ export default function UserGroupsPage() {
|
||||
meta: { width: 200 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openEditItem(row.original)}
|
||||
>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => openEditItem(row.original)}>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -109,7 +127,7 @@ export default function UserGroupsPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
[t, openEditItem, confirmDelete],
|
||||
)
|
||||
|
||||
const { table, selectedRowIds } = useDataTable<GroupRow>({
|
||||
@@ -121,35 +139,9 @@ export default function UserGroupsPage() {
|
||||
|
||||
const selectedKeys = Array.from(selectedRowIds)
|
||||
|
||||
const openEditItem = (row: GroupRow) => {
|
||||
setEditRow(row)
|
||||
setEditFormOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = (row: GroupRow) => {
|
||||
dialog.error({
|
||||
title: t("Confirm Delete"),
|
||||
content: "",
|
||||
positiveText: t("Delete"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => deleteItem(row),
|
||||
})
|
||||
}
|
||||
|
||||
const deleteItem = async (row: GroupRow) => {
|
||||
try {
|
||||
const info = (await getGroup(row.name)) as { members?: string[] }
|
||||
if (info?.members?.length) {
|
||||
message.error(t("Please remove members first"))
|
||||
return
|
||||
}
|
||||
await removeGroup(row.name)
|
||||
message.success(t("Delete Success"))
|
||||
await getDataList()
|
||||
} catch {
|
||||
message.error(t("Delete Failed"))
|
||||
}
|
||||
}
|
||||
React.useEffect(() => {
|
||||
table.resetRowSelection()
|
||||
}, [data, table])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -172,11 +164,7 @@ export default function UserGroupsPage() {
|
||||
<RiGroup2Fill className="size-4" />
|
||||
{t("Assign Policy")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setNewFormOpen(true)}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
{t("Add User Group")}
|
||||
</Button>
|
||||
@@ -196,17 +184,8 @@ export default function UserGroupsPage() {
|
||||
/>
|
||||
<DataTablePagination table={table} />
|
||||
|
||||
<UserGroupNewForm
|
||||
open={newFormOpen}
|
||||
onOpenChange={setNewFormOpen}
|
||||
onSuccess={getDataList}
|
||||
/>
|
||||
<UserGroupEditForm
|
||||
open={editFormOpen}
|
||||
onOpenChange={setEditFormOpen}
|
||||
row={editRow}
|
||||
onSuccess={getDataList}
|
||||
/>
|
||||
<UserGroupNewForm open={newFormOpen} onOpenChange={setNewFormOpen} onSuccess={getDataList} />
|
||||
<UserGroupEditForm open={editFormOpen} onOpenChange={setEditFormOpen} row={editRow} onSuccess={getDataList} />
|
||||
<UserGroupSetPoliciesMultiple
|
||||
checkedKeys={selectedKeys}
|
||||
open={policiesDialogOpen}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function UsersPage() {
|
||||
const [editFormOpen, setEditFormOpen] = useState(false)
|
||||
const [editRow, setEditRow] = useState<UserRow | null>(null)
|
||||
|
||||
const getDataList = async () => {
|
||||
const getDataList = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = (await listUsers()) as Record<string, Record<string, unknown>>
|
||||
@@ -66,17 +66,16 @@ export default function UsersPage() {
|
||||
...(typeof info === "object" ? info : {}),
|
||||
})) as UserRow[]
|
||||
setData(users)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error(t("Failed to get data"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
table.resetRowSelection()
|
||||
}
|
||||
}
|
||||
}, [listUsers, message, t])
|
||||
|
||||
useEffect(() => {
|
||||
getDataList()
|
||||
}, [])
|
||||
}, [getDataList])
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!searchTerm) return data
|
||||
@@ -99,9 +98,7 @@ export default function UsersPage() {
|
||||
accessorKey: "status",
|
||||
header: () => t("Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.status === "enabled" ? "secondary" : "outline"}
|
||||
>
|
||||
<Badge variant={row.original.status === "enabled" ? "secondary" : "outline"}>
|
||||
{row.original.status === "enabled" ? t("Enabled") : row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
@@ -124,21 +121,11 @@ export default function UsersPage() {
|
||||
meta: { width: 200 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditItem(row.original)}
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -157,6 +144,10 @@ export default function UsersPage() {
|
||||
|
||||
const selectedKeys = selectedRowIds
|
||||
|
||||
React.useEffect(() => {
|
||||
table.resetRowSelection()
|
||||
}, [data, table])
|
||||
|
||||
const openEditItem = (row: UserRow) => {
|
||||
setEditRow(row)
|
||||
setEditFormOpen(true)
|
||||
@@ -178,7 +169,7 @@ export default function UsersPage() {
|
||||
message.success(t("Delete Success"))
|
||||
table.resetRowSelection()
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error(t("Delete Failed"))
|
||||
}
|
||||
}
|
||||
@@ -196,7 +187,7 @@ export default function UsersPage() {
|
||||
message.success(t("Delete Success"))
|
||||
table.resetRowSelection()
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error(t("Delete Failed"))
|
||||
}
|
||||
},
|
||||
@@ -219,21 +210,11 @@ export default function UsersPage() {
|
||||
clearable
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={deleteByList}
|
||||
>
|
||||
<Button type="button" variant="outline" disabled={!selectedKeys.length} onClick={deleteByList}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
{t("Delete Selected")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={addToGroup}
|
||||
>
|
||||
<Button type="button" variant="outline" disabled={!selectedKeys.length} onClick={addToGroup}>
|
||||
<RiGroup2Fill className="size-4" />
|
||||
{t("Add to Group")}
|
||||
</Button>
|
||||
@@ -258,12 +239,7 @@ export default function UsersPage() {
|
||||
<DataTablePagination table={table} />
|
||||
|
||||
<UserNewForm open={newFormOpen} onOpenChange={setNewFormOpen} onSuccess={getDataList} />
|
||||
<UserEditForm
|
||||
open={editFormOpen}
|
||||
onOpenChange={setEditFormOpen}
|
||||
row={editRow}
|
||||
onSuccess={getDataList}
|
||||
/>
|
||||
<UserEditForm open={editFormOpen} onOpenChange={setEditFormOpen} row={editRow} onSuccess={getDataList} />
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
+2
-2
@@ -89,7 +89,7 @@
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.87 0.00 0);
|
||||
--primary: oklch(0.87 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
@@ -149,4 +149,4 @@
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-12
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import { I18nProvider } from "@/components/providers/i18n-provider"
|
||||
import { AuthProvider } from "@/contexts/auth-context"
|
||||
@@ -10,28 +10,27 @@ import { TaskProvider } from "@/contexts/task-context"
|
||||
import { PermissionsProvider } from "@/hooks/use-permissions"
|
||||
import { AppUiProvider } from "@/components/providers/app-ui-provider"
|
||||
|
||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
})
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RustFS",
|
||||
description:
|
||||
"RustFS is a distributed file system written in Rust.",
|
||||
};
|
||||
description: "RustFS is a distributed file system written in Rust.",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
@@ -45,7 +44,7 @@ export default function RootLayout({
|
||||
<S3Provider>
|
||||
<TaskProvider>
|
||||
<PermissionsProvider>
|
||||
<AppUiProvider>{children}</AppUiProvider>
|
||||
<AppUiProvider>{children}</AppUiProvider>
|
||||
</PermissionsProvider>
|
||||
</TaskProvider>
|
||||
</S3Provider>
|
||||
@@ -55,5 +54,5 @@ export default function RootLayout({
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user