diff --git a/app/(dashboard)/buckets/page.tsx b/app/(dashboard)/buckets/page.tsx index d6642185..21d9a74b 100644 --- a/app/(dashboard)/buckets/page.tsx +++ b/app/(dashboard)/buckets/page.tsx @@ -4,25 +4,14 @@ import { useSearchParams } from "next/navigation" import { useTranslation } from "react-i18next" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { BucketInfo } from "@/components/buckets/info" -import { usePermissions } from "@/hooks/use-permissions" -import { CONSOLE_SCOPES } from "@/lib/console-permissions" -import { BucketLifecycleTab } from "@/components/buckets/lifecycle-tab" -import { BucketReplicationTab } from "@/components/buckets/replication-tab" -import { BucketEventsTab } from "@/components/buckets/events-tab" export default function BucketSettingsPage() { const { t } = useTranslation() const searchParams = useSearchParams() - const { hasPermission } = usePermissions() const bucketName = searchParams.get("bucket") ?? "" - const canViewLifecycle = hasPermission(CONSOLE_SCOPES.VIEW_BUCKET_LIFECYCLE) - const canViewReplication = hasPermission(CONSOLE_SCOPES.VIEW_BUCKET_REPLICATION) - const canViewEvents = hasPermission(CONSOLE_SCOPES.VIEW_BUCKET_EVENTS) - return ( @@ -31,50 +20,7 @@ export default function BucketSettingsPage() { - - - - {t("Overview")} - - {canViewLifecycle && ( - - {t("Lifecycle")} - - )} - {canViewReplication && ( - - {t("Replication")} - - )} - {canViewEvents && ( - - {t("Events")} - - )} - - - - {bucketName ? : null} - - - {canViewLifecycle && ( - - {bucketName ? : null} - - )} - - {canViewReplication && ( - - {bucketName ? : null} - - )} - - {canViewEvents && ( - - {bucketName ? : null} - - )} - + {bucketName ? : null} ) } diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx index 496342d3..2955cc04 100644 --- a/app/(dashboard)/events/page.tsx +++ b/app/(dashboard)/events/page.tsx @@ -1,200 +1,49 @@ "use client" -import { useState, useEffect, useCallback, useMemo } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" import { useTranslation } from "react-i18next" -import { RiAddLine, RiRefreshLine } from "@remixicon/react" +import { RiArrowLeftLine } from "@remixicon/react" import { Button } from "@/components/ui/button" 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 { EventsNewForm } from "@/components/events/new-form" -import { getEventsColumns } from "@/components/events/columns" -import { useBucket } from "@/hooks/use-bucket" -import { usePermissions } from "@/hooks/use-permissions" -import { useDialog } from "@/lib/feedback/dialog" -import { useMessage } from "@/lib/feedback/message" -import type { NotificationItem } from "@/lib/events" +import { BucketList } from "@/components/buckets/list" +import { BucketEventsTab } from "@/components/buckets/events-tab" +import { buildModuleBucketPath } from "@/lib/module-bucket-route" export default function EventsPage() { const { t } = useTranslation() - const message = useMessage() - const dialog = useDialog() - const { listBucketNotifications, putBucketNotifications } = useBucket() - const { canCapability } = usePermissions() - - const [bucketName, setBucketName] = useState(null) - const [data, setData] = useState([]) - const [loading, setLoading] = useState(false) - const [newFormOpen, setNewFormOpen] = useState(false) - - const canEditEvents = bucketName ? canCapability("bucket.events.edit", { bucket: bucketName }) : false - - const loadData = useCallback(async () => { - if (!bucketName) { - setData([]) - return - } - setLoading(true) - try { - const response = await listBucketNotifications(bucketName) - const notifications: NotificationItem[] = [] - - const addFromConfig = ( - configs: Array<{ - Id?: string - LambdaFunctionArn?: string - QueueArn?: string - TopicArn?: string - Events?: string[] - Filter?: { Key?: { FilterRules?: Array<{ Name: string; Value: string }> } } - }>, - type: NotificationItem["type"], - 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)[arnKey] - notifications.push({ - id: config.Id ?? "", - type, - arn: arn ?? "", - events: config.Events ?? [], - prefix, - suffix, - filterRules: config.Filter?.Key?.FilterRules ?? [], - }) - }, - ) - } - - const r = response as { - LambdaFunctionConfigurations?: unknown[] - QueueConfigurations?: unknown[] - TopicConfigurations?: unknown[] - } - - 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) { - console.error(t("Get Notification Config Failed"), error) - setData([]) - } finally { - setLoading(false) - } - }, [bucketName, listBucketNotifications, t]) - - useEffect(() => { - loadData() - }, [loadData]) - - const handleRowDelete = useCallback( - async (row: NotificationItem) => { - const confirmed = await new Promise((resolve) => { - dialog.warning({ - title: t("Confirm Delete"), - content: t("Are you sure you want to delete this notification configuration?"), - positiveText: t("Delete"), - negativeText: t("Cancel"), - onPositiveClick: () => resolve(true), - onNegativeClick: () => resolve(false), - }) - }) - - if (!confirmed || !bucketName) return - - try { - setLoading(true) - const currentResponse = await listBucketNotifications(bucketName) - const currentNotifications = (currentResponse ?? {}) as unknown as Record - - const configKey = - row.type === "Lambda" - ? "LambdaFunctionConfigurations" - : row.type === "SQS" - ? "QueueConfigurations" - : "TopicConfigurations" - const configs = (currentNotifications as Record>)[configKey] - const updated = configs?.filter((c) => c.Id !== row.id) ?? [] - - const newConfig = { - ...currentNotifications, - ...(row.type === "Lambda" - ? { LambdaFunctionConfigurations: updated } - : row.type === "SQS" - ? { QueueConfigurations: updated } - : { TopicConfigurations: updated }), - } - - await putBucketNotifications(bucketName, newConfig) - message.success(t("Delete Success")) - loadData() - } catch (error) { - console.error(t("Delete Failed"), error) - message.error(`${t("Delete Failed")}: ${(error as Error).message ?? error}`) - } finally { - setLoading(false) - } - }, - [bucketName, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t], - ) - - const columns = useMemo( - () => getEventsColumns(t, handleRowDelete, canEditEvents), - [t, handleRowDelete, canEditEvents], - ) - - const { table } = useDataTable({ - data, - columns, - getRowId: (row) => row.id, - }) + const searchParams = useSearchParams() + const bucketName = searchParams.get("bucket") ?? "" + + if (!bucketName) { + return ( + + {t("Events")}} + emptyDescription={t("Create a bucket to start storing objects.")} + getBucketHref={(name) => buildModuleBucketPath("/events", name)} + /> + + ) + } return ( - - setNewFormOpen(true)} disabled={!bucketName}> - - {t("Add Event Subscription")} - - - - {t("Refresh")} - - > + }> + + {t("Buckets")} + } > - {t("Events")} + + {t("Events")}: {bucketName} + - - - {bucketName && ( - - )} + ) } diff --git a/app/(dashboard)/lifecycle/page.tsx b/app/(dashboard)/lifecycle/page.tsx index 9f7d76ef..643765ef 100644 --- a/app/(dashboard)/lifecycle/page.tsx +++ b/app/(dashboard)/lifecycle/page.tsx @@ -1,220 +1,49 @@ "use client" -import * as React from "react" -import { useState, useEffect, useCallback, useMemo } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" import { useTranslation } from "react-i18next" -import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react" +import { RiArrowLeftLine } 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 - Filter?: { - Prefix?: string - Tag?: { Key: string; Value: string } - And?: { - 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 }> - NoncurrentVersionTransitions?: Array<{ - NoncurrentDays?: number - StorageClass?: string - }> -} +import { BucketList } from "@/components/buckets/list" +import { BucketLifecycleTab } from "@/components/buckets/lifecycle-tab" +import { buildModuleBucketPath } from "@/lib/module-bucket-route" export default function LifecyclePage() { const { t } = useTranslation() - const message = useMessage() - const dialog = useDialog() - const { getBucketLifecycleConfiguration, deleteBucketLifecycle, putBucketLifecycleConfiguration } = useBucket() - - const [bucketName, setBucketName] = useState(null) - const [data, setData] = useState([]) - const [loading, setLoading] = useState(false) - const [newFormOpen, setNewFormOpen] = useState(false) - - const loadData = useCallback(async () => { - if (!bucketName) { - setData([]) - return - } - setLoading(true) - try { - const response = await getBucketLifecycleConfiguration(bucketName) - const rules = [...(response?.Rules ?? [])] - .map((r) => r as LifecycleRule) - .sort((a, b) => (a.ID ?? "").localeCompare(b.ID ?? "")) - setData(rules) - } catch { - setData([]) - } finally { - setLoading(false) - } - }, [bucketName, getBucketLifecycleConfiguration]) - - useEffect(() => { - loadData() - }, [loadData]) - - const columns: ColumnDef[] = useMemo( - () => [ - { - id: "type", - header: () => t("Type"), - accessorFn: (row) => (row.Transitions || row.NoncurrentVersionTransitions ? "Transition" : "Expire"), - }, - { - id: "version", - header: () => t("Version"), - accessorFn: (row) => - row.NoncurrentVersionExpiration || row.NoncurrentVersionTransitions - ? t("Non-current Version") - : t("Current Version"), - }, - { - id: "deleteMarker", - header: () => t("Expiration Delete Mark"), - accessorFn: (row) => (row.Expiration?.ExpiredObjectDeleteMarker ? t("On") : t("Off")), - }, - { - id: "tier", - header: () => t("Tier"), - accessorFn: (row) => - row.Transitions?.[0]?.StorageClass || row.NoncurrentVersionTransitions?.[0]?.StorageClass || "--", - }, - { - id: "prefix", - header: () => t("Prefix"), - accessorFn: (row) => row.Filter?.Prefix || row.Filter?.And?.Prefix || "", - }, - { - id: "timeCycle", - header: () => `${t("Time Cycle")} (${t("Days")})`, - accessorFn: (row) => - row.Expiration?.Days ?? - row.NoncurrentVersionExpiration?.NoncurrentDays ?? - row.Transitions?.[0]?.Days ?? - row.NoncurrentVersionTransitions?.[0]?.NoncurrentDays ?? - "", - }, - { - id: "status", - header: () => t("Status"), - accessorFn: (row) => row.Status ?? "-", - cell: ({ row }) => ( - - {row.original.Status ?? "-"} - - ), - }, - { - id: "actions", - header: () => t("Actions"), - enableSorting: false, - cell: ({ row }) => ( - - confirmDelete(row.original)}> - - {t("Delete")} - - - ), - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps -- confirmDelete used in cell, stable ref - [t], - ) - - const { table } = useDataTable({ - data, - columns, - getRowId: (row) => row.ID ?? JSON.stringify(row), - }) - - const confirmDelete = (row: LifecycleRule) => { - dialog.error({ - title: t("Warning"), - content: t("Are you sure you want to delete this rule?"), - 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 - - try { - if (remaining.length === 0) { - await deleteBucketLifecycle(bucketName) - } else { - await putBucketLifecycleConfiguration(bucketName, { - Rules: remaining, - }) - } - message.success(t("Delete Success")) - loadData() - } catch (error) { - message.error((error as Error).message || t("Delete Failed")) - } + const searchParams = useSearchParams() + const bucketName = searchParams.get("bucket") ?? "" + + if (!bucketName) { + return ( + + {t("Lifecycle")}} + emptyDescription={t("Create a bucket to start storing objects.")} + getBucketHref={(name) => buildModuleBucketPath("/lifecycle", name)} + /> + + ) } return ( - - setNewFormOpen(true)} disabled={!bucketName}> - - {t("Add Lifecycle Rule")} - - - - {t("Refresh")} - - > + }> + + {t("Buckets")} + } > - {t("Lifecycle")} + + {t("Lifecycle")}: {bucketName} + - - - {bucketName && ( - - )} + ) } diff --git a/app/(dashboard)/replication/page.tsx b/app/(dashboard)/replication/page.tsx index aaf75ad6..e331f7af 100644 --- a/app/(dashboard)/replication/page.tsx +++ b/app/(dashboard)/replication/page.tsx @@ -1,201 +1,49 @@ "use client" -import * as React from "react" -import { useState, useEffect, useCallback, useMemo } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" import { useTranslation } from "react-i18next" -import { RiAddLine, RiRefreshLine, RiDeleteBin7Line } from "@remixicon/react" +import { RiArrowLeftLine } 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 } -} +import { BucketList } from "@/components/buckets/list" +import { BucketReplicationTab } from "@/components/buckets/replication-tab" +import { buildModuleBucketPath } from "@/lib/module-bucket-route" export default function ReplicationPage() { const { t } = useTranslation() - const message = useMessage() - const dialog = useDialog() - const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } = - useBucket() - - const [bucketName, setBucketName] = useState(null) - const [data, setData] = useState([]) - const [loading, setLoading] = useState(false) - const [newFormOpen, setNewFormOpen] = useState(false) - - const loadData = useCallback(async () => { - if (!bucketName) { - setData([]) - return - } - setLoading(true) - try { - const res = await getBucketReplication(bucketName) - setData(res?.ReplicationConfiguration?.Rules ?? []) - } catch { - setData([]) - } finally { - setLoading(false) - } - }, [bucketName, getBucketReplication]) - - useEffect(() => { - loadData() - }, [loadData]) - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "ID", - header: () => t("Rule ID"), - cell: ({ row }) => {row.original.ID || "-"}, - }, - { - accessorKey: "Status", - header: () => t("Status"), - cell: ({ row }) => ( - - {row.original.Status === "Enabled" ? t("Enabled") : t("Disabled")} - - ), - }, - { - accessorKey: "Priority", - header: () => t("Priority"), - cell: ({ row }) => {String(row.original.Priority ?? "-")}, - }, - { - id: "Filter", - header: () => t("Prefix"), - cell: ({ row }) => {row.original.Filter?.Prefix || "-"}, - }, - { - id: "destination-bucket", - header: () => t("Destination Bucket"), - cell: ({ row }) => { - const bucketArn = row.original.Destination?.Bucket || "" - return {bucketArn.replace(/^arn:aws:s3:::/, "") || "-"} - }, - }, - { - id: "destination-storage", - header: () => t("Storage Class"), - cell: ({ row }) => {row.original.Destination?.StorageClass || "-"}, - }, - { - id: "actions", - header: () => t("Actions"), - enableSorting: false, - cell: ({ row }) => ( - - confirmDelete(row.original)}> - - {t("Delete")} - - - ), - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps -- confirmDelete used in cell, stable ref - [t], - ) - - const { table } = useDataTable({ - data, - columns, - getRowId: (row) => row.ID ?? JSON.stringify(row), - }) - - const confirmDelete = (rule: ReplicationRule) => { - dialog.error({ - title: t("Warning"), - content: t("Are you sure you want to delete this replication rule?"), - positiveText: t("Confirm"), - negativeText: t("Cancel"), - onPositiveClick: () => handleRowDelete(rule), - }) - } - - const handleRowDelete = async (rule: ReplicationRule) => { - const remaining = data.filter((item) => item !== rule) - if (!bucketName) return - - try { - if (remaining.length === 0) { - await deleteBucketReplication(bucketName) - await deleteRemoteReplicationTarget(bucketName, rule.Destination?.Bucket ?? "") - } else { - const currentConfig = await getBucketReplication(bucketName) - const role = currentConfig?.ReplicationConfiguration?.Role - if (!role) { - throw new Error("Replication configuration Role is missing") - } - await putBucketReplication(bucketName, { - Role: role, - Rules: remaining, - }) - } - message.success(t("Delete Success")) - loadData() - } catch (error) { - message.error((error as Error).message || t("Delete Failed")) - } + const searchParams = useSearchParams() + const bucketName = searchParams.get("bucket") ?? "" + + if (!bucketName) { + return ( + + {t("Bucket Replication")}} + emptyDescription={t("Create a bucket to start storing objects.")} + getBucketHref={(name) => buildModuleBucketPath("/replication", name)} + /> + + ) } return ( - - setNewFormOpen(true)} disabled={!bucketName}> - - {t("Add Replication Rule")} - - - - {t("Refresh")} - - > + }> + + {t("Buckets")} + } > - {t("Bucket Replication")} + + {t("Bucket Replication")}: {bucketName} + - - - {bucketName && ( - - )} + ) } diff --git a/components/buckets/events-tab.tsx b/components/buckets/events-tab.tsx index 89d8ea31..47872de9 100644 --- a/components/buckets/events-tab.tsx +++ b/components/buckets/events-tab.tsx @@ -19,9 +19,10 @@ import type { NotificationItem } from "@/lib/events" interface BucketEventsTabProps { bucketName: string + hideTitle?: boolean } -export function BucketEventsTab({ bucketName }: BucketEventsTabProps) { +export function BucketEventsTab({ bucketName, hideTitle = false }: BucketEventsTabProps) { const { t } = useTranslation() const message = useMessage() const dialog = useDialog() @@ -193,7 +194,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) { return ( - {t("Events")} + {hideTitle ? null : {t("Events")}} {canEditEvents ? ( setNewFormOpen(true)} disabled={!canManageBucketEvents}> diff --git a/components/buckets/lifecycle-tab.tsx b/components/buckets/lifecycle-tab.tsx index 6ccbcca1..a78e328a 100644 --- a/components/buckets/lifecycle-tab.tsx +++ b/components/buckets/lifecycle-tab.tsx @@ -38,9 +38,10 @@ interface LifecycleRule { interface BucketLifecycleTabProps { bucketName: string + hideTitle?: boolean } -export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) { +export function BucketLifecycleTab({ bucketName, hideTitle = false }: BucketLifecycleTabProps) { const { t } = useTranslation() const message = useMessage() const dialog = useDialog() @@ -186,7 +187,7 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) { return ( - {t("Lifecycle")} + {hideTitle ? null : {t("Lifecycle")}} {canEditLifecycle ? ( setNewFormOpen(true)}> diff --git a/components/buckets/list.tsx b/components/buckets/list.tsx new file mode 100644 index 00000000..4cf2b878 --- /dev/null +++ b/components/buckets/list.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { useTranslation } from "react-i18next" +import { RiArchiveLine, RiRefreshLine } from "@remixicon/react" +import { Button } from "@/components/ui/button" +import { SearchInput } from "@/components/search-input" +import { DataTable } from "@/components/data-table/data-table" +import { useDataTable } from "@/hooks/use-data-table" +import { useBucket } from "@/hooks/use-bucket" +import { useSystem } from "@/hooks/use-system" +import { Spinner } from "@/components/ui/spinner" +import { niceBytes } from "@/lib/functions" +import type { ColumnDef } from "@tanstack/react-table" +import dayjs from "dayjs" + +export interface BucketListRow { + Name: string + CreationDate: string + Count?: number + Size?: string + SizeBytes?: number + IsPublic?: boolean +} + +type BucketUsageMap = Record + +interface BucketListProps { + title: React.ReactNode + emptyDescription: string + getBucketHref: (bucketName: string) => string +} + +export function BucketList({ title, emptyDescription, getBucketHref }: BucketListProps) { + const { t } = useTranslation() + const { listBuckets, getBucketPolicyStatus } = useBucket() + const { getDataUsageInfo } = useSystem() + + const [searchTerm, setSearchTerm] = React.useState("") + const [data, setData] = React.useState([]) + const [pending, setPending] = React.useState(true) + const [usageLoading, setUsageLoading] = React.useState(false) + const [policyLoading, setPolicyLoading] = React.useState(false) + const fetchIdRef = React.useRef(0) + + const loadBucketUsage = React.useCallback( + async (fetchId: number, bucketNames: string[]) => { + if (bucketNames.length === 0) { + setUsageLoading(false) + return + } + + try { + const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined + if (fetchId !== fetchIdRef.current || !usage) return + + const bucketUsage = usage.buckets_usage ?? {} + 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 + return { + ...row, + Count: objectsCount, + Size: niceBytes(String(totalSize)), + SizeBytes: totalSize, + } + }), + ) + } finally { + if (fetchId === fetchIdRef.current) { + setUsageLoading(false) + } + } + }, + [getDataUsageInfo], + ) + + const loadPolicyStatus = React.useCallback( + async (fetchId: number, bucketNames: string[]) => { + if (bucketNames.length === 0) { + setPolicyLoading(false) + return + } + + try { + const results = await Promise.all( + bucketNames.map(async (name) => { + try { + const resp = (await getBucketPolicyStatus(name)) as { + PolicyStatus?: { IsPublic?: boolean } + } + return { name, isPublic: resp?.PolicyStatus?.IsPublic === true } + } catch { + return { name, isPublic: false } + } + }), + ) + if (fetchId !== fetchIdRef.current) return + + const policyMap = Object.fromEntries(results.map((r) => [r.name, r.isPublic])) + setData((prev) => + prev.map((row) => ({ + ...row, + IsPublic: policyMap[row.Name] ?? false, + })), + ) + } finally { + if (fetchId === fetchIdRef.current) { + setPolicyLoading(false) + } + } + }, + [getBucketPolicyStatus], + ) + + const fetchBuckets = React.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 + + const buckets = ((response as { Buckets?: Array<{ Name?: string; CreationDate?: string }> })?.Buckets ?? []) + .map((item) => { + const name = item?.Name + if (!name) return null + + return { + Name: name, + CreationDate: item?.CreationDate ? new Date(item.CreationDate).toISOString() : "", + } + }) + .filter((bucket): bucket is BucketListRow => bucket !== null) + .sort((a, b) => a.Name.localeCompare(b.Name)) + + setData(buckets) + + const bucketNames = buckets.map((bucket) => bucket.Name) + setUsageLoading(true) + void loadBucketUsage(fetchId, bucketNames) + + setPolicyLoading(true) + void loadPolicyStatus(fetchId, bucketNames) + } catch { + if (fetchId !== fetchIdRef.current) return + setData([]) + } finally { + if (fetchId === fetchIdRef.current) { + setPending(false) + } + } + }, + [listBuckets, loadBucketUsage, loadPolicyStatus], + ) + + React.useEffect(() => { + fetchBuckets() + }, [fetchBuckets]) + + const filteredData = React.useMemo( + () => (searchTerm ? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) : data), + [data, searchTerm], + ) + + const columns: ColumnDef[] = React.useMemo( + () => [ + { + header: () => t("Bucket"), + accessorKey: "Name", + cell: ({ row }) => ( + + + {row.original.Name} + + ), + }, + { + header: () => t("Creation Date"), + accessorKey: "CreationDate", + cell: ({ row }) => + row.original.CreationDate ? dayjs(row.original.CreationDate).format("YYYY-MM-DD HH:mm:ss") : "--", + }, + { + header: () => t("Object Count"), + accessorKey: "Count", + cell: ({ row }) => + typeof row.original.Count === "number" ? ( + row.original.Count.toLocaleString() + ) : usageLoading ? ( + + ) : ( + "--" + ), + }, + { + header: () => t("Size"), + id: "SizeBytes", + accessorFn: (row) => (typeof row.SizeBytes === "number" ? row.SizeBytes : undefined), + cell: ({ row }) => + row.original.Size ?? (usageLoading ? : "--"), + }, + { + header: () => t("Access Policy"), + accessorKey: "IsPublic", + cell: ({ row }) => { + if (typeof row.original.IsPublic === "boolean") { + return row.original.IsPublic ? ( + {t("Public")} + ) : ( + {t("Private")} + ) + } + return policyLoading ? : "--" + }, + }, + ], + [getBucketHref, policyLoading, t, usageLoading], + ) + + const { table } = useDataTable({ + data: filteredData, + columns, + manualPagination: true, + getRowId: (row) => row.Name, + }) + + return ( + <> + + {title} + + + fetchBuckets({ force: true })}> + + {t("Refresh")} + + + + + + > + ) +} diff --git a/components/buckets/replication-tab.tsx b/components/buckets/replication-tab.tsx index 68ab25d0..e650b83b 100644 --- a/components/buckets/replication-tab.tsx +++ b/components/buckets/replication-tab.tsx @@ -24,9 +24,10 @@ interface ReplicationRule { interface BucketReplicationTabProps { bucketName: string + hideTitle?: boolean } -export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps) { +export function BucketReplicationTab({ bucketName, hideTitle = false }: BucketReplicationTabProps) { const { t } = useTranslation() const message = useMessage() const dialog = useDialog() @@ -176,7 +177,7 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps) return ( - {t("Bucket Replication")} + {hideTitle ? null : {t("Bucket Replication")}} {canEditReplication ? ( setNewFormOpen(true)}> diff --git a/config/navs.ts b/config/navs.ts index 7536b74d..0acfd322 100644 --- a/config/navs.ts +++ b/config/navs.ts @@ -60,21 +60,21 @@ export default [ key: "divider-1", type: "divider", }, - // { - // label: "Bucket Events", - // to: "/events", - // icon: "ri:broadcast-line", - // }, - // { - // label: "Bucket Replication", - // to: "/replication", - // icon: "ri:file-copy-line", - // }, - // { - // label: "Lifecycle", - // to: "/lifecycle", - // icon: "ri:exchange-2-line", - // }, + { + label: "Bucket Events", + to: "/events", + icon: "ri:broadcast-line", + }, + { + label: "Bucket Replication", + to: "/replication", + icon: "ri:file-copy-line", + }, + { + label: "Lifecycle", + to: "/lifecycle", + icon: "ri:exchange-2-line", + }, { label: "Site Replication", to: "/site-replication", diff --git a/lib/dashboard-route-meta.ts b/lib/dashboard-route-meta.ts index 7b419719..68cdf714 100644 --- a/lib/dashboard-route-meta.ts +++ b/lib/dashboard-route-meta.ts @@ -17,6 +17,9 @@ export const MENU_CONTROLLED_DASHBOARD_ROUTES: readonly string[] = [ "/user-groups", "/import-export", "/status", + "/events", + "/replication", + "/lifecycle", "/tiers", "/events-target", "/audit-target", diff --git a/lib/module-bucket-route.js b/lib/module-bucket-route.js new file mode 100644 index 00000000..783e1e18 --- /dev/null +++ b/lib/module-bucket-route.js @@ -0,0 +1,6 @@ +export function buildModuleBucketPath(route, bucketName) { + if (!bucketName) return route + + const params = new URLSearchParams({ bucket: bucketName }) + return `${route}?${params.toString()}` +} diff --git a/lib/module-bucket-route.ts b/lib/module-bucket-route.ts new file mode 100644 index 00000000..a522d60e --- /dev/null +++ b/lib/module-bucket-route.ts @@ -0,0 +1,8 @@ +export type BucketModuleRoute = "/events" | "/lifecycle" | "/replication" + +export function buildModuleBucketPath(route: BucketModuleRoute, bucketName: string): string { + if (!bucketName) return route + + const params = new URLSearchParams({ bucket: bucketName }) + return `${route}?${params.toString()}` +} diff --git a/tests/lib/module-bucket-route.test.js b/tests/lib/module-bucket-route.test.js new file mode 100644 index 00000000..c6ec3e7c --- /dev/null +++ b/tests/lib/module-bucket-route.test.js @@ -0,0 +1,15 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { buildModuleBucketPath } from "../../lib/module-bucket-route.js" + +test("buildModuleBucketPath links a bucket to a module detail route", () => { + assert.equal(buildModuleBucketPath("/lifecycle", "photos"), "/lifecycle?bucket=photos") +}) + +test("buildModuleBucketPath encodes bucket names in query params", () => { + assert.equal(buildModuleBucketPath("/events", "logs archive"), "/events?bucket=logs+archive") +}) + +test("buildModuleBucketPath returns module root when bucket is empty", () => { + assert.equal(buildModuleBucketPath("/replication", ""), "/replication") +})