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:
安正超
2026-02-04 21:42:11 +08:00
committed by GitHub
parent ba0ceb5220
commit ca87e9e007
205 changed files with 3219 additions and 6286 deletions
+3 -4
View File
@@ -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
View File
@@ -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 -5
View File
@@ -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>
}
+6 -38
View File
@@ -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>
+12 -35
View File
@@ -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>
+106 -62
View File
@@ -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>
+3 -9
View File
@@ -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>
+12 -45
View File
@@ -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>
)
}
+33 -78
View File
@@ -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>
)
+23 -90
View File
@@ -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>
+1 -5
View File
@@ -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"
+76 -105
View File
@@ -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
View File
@@ -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")
}
+3 -16
View File
@@ -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>
)
}
+65 -88
View File
@@ -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>
);
)
}
+18 -58
View File
@@ -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>
+14 -17
View File
@@ -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>
);
)
}
+21 -73
View File
@@ -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">
+32 -40
View File
@@ -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}
+51 -72
View File
@@ -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}
+16 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
)
}