diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..68ff5ed --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,54 @@ +import * as React from "react" +import { cn } from "../../lib/utils" + +export interface DialogProps { + open: boolean + onClose: () => void + children: React.ReactNode + className?: string +} + +export function Dialog({ open, onClose, children, className }: DialogProps) { + const dialogRef = React.useRef(null) + + React.useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + if (open) { + if (!dialog.open) { + dialog.showModal() + } + } else { + if (dialog.open) { + dialog.close() + } + } + }, [open]) + + const handleCancel = (e: React.SyntheticEvent) => { + e.preventDefault() + onClose() + } + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === dialogRef.current) { + onClose() + } + } + + return ( + + {open ? children : null} + + ) +} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..ec65dc0 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,38 @@ +import * as React from "react" +import { cn } from "../../lib/utils" + +export interface SwitchProps extends React.ButtonHTMLAttributes { + checked: boolean + onCheckedChange?: (checked: boolean) => void +} + +export function Switch({ checked, onCheckedChange, disabled, className, ...props }: SwitchProps) { + const handleClick = () => { + if (!disabled && onCheckedChange) { + onCheckedChange(!checked) + } + } + + return ( + + ) +} diff --git a/src/features/buckets/api/buckets.api.ts b/src/features/buckets/api/buckets.api.ts new file mode 100644 index 0000000..d8e4f07 --- /dev/null +++ b/src/features/buckets/api/buckets.api.ts @@ -0,0 +1,63 @@ +import { apiRequest, IS_MOCK_MODE } from "../../../lib/api-client" +import type { + BucketRecord, + CreateBucketRequest, + ImportCandidate, + ImportCandidatesResponse, + ImportResult, + UpdateBucketRequest, +} from "./buckets.types" +import { + mockCreateBucket, + mockImportBuckets, + mockListImportCandidates, + mockUpdateBucket, + mockDeleteBucket, +} from "./buckets.mock" + +export async function createBucket(data: CreateBucketRequest): Promise { + if (IS_MOCK_MODE) { + return mockCreateBucket(data) + } + return apiRequest("/api/buckets", { + method: "POST", + body: JSON.stringify(data), + }) +} + +export async function updateBucket(name: string, data: UpdateBucketRequest): Promise { + if (IS_MOCK_MODE) { + return mockUpdateBucket(name, data) + } + return apiRequest(`/api/buckets/${encodeURIComponent(name)}`, { + method: "PATCH", + body: JSON.stringify(data), + }) +} + +export async function deleteBucket(name: string): Promise { + if (IS_MOCK_MODE) { + return mockDeleteBucket(name) + } + return apiRequest(`/api/buckets/${encodeURIComponent(name)}`, { + method: "DELETE", + }) +} + +export async function fetchImportCandidates(): Promise { + if (IS_MOCK_MODE) { + return mockListImportCandidates() + } + const response = await apiRequest("/api/import-candidates") + return response.candidates +} + +export async function importBuckets(names: string[]): Promise { + if (IS_MOCK_MODE) { + return mockImportBuckets(names) + } + return apiRequest("/api/import", { + method: "POST", + body: JSON.stringify({ names }), + }) +} diff --git a/src/features/buckets/api/buckets.keys.ts b/src/features/buckets/api/buckets.keys.ts new file mode 100644 index 0000000..821aa30 --- /dev/null +++ b/src/features/buckets/api/buckets.keys.ts @@ -0,0 +1,4 @@ +export const bucketKeys = { + all: ["buckets"] as const, + importCandidates: () => [...bucketKeys.all, "import-candidates"] as const, +} diff --git a/src/features/buckets/api/buckets.mock.ts b/src/features/buckets/api/buckets.mock.ts new file mode 100644 index 0000000..11d5498 --- /dev/null +++ b/src/features/buckets/api/buckets.mock.ts @@ -0,0 +1,214 @@ +import type { BucketStat, BucketStatsResponse, StatusResponse } from "../../status/api/status.types" +import type { + BucketRecord, + CreateBucketRequest, + ImportCandidate, + ImportResult, + UpdateBucketRequest, +} from "./buckets.types" + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +let mockBuckets: BucketStat[] = [ + { + name: "documents", + objectCount: 142, + totalSize: 1024 * 1024 * 340, // 340 MB + lastModified: new Date(Date.now() - 1000 * 60 * 15).toISOString(), + truncated: false, + publicRead: false, + error: null, + }, + { + name: "media-storage", + objectCount: 890, + totalSize: 1024 * 1024 * 1024 * 5.2, // 5.2 GB + lastModified: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), + truncated: false, + publicRead: false, + error: null, + }, + { + name: "backups", + objectCount: 24, + totalSize: 1024 * 1024 * 850, // 850 MB + lastModified: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), + truncated: false, + publicRead: false, + error: null, + }, + { + name: "public-assets", + objectCount: 65, + totalSize: 1024 * 1024 * 45, // 45 MB + lastModified: new Date(Date.now() - 1000 * 60 * 5).toISOString(), + truncated: false, + publicRead: true, + error: null, + }, +] + +let mockImportCandidatesList: ImportCandidate[] = [ + { + name: "archive-2024", + folderId: "mock-folder-archive", + objectCount: 128, + }, + { + name: "user-avatars", + folderId: "mock-folder-avatars", + objectCount: 450, + }, +] + +export async function mockFetchStatus(): Promise { + await delay(400) + return { + gateway: { + status: "ok", + region: "auto", + multipartEnabled: true, + etagStyle: "md5", + docsEnabled: true, + buckets: mockBuckets.map((b) => b.name), + publicReadBuckets: mockBuckets.filter((b) => b.publicRead).map((b) => b.name), + rootFolder: { + name: "s3-storage", + id: "mock-root-folder-id", + configured: true, + }, + corsOrigins: ["http://localhost:5173"], + credentials: { + s3Keys: true, + googleOAuth: true, + dashboardPassword: true, + }, + }, + drive: { + connected: true, + account: { + email: "demo-storage@example.com", + displayName: "S3 Drive Bridge Admin", + }, + quota: { + limit: 15 * 1024 * 1024 * 1024, // 15 GB + usage: 6.4 * 1024 * 1024 * 1024, // 6.4 GB + usageInDrive: 6.2 * 1024 * 1024 * 1024, + usageInDriveTrash: 200 * 1024 * 1024, + free: 8.6 * 1024 * 1024 * 1024, + percentUsed: 42.7, + }, + error: null, + }, + checkedAt: new Date().toISOString(), + } +} + +export async function mockFetchBucketStats(): Promise { + await delay(600) + const totalCount = mockBuckets.reduce((acc, b) => acc + b.objectCount, 0) + const totalSize = mockBuckets.reduce((acc, b) => acc + b.totalSize, 0) + + return { + buckets: [...mockBuckets], + totals: { + buckets: mockBuckets.length, + objectCount: totalCount, + totalSize, + }, + cachedAt: new Date().toISOString(), + } +} + +export async function mockCreateBucket(data: CreateBucketRequest): Promise { + await delay(500) + if (mockBuckets.some((b) => b.name === data.name)) { + throw new Error(`Bucket '${data.name}' already exists`) + } + const newBucket: BucketStat = { + name: data.name, + objectCount: 0, + totalSize: 0, + lastModified: new Date().toISOString(), + truncated: false, + publicRead: Boolean(data.publicRead), + error: null, + } + mockBuckets = [newBucket, ...mockBuckets] + return { + name: data.name, + folderId: `mock-folder-${data.name}`, + publicRead: Boolean(data.publicRead), + createdTime: new Date().toISOString(), + } +} + +export async function mockUpdateBucket(name: string, data: UpdateBucketRequest): Promise { + await delay(400) + const existing = mockBuckets.find((b) => b.name === name) + if (!existing) { + throw new Error(`Bucket '${name}' not found`) + } + + if (data.name && data.name !== name) { + if (mockBuckets.some((b) => b.name === data.name)) { + throw new Error(`Bucket '${data.name}' already exists`) + } + existing.name = data.name + } + + if (data.publicRead !== undefined) { + existing.publicRead = data.publicRead + } + + return { + name: existing.name, + folderId: `mock-folder-${existing.name}`, + publicRead: existing.publicRead, + createdTime: new Date().toISOString(), + } +} + +export async function mockDeleteBucket(name: string): Promise { + await delay(400) + const existing = mockBuckets.find((b) => b.name === name) + if (!existing) { + throw new Error(`Bucket '${name}' not found`) + } + if (existing.objectCount > 0) { + throw new Error(`Bucket '${name}' is not empty (${existing.objectCount} objects)`) + } + mockBuckets = mockBuckets.filter((b) => b.name !== name) +} + +export async function mockListImportCandidates(): Promise { + await delay(400) + return [...mockImportCandidatesList] +} + +export async function mockImportBuckets(names: string[]): Promise { + await delay(600) + const imported: string[] = [] + const failed: Array<{ name: string; error: string }> = [] + + for (const name of names) { + const candidate = mockImportCandidatesList.find((c) => c.name === name) + if (!candidate) { + failed.push({ name, error: "Candidate not found" }) + continue + } + mockBuckets.push({ + name: candidate.name, + objectCount: candidate.objectCount, + totalSize: candidate.objectCount * 1024 * 1024 * 2, + lastModified: new Date().toISOString(), + truncated: false, + publicRead: false, + error: null, + }) + mockImportCandidatesList = mockImportCandidatesList.filter((c) => c.name !== name) + imported.push(name) + } + + return { imported, failed } +} diff --git a/src/features/buckets/api/buckets.types.ts b/src/features/buckets/api/buckets.types.ts new file mode 100644 index 0000000..c19c9f2 --- /dev/null +++ b/src/features/buckets/api/buckets.types.ts @@ -0,0 +1,38 @@ +export interface BucketRecord { + name: string + folderId: string + publicRead: boolean + createdTime: string | null +} + +export interface CreateBucketRequest { + name: string + publicRead?: boolean +} + +export interface UpdateBucketRequest { + publicRead?: boolean + name?: string +} + +export interface ImportCandidate { + name: string + folderId: string + objectCount: number +} + +export interface ImportCandidatesResponse { + candidates: ImportCandidate[] +} + +export interface ImportBucketsRequest { + names: string[] +} + +export interface ImportResult { + imported: string[] + failed: Array<{ + name: string + error: string + }> +} diff --git a/src/features/buckets/components/CreateBucketDialog.tsx b/src/features/buckets/components/CreateBucketDialog.tsx new file mode 100644 index 0000000..ca8928b --- /dev/null +++ b/src/features/buckets/components/CreateBucketDialog.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import { Dialog } from "../../../components/ui/dialog" +import { Button } from "../../../components/ui/button" +import { Input } from "../../../components/ui/input" +import { Label } from "../../../components/ui/label" +import { Switch } from "../../../components/ui/switch" +import { useCreateBucket } from "../hooks/useCreateBucket" +import { AlertCircle, FolderPlus, X } from "lucide-react" + +interface CreateBucketDialogProps { + open: boolean + onClose: () => void +} + +export function CreateBucketDialog({ open, onClose }: CreateBucketDialogProps) { + const [name, setName] = React.useState("") + const [publicRead, setPublicRead] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState(null) + + const { mutate: create, isPending } = useCreateBucket() + + React.useEffect(() => { + if (open) { + setName("") + setPublicRead(false) + setErrorMessage(null) + } + }, [open]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setErrorMessage(null) + const trimmed = name.trim() + if (!trimmed) return + + create( + { name: trimmed, publicRead }, + { + onSuccess: () => { + onClose() + }, + onError: (err) => { + setErrorMessage(err.message || "Failed to create bucket") + }, + }, + ) + } + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Create Bucket

+

Create a new storage bucket under root folder

+
+
+ +
+ +
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + +
+ + setName(e.target.value)} + disabled={isPending} + className="bg-background/60 border-border/80 rounded-xl h-10" + autoFocus + /> +

+ 3-63 lowercase alphanumeric characters and hyphens. +

+
+ +
+
+ Public Read + Allow unauthenticated GET / HEAD requests +
+ +
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/features/buckets/components/DeleteBucketDialog.tsx b/src/features/buckets/components/DeleteBucketDialog.tsx new file mode 100644 index 0000000..ce08541 --- /dev/null +++ b/src/features/buckets/components/DeleteBucketDialog.tsx @@ -0,0 +1,125 @@ +import * as React from "react" +import { Dialog } from "../../../components/ui/dialog" +import { Button } from "../../../components/ui/button" +import { Input } from "../../../components/ui/input" +import { Label } from "../../../components/ui/label" +import { useDeleteBucket } from "../hooks/useDeleteBucket" +import { AlertCircle, AlertTriangle, X } from "lucide-react" + +interface DeleteBucketDialogProps { + open: boolean + onClose: () => void + bucketName: string | null +} + +export function DeleteBucketDialog({ open, onClose, bucketName }: DeleteBucketDialogProps) { + const [confirmation, setConfirmation] = React.useState("") + const [errorMessage, setErrorMessage] = React.useState(null) + + const { mutate: deleteMutate, isPending } = useDeleteBucket() + + React.useEffect(() => { + if (open) { + setConfirmation("") + setErrorMessage(null) + } + }, [open]) + + if (!bucketName) return null + + const isConfirmed = confirmation === bucketName + + const handleDelete = (e: React.FormEvent) => { + e.preventDefault() + if (!isConfirmed) return + + setErrorMessage(null) + deleteMutate(bucketName, { + onSuccess: () => { + onClose() + }, + onError: (err) => { + setErrorMessage(err.message || "Failed to delete bucket") + }, + }) + } + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Delete Bucket

+

This action will move the bucket folder to Drive trash

+
+
+ +
+ +
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + +
+

+ Only empty buckets can be deleted. If objects exist, the deletion will be rejected. +

+
+ +
+ + setConfirmation(e.target.value)} + disabled={isPending} + className="bg-background/60 border-border/80 rounded-xl h-10 font-mono text-sm" + autoFocus + /> +
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/features/buckets/components/ImportBucketsDialog.tsx b/src/features/buckets/components/ImportBucketsDialog.tsx new file mode 100644 index 0000000..6d86b1a --- /dev/null +++ b/src/features/buckets/components/ImportBucketsDialog.tsx @@ -0,0 +1,257 @@ +import * as React from "react" +import { Dialog } from "../../../components/ui/dialog" +import { Button } from "../../../components/ui/button" +import { useImportCandidates } from "../hooks/useImportCandidates" +import { useImportBuckets } from "../hooks/useImportBuckets" +import { AlertCircle, DownloadCloud, Folder, RefreshCw, X, FolderCheck, Check, Info } from "lucide-react" + +interface ImportBucketsDialogProps { + open: boolean + onClose: () => void +} + +export function ImportBucketsDialog({ open, onClose }: ImportBucketsDialogProps) { + const [selectedNames, setSelectedNames] = React.useState>(new Set()) + const [errorMessage, setErrorMessage] = React.useState(null) + + const { data: candidates, isLoading, error, refetch, isFetching } = useImportCandidates({ enabled: open }) + const { mutate: importMutate, isPending } = useImportBuckets() + + React.useEffect(() => { + if (open) { + setSelectedNames(new Set()) + setErrorMessage(null) + } + }, [open]) + + const toggleSelect = (name: string) => { + setSelectedNames((prev) => { + const next = new Set(prev) + if (next.has(name)) { + next.delete(name) + } else { + next.add(name) + } + return next + }) + } + + const handleSelectAll = () => { + if (!candidates) return + if (selectedNames.size === candidates.length) { + setSelectedNames(new Set()) + } else { + setSelectedNames(new Set(candidates.map((c) => c.name))) + } + } + + const handleImport = (e: React.FormEvent) => { + e.preventDefault() + if (selectedNames.size === 0) return + + setErrorMessage(null) + importMutate(Array.from(selectedNames), { + onSuccess: (result) => { + if (result.failed.length > 0) { + const failedDetails = result.failed.map((f) => `${f.name}: ${f.error}`).join(", ") + setErrorMessage(`Import completed with errors: ${failedDetails}`) + } else { + onClose() + } + }, + onError: (err) => { + setErrorMessage(err.message || "Failed to import buckets") + }, + }) + } + + const allSelected = Boolean(candidates && candidates.length > 0 && selectedNames.size === candidates.length) + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Import from Drive

+

+ Move root Drive folders into your managed storage root +

+
+
+ +
+ +
+ {/* Error messages */} + {errorMessage && ( +
+ + {errorMessage} +
+ )} + + {error && ( +
+ + Failed to load Drive folders: {error.message} +
+ )} + + {/* Subheader Toolbar */} +
+ + {candidates ? ( + <> + {candidates.length} candidate folder{candidates.length === 1 ? "" : "s"} found + + ) : ( + "Scanning Google Drive..." + )} + +
+ + {candidates && candidates.length > 0 && ( + + )} +
+
+ + {/* Folder Candidate List */} +
+ {isLoading ? ( +
+ + Scanning Google Drive root folders... +
+ ) : !candidates || candidates.length === 0 ? ( +
+
+ +
+
No Unmanaged Folders
+

+ All root folders in Google Drive are already managed or no other top-level folders exist. +

+
+ ) : ( + candidates.map((candidate) => { + const isSelected = selectedNames.has(candidate.name) + return ( + + ) + }) + )} +
+ +
+ + Selected folders will be moved under your configured storage root directory. +
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/features/buckets/hooks/useCreateBucket.ts b/src/features/buckets/hooks/useCreateBucket.ts new file mode 100644 index 0000000..72196b9 --- /dev/null +++ b/src/features/buckets/hooks/useCreateBucket.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createBucket } from "../api/buckets.api" +import type { CreateBucketRequest } from "../api/buckets.types" +import { statusKeys } from "../../status/api/status.keys" + +export function useCreateBucket() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: CreateBucketRequest) => createBucket(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: statusKeys.all }) + }, + }) +} diff --git a/src/features/buckets/hooks/useDeleteBucket.ts b/src/features/buckets/hooks/useDeleteBucket.ts new file mode 100644 index 0000000..92be12e --- /dev/null +++ b/src/features/buckets/hooks/useDeleteBucket.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { deleteBucket } from "../api/buckets.api" +import { statusKeys } from "../../status/api/status.keys" + +export function useDeleteBucket() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (name: string) => deleteBucket(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: statusKeys.all }) + }, + }) +} diff --git a/src/features/buckets/hooks/useImportBuckets.ts b/src/features/buckets/hooks/useImportBuckets.ts new file mode 100644 index 0000000..2f52516 --- /dev/null +++ b/src/features/buckets/hooks/useImportBuckets.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { importBuckets } from "../api/buckets.api" +import { statusKeys } from "../../status/api/status.keys" +import { bucketKeys } from "../api/buckets.keys" + +export function useImportBuckets() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (names: string[]) => importBuckets(names), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: statusKeys.all }) + queryClient.invalidateQueries({ queryKey: bucketKeys.all }) + }, + }) +} diff --git a/src/features/buckets/hooks/useImportCandidates.ts b/src/features/buckets/hooks/useImportCandidates.ts new file mode 100644 index 0000000..b726b92 --- /dev/null +++ b/src/features/buckets/hooks/useImportCandidates.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query" +import { fetchImportCandidates } from "../api/buckets.api" +import { bucketKeys } from "../api/buckets.keys" + +export function useImportCandidates(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: bucketKeys.importCandidates(), + queryFn: fetchImportCandidates, + enabled: options?.enabled ?? true, + staleTime: 10_000, + }) +} diff --git a/src/features/buckets/hooks/useUpdateBucket.ts b/src/features/buckets/hooks/useUpdateBucket.ts new file mode 100644 index 0000000..7967c2e --- /dev/null +++ b/src/features/buckets/hooks/useUpdateBucket.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { updateBucket } from "../api/buckets.api" +import type { UpdateBucketRequest } from "../api/buckets.types" +import { statusKeys } from "../../status/api/status.keys" + +export function useUpdateBucket() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ name, data }: { name: string; data: UpdateBucketRequest }) => + updateBucket(name, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: statusKeys.all }) + }, + }) +} diff --git a/src/features/status/api/status.mock.ts b/src/features/status/api/status.mock.ts index 684a5e3..18a321e 100644 --- a/src/features/status/api/status.mock.ts +++ b/src/features/status/api/status.mock.ts @@ -1,96 +1 @@ -import type { BucketStatsResponse, StatusResponse } from "./status.types" - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -export async function mockFetchStatus(): Promise { - await delay(400) - return { - gateway: { - status: "ok", - region: "auto", - multipartEnabled: true, - etagStyle: "md5", - docsEnabled: true, - buckets: ["documents", "media-storage", "backups", "public-assets"], - publicReadBuckets: ["public-assets"], - corsOrigins: ["http://localhost:5173"], - credentials: { - s3Keys: true, - googleOAuth: true, - dashboardPassword: true, - }, - }, - drive: { - connected: true, - account: { - email: "demo-storage@example.com", - displayName: "S3 Drive Bridge Admin", - }, - quota: { - limit: 15 * 1024 * 1024 * 1024, // 15 GB - usage: 6.4 * 1024 * 1024 * 1024, // 6.4 GB - usageInDrive: 6.2 * 1024 * 1024 * 1024, - usageInDriveTrash: 200 * 1024 * 1024, - free: 8.6 * 1024 * 1024 * 1024, - percentUsed: 42.7, - }, - error: null, - }, - checkedAt: new Date().toISOString(), - } -} - -export async function mockFetchBucketStats(): Promise { - await delay(600) - const buckets = [ - { - name: "documents", - objectCount: 142, - totalSize: 1024 * 1024 * 340, // 340 MB - lastModified: new Date(Date.now() - 1000 * 60 * 15).toISOString(), - truncated: false, - publicRead: false, - error: null, - }, - { - name: "media-storage", - objectCount: 890, - totalSize: 1024 * 1024 * 1024 * 5.2, // 5.2 GB - lastModified: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), - truncated: false, - publicRead: false, - error: null, - }, - { - name: "backups", - objectCount: 24, - totalSize: 1024 * 1024 * 850, // 850 MB - lastModified: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), - truncated: false, - publicRead: false, - error: null, - }, - { - name: "public-assets", - objectCount: 65, - totalSize: 1024 * 1024 * 45, // 45 MB - lastModified: new Date(Date.now() - 1000 * 60 * 5).toISOString(), - truncated: false, - publicRead: true, - error: null, - }, - ] - - const totalCount = buckets.reduce((acc, b) => acc + b.objectCount, 0) - const totalSize = buckets.reduce((acc, b) => acc + b.totalSize, 0) - - return { - buckets, - totals: { - buckets: buckets.length, - objectCount: totalCount, - totalSize, - }, - cachedAt: new Date().toISOString(), - } -} +export { mockFetchStatus, mockFetchBucketStats } from "../../buckets/api/buckets.mock" diff --git a/src/features/status/api/status.types.ts b/src/features/status/api/status.types.ts index 094755e..1768b30 100644 --- a/src/features/status/api/status.types.ts +++ b/src/features/status/api/status.types.ts @@ -6,6 +6,11 @@ export interface GatewayConfig { docsEnabled: boolean buckets: string[] publicReadBuckets: string[] + rootFolder: { + name: string | null + id: string | null + configured: boolean + } corsOrigins: string[] credentials: { s3Keys: boolean diff --git a/src/features/status/components/BucketStatsTable.tsx b/src/features/status/components/BucketStatsTable.tsx index b1bdc79..d42bd4e 100644 --- a/src/features/status/components/BucketStatsTable.tsx +++ b/src/features/status/components/BucketStatsTable.tsx @@ -1,22 +1,36 @@ -import { useState } from "react" +import { useState, useMemo } from "react" import { FolderTree, RotateCw, Sparkles, AlertCircle, + Plus, + DownloadCloud, + Trash2, + Files, + Search, Globe, Lock, - Files, + ArrowUpDown, + HardDrive, + CheckCircle2, + RefreshCw, } from "lucide-react" import { useQueryClient } from "@tanstack/react-query" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" import { formatBytes, formatRelativeTime } from "@/lib/format" import { fetchBucketStats } from "../api/status.api" import { statusKeys } from "../api/status.keys" import type { BucketStatsResponse } from "../api/status.types" +import { useUpdateBucket } from "../../buckets/hooks/useUpdateBucket" +import { CreateBucketDialog } from "../../buckets/components/CreateBucketDialog" +import { DeleteBucketDialog } from "../../buckets/components/DeleteBucketDialog" +import { ImportBucketsDialog } from "../../buckets/components/ImportBucketsDialog" interface BucketStatsTableProps { stats?: BucketStatsResponse @@ -24,9 +38,25 @@ interface BucketStatsTableProps { error?: Error | null } +type SortField = "name" | "objectCount" | "totalSize" | "lastModified" +type SortOrder = "asc" | "desc" +type FilterTab = "all" | "public" | "private" + export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTableProps) { const queryClient = useQueryClient() const [isRefreshing, setIsRefreshing] = useState(false) + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [isImportOpen, setIsImportOpen] = useState(false) + const [deletingBucket, setDeletingBucket] = useState(null) + const [updatingBucket, setUpdatingBucket] = useState(null) + + // Search, filter and sorting states + const [searchTerm, setSearchTerm] = useState("") + const [filterTab, setFilterTab] = useState("all") + const [sortField, setSortField] = useState("name") + const [sortOrder, setSortOrder] = useState("asc") + + const { mutate: updateBucketMutate } = useUpdateBucket() const handleForceRefresh = async () => { try { @@ -38,136 +68,579 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr } } + const handleTogglePublicRead = (bucketName: string, currentPublic: boolean) => { + setUpdatingBucket(bucketName) + updateBucketMutate( + { + name: bucketName, + data: { publicRead: !currentPublic }, + }, + { + onSettled: () => { + setUpdatingBucket(null) + }, + }, + ) + } + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc") + } else { + setSortField(field) + setSortOrder("asc") + } + } + + const buckets = stats?.buckets || [] + + // Count summaries + const publicCount = useMemo(() => buckets.filter((b) => b.publicRead).length, [buckets]) + const privateCount = useMemo(() => buckets.filter((b) => !b.publicRead).length, [buckets]) + + // Filtered and sorted bucket list + const filteredBuckets = useMemo(() => { + return buckets + .filter((bucket) => { + // Tab filter + if (filterTab === "public" && !bucket.publicRead) return false + if (filterTab === "private" && bucket.publicRead) return false + + // Search term filter + if (searchTerm.trim()) { + const term = searchTerm.toLowerCase().trim() + return bucket.name.toLowerCase().includes(term) + } + return true + }) + .sort((a, b) => { + let comp = 0 + if (sortField === "name") { + comp = a.name.localeCompare(b.name) + } else if (sortField === "objectCount") { + comp = (a.objectCount || 0) - (b.objectCount || 0) + } else if (sortField === "totalSize") { + comp = (a.totalSize || 0) - (b.totalSize || 0) + } else if (sortField === "lastModified") { + const timeA = a.lastModified ? new Date(a.lastModified).getTime() : 0 + const timeB = b.lastModified ? new Date(b.lastModified).getTime() : 0 + comp = timeA - timeB + } + return sortOrder === "asc" ? comp : -comp + }) + }, [buckets, filterTab, searchTerm, sortField, sortOrder]) + return ( - -
- -
-
-
- + <> + + {/* Top Header Section */} +
+
+ {/* Title & Stats Badges */} +
+
+
+ +
+
+
+ + Bucket Explorer + + {stats && ( + + {stats.totals.buckets} {stats.totals.buckets === 1 ? "Bucket" : "Buckets"} + + )} +
+ + Explore and manage S3 storage buckets mapped directly to Google Drive folders + +
- Bucket Explorer
-
- {stats && ( - - {stats.totals.buckets} {stats.totals.buckets === 1 ? "bucket" : "buckets"} · {stats.totals.objectCount} objects · {formatBytes(stats.totals.totalSize)} - - )} + + {/* Main Action Buttons */} +
+ + + +
- - Allowlisted S3 buckets mapped to Google Drive folders with size and object counts. - - - + {/* Quick Metrics Bar (when stats loaded) */} + {stats && stats.buckets.length > 0 && ( +
+
+
+ +
+
+
Total Storage
+
{formatBytes(stats.totals.totalSize)}
+
+
+ +
+
+ +
+
+
Total Objects
+
{stats.totals.objectCount.toLocaleString()}
+
+
+ +
+
+ +
+
+
Public Buckets
+
{publicCount}
+
+
+ +
+
+ +
+
+
Private Buckets
+
{privateCount}
+
+
+
+ )} +
+ + {/* Content Body */} + {isLoading ? ( -
- - - +
+
+ + +
+ + +
) : error || !stats ? ( -
- -
Failed to load bucket stats
-

{error?.message || "Unknown error occurred"}

+
+
+ +
+
+
Failed to Load Bucket Stats
+

{error?.message || "Unknown error occurred"}

+
+
) : stats.buckets.length === 0 ? ( -
- -
No buckets configured
-

- Set ALLOWED_BUCKETS in your Worker configuration to expose buckets. -

+
+
+ +
+
+
No Buckets Configured Yet
+

+ Create a new storage bucket or discover and import existing Google Drive folders from your configured storage root. +

+
+
+ + +
) : ( -
- - - - - - - - - - - - {stats.buckets.map((b) => ( - - - - - - - - ))} - -
Bucket NameAccessObjectsTotal SizeLast Modified
- {b.name} - - {b.publicRead ? ( - - - Public Read - - ) : ( - - - Private - - )} - - {b.error ? ( - Error - ) : ( - {b.truncated ? `≥ ${b.objectCount}` : b.objectCount} - )} - - {b.error ? "—" : formatBytes(b.totalSize)} - - {b.error ? ( - - Failed to scan - - ) : ( - formatRelativeTime(b.lastModified) - )} -
+
+ {/* Search, Filter Tabs & Sort Controls */} +
+ {/* Search Bar */} +
+ + setSearchTerm(e.target.value)} + className="pl-9 h-8 text-xs bg-background/70 border-border/70 rounded-lg focus-visible:ring-1" + /> + {searchTerm && ( + + )} +
+ + {/* Filter Tabs */} +
+ + + +
+
+ + {/* No match state */} + {filteredBuckets.length === 0 ? ( +
+ +
No buckets matched your search
+

+ Try changing your search term or clearing the active filter. +

+ +
+ ) : ( + <> + {/* Desktop Table View (>= md screens) */} +
+ + + + + + + + + + + + + {filteredBuckets.map((b) => { + const isUpdating = updatingBucket === b.name + return ( + + {/* Name column */} + + + {/* Access policy */} + + + {/* Object count */} + + + {/* Size */} + + + {/* Last modified */} + + + {/* Actions */} + + + ) + })} + +
+ + Access Policy + + + + + + Actions
+
+
+ +
+
+ {b.name} + + s3://{b.name} + +
+
+
+
+ handleTogglePublicRead(b.name, b.publicRead)} + className="data-[state=checked]:bg-emerald-500" + /> +
+ {b.publicRead ? ( + + + Public Read + + ) : ( + + + Private + + )} + {isUpdating && } +
+
+
+ {b.error ? ( + Error + ) : ( +
+ {b.truncated ? `≥ ${b.objectCount.toLocaleString()}` : b.objectCount.toLocaleString()} +
+ )} +
+ {b.error ? ( + + ) : ( + + {formatBytes(b.totalSize)} + + )} + + {b.error ? ( + + Scan failed + + ) : ( + + {formatRelativeTime(b.lastModified)} + + )} + + +
+
+ + {/* Mobile & Tablet Card List View (< md screens) */} +
+ {filteredBuckets.map((b) => { + const isUpdating = updatingBucket === b.name + return ( +
+ {/* Header of bucket card */} +
+
+
+ +
+
+
{b.name}
+
s3://{b.name}
+
+
+ + +
+ + {/* Stats Grid inside card */} +
+
+ Size + + {b.error ? "—" : formatBytes(b.totalSize)} + +
+
+ Objects + + {b.error ? "Error" : b.truncated ? `≥ ${b.objectCount}` : b.objectCount} + +
+
+ + {/* Access Policy & Last Updated */} +
+
+ handleTogglePublicRead(b.name, b.publicRead)} + className="data-[state=checked]:bg-emerald-500 scale-90" + /> + + {b.publicRead ? "Public Read" : "Private"} + +
+ + + {b.error ? "Scan error" : formatRelativeTime(b.lastModified)} + +
+
+ ) + })} +
+ + )}
)} - {/* S3 actions info bar */} -
- Supported APIs: - {["s3:ListObjectsV2", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:CreateMultipartUpload"].map( - (api) => ( + {/* S3 capabilities bottom bar */} +
+
+ S3 API Support: + {[ + { name: "s3:ListObjectsV2", desc: "List bucket objects" }, + { name: "s3:GetObject", desc: "Download objects" }, + { name: "s3:PutObject", desc: "Upload files" }, + { name: "s3:DeleteObject", desc: "Delete files" }, + { name: "s3:CreateMultipartUpload", desc: "Multipart uploads" }, + ].map((api) => ( - {api} + {api.name} - ) - )} + ))} +
+
+ + Direct Google Drive mapping active +
-
- + + + setIsCreateOpen(false)} /> + setDeletingBucket(null)} + bucketName={deletingBucket} + /> + setIsImportOpen(false)} /> + ) } diff --git a/src/features/status/components/GatewayConfigCard.tsx b/src/features/status/components/GatewayConfigCard.tsx index d4439ea..fc17df1 100644 --- a/src/features/status/components/GatewayConfigCard.tsx +++ b/src/features/status/components/GatewayConfigCard.tsx @@ -1,4 +1,4 @@ -import { ShieldCheck, ArrowUpRight, CheckCircle2, XCircle } from "lucide-react" +import { ShieldCheck, ArrowUpRight, CheckCircle2, XCircle, FolderTree } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" @@ -31,6 +31,8 @@ export function GatewayConfigCard({ gateway, isLoading }: GatewayConfigCardProps const hasS3Keys = creds?.s3Keys ?? false const hasOAuth = creds?.googleOAuth ?? false const hasPassword = creds?.dashboardPassword ?? false + const rootFolder = gateway?.rootFolder + const isRootConfigured = rootFolder?.configured ?? false return ( @@ -40,13 +42,38 @@ export function GatewayConfigCard({ gateway, isLoading }: GatewayConfigCardProps
- Security & Protocol + Security & Storage Root
- Authentication and credential verification + Authentication, credentials, and storage root folder + {/* Storage Root Folder */} +
+
+ + + Drive Root Folder + + + {isRootConfigured ? (rootFolder?.name ?? "Configured") : "Not Configured"} + +
+

+ {isRootConfigured ? ( + + ) : ( + + )} + + {isRootConfigured + ? `All buckets anchored under '/${rootFolder?.name}'` + : "DRIVE_ROOT_FOLDER missing in Worker configuration"} + +

+
+ {/* AWS SigV4 */}
diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index 9003637..0530060 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -1,4 +1,4 @@ -import { QueryClient, QueryCache } from "@tanstack/react-query" +import { QueryClient, QueryCache, MutationCache } from "@tanstack/react-query" import { ApiError } from "./api-client" import { clearAuthToken } from "./auth-storage" @@ -8,17 +8,24 @@ import { clearAuthToken } from "./auth-storage" export function createQueryClient(): QueryClient { let client: QueryClient + const handle401 = (error: unknown) => { + if (error instanceof ApiError && error.status === 401) { + clearAuthToken() + client?.clear() + } + } + const queryCache = new QueryCache({ - onError: (error) => { - if (error instanceof ApiError && error.status === 401) { - clearAuthToken() - client?.clear() - } - }, + onError: handle401, + }) + + const mutationCache = new MutationCache({ + onError: handle401, }) client = new QueryClient({ queryCache, + mutationCache, defaultOptions: { queries: { staleTime: 60_000, diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 9618334..ae390b8 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -17,7 +17,6 @@ import { GatewayStatusCard } from "@/features/status/components/GatewayStatusCar import { DriveQuotaCard } from "@/features/status/components/DriveQuotaCard" import { MultipartStatusCard } from "@/features/status/components/MultipartStatusCard" import { BucketStatsTable } from "@/features/status/components/BucketStatsTable" -import { GatewayConfigCard } from "@/features/status/components/GatewayConfigCard" import { Button } from "@/components/ui/button" import { formatRelativeTime } from "@/lib/format" @@ -191,16 +190,12 @@ export default function Dashboard() {
{/* Feature Capabilities & Specifications */} -
+
-