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 ( - - - - + } > -

{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 }) => ( -
- -
- ), - }, - ], - // 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 ( - - - - + } > -

{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 }) => ( -
- -
- ), - }, - ], - // 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 ( - - - - + } > -

{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 ? ( +
+
+ + + + ) +} 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 ? (