diff --git a/.gitignore b/.gitignore index 3961f0a..2534c22 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,7 @@ garage.toml backend/docs/ config.yaml -docs/**/*.md \ No newline at end of file +docs/**/*.md + +# Superpowers brainstorm / session scratch +.superpowers/ \ No newline at end of file diff --git a/frontend/public/fonts/LICENSE.txt b/frontend/public/fonts/LICENSE.txt new file mode 100644 index 0000000..61f6f4b --- /dev/null +++ b/frontend/public/fonts/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/frontend/public/fonts/geist-mono-400.woff2 b/frontend/public/fonts/geist-mono-400.woff2 new file mode 100644 index 0000000..25b7962 Binary files /dev/null and b/frontend/public/fonts/geist-mono-400.woff2 differ diff --git a/frontend/public/fonts/geist-mono-500.woff2 b/frontend/public/fonts/geist-mono-500.woff2 new file mode 100644 index 0000000..dac0590 Binary files /dev/null and b/frontend/public/fonts/geist-mono-500.woff2 differ diff --git a/frontend/public/fonts/geist-sans-400.woff2 b/frontend/public/fonts/geist-sans-400.woff2 new file mode 100644 index 0000000..2d46108 Binary files /dev/null and b/frontend/public/fonts/geist-sans-400.woff2 differ diff --git a/frontend/public/fonts/geist-sans-500.woff2 b/frontend/public/fonts/geist-sans-500.woff2 new file mode 100644 index 0000000..3d86535 Binary files /dev/null and b/frontend/public/fonts/geist-sans-500.woff2 differ diff --git a/frontend/public/fonts/geist-sans-600.woff2 b/frontend/public/fonts/geist-sans-600.woff2 new file mode 100644 index 0000000..cdf2c7d Binary files /dev/null and b/frontend/public/fonts/geist-sans-600.woff2 differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aeb7d5b..41b93ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,19 @@ import { useEffect } from 'react'; -import {BrowserRouter, Route, Routes} from 'react-router-dom'; +import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom'; import {QueryClientProvider} from '@tanstack/react-query'; import {ThemeProvider, useTheme} from '@/components/theme-provider'; import {Layout} from '@/components/layout/layout'; +import {BucketDetailShell} from '@/components/layout/bucket-detail-shell'; import {Dashboard} from '@/pages/Dashboard'; import {Buckets} from '@/pages/Buckets'; +import {BucketObjects} from '@/pages/BucketObjects'; +import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView'; +import {BucketPermissions} from '@/pages/BucketPermissions'; +import {BucketWebsite} from '@/pages/BucketWebsite'; +import {BucketSettings} from '@/pages/BucketSettings'; import {Cluster} from '@/pages/Cluster'; import {AccessControl} from '@/pages/AccessControl'; import {Login} from '@/pages/Login'; -import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView'; import {Toaster} from 'sonner'; import {queryClient} from '@/lib/query-client'; import {useAuthStore} from '@/store/auth-store'; @@ -17,8 +22,21 @@ import {LoadingSpinner} from '@/components/auth/LoadingSpinner'; function ThemedToaster() { const { theme } = useTheme(); - - return ; + return ( + + ); } function App() { @@ -49,7 +67,14 @@ function App() { > } /> } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/components/auth/BasicLoginForm.tsx b/frontend/src/components/auth/BasicLoginForm.tsx index 4d337f2..7cb2d8a 100644 --- a/frontend/src/components/auth/BasicLoginForm.tsx +++ b/frontend/src/components/auth/BasicLoginForm.tsx @@ -99,7 +99,7 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps +
+ + onSearchChange(e.target.value)} + className="pl-8" + />
{/* Buckets Table */} @@ -99,14 +91,14 @@ export function BucketListView({ {bucket.name} {bucket.websiteAccess && ( - + Website )} - {bucket.region || 'default'} + {bucket.region || 'default'} {bucket.objectCount?.toLocaleString() || 0} {bucket.size ? formatBytes(bucket.size) : '0 B'} diff --git a/frontend/src/components/buckets/BucketSettingsDialog.tsx b/frontend/src/components/buckets/BucketSettingsDialog.tsx deleted file mode 100644 index 5426551..0000000 --- a/frontend/src/components/buckets/BucketSettingsDialog.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Select, SelectOption } from '@/components/ui/select'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { useAccessKeys } from '@/hooks/useApi'; -import type { Bucket } from '@/types'; -import { toast } from 'sonner'; - -interface BucketSettingsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - bucket: Bucket | null; - onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise; -} - -export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) { - const { data: availableKeys = [] } = useAccessKeys(); - const [selectedAccessKey, setSelectedAccessKey] = useState(''); - const [permissionRead, setPermissionRead] = useState(false); - const [permissionWrite, setPermissionWrite] = useState(false); - const [permissionOwner, setPermissionOwner] = useState(false); - - useEffect(() => { - if (open && bucket) { - resetForm(); - } - }, [open, bucket]); - - const resetForm = () => { - setSelectedAccessKey(''); - setPermissionRead(false); - setPermissionWrite(false); - setPermissionOwner(false); - }; - - const handleAccessKeyChange = (accessKeyId: string) => { - setSelectedAccessKey(accessKeyId); - - if (!accessKeyId) { - setPermissionRead(false); - setPermissionWrite(false); - setPermissionOwner(false); - return; - } - - const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId); - if (selectedKey && bucket) { - const bucketPermission = selectedKey.permissions.find( - perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name - ); - - if (bucketPermission) { - setPermissionRead(bucketPermission.read); - setPermissionWrite(bucketPermission.write); - setPermissionOwner(bucketPermission.owner); - } else { - setPermissionRead(false); - setPermissionWrite(false); - setPermissionOwner(false); - } - } - }; - - const handleGrantPermission = async () => { - if (!bucket || !selectedAccessKey) { - toast.error('Please select an access key'); - return; - } - - if (!permissionRead && !permissionWrite && !permissionOwner) { - toast.error('Please select at least one permission'); - return; - } - - const success = await onGrantPermission(bucket.name, selectedAccessKey, { - read: permissionRead, - write: permissionWrite, - owner: permissionOwner, - }); - - if (success) { - resetForm(); - onOpenChange(false); - } - }; - - return ( - - - - Bucket Settings - {bucket?.name} - - Grant access key permissions for this bucket - - -
-
- - -

- Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected. -

-
- -
- -
-
- setPermissionRead(checked as boolean)} - /> -
- -

- Allows reading objects from the bucket (GetObject, HeadObject, ListObjects) -

-
-
- -
- setPermissionWrite(checked as boolean)} - /> -
- -

- Allows writing and deleting objects in the bucket (PutObject, DeleteObject) -

-
-
- -
- setPermissionOwner(checked as boolean)} - /> -
- -

- Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy) -

-
-
-
-
-
- - - - -
-
- ); -} diff --git a/frontend/src/components/buckets/BucketWebsiteDialog.tsx b/frontend/src/components/buckets/BucketWebsiteDialog.tsx deleted file mode 100644 index 0a116a3..0000000 --- a/frontend/src/components/buckets/BucketWebsiteDialog.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Badge } from '@/components/ui/badge'; -import { Switch } from '@/components/ui/switch'; -import type { Bucket } from '@/types'; - -interface BucketWebsiteDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - bucket: Bucket | null; - onSave: ( - bucketName: string, - payload: { enabled: boolean; indexDocument?: string; errorDocument?: string } - ) => Promise; -} - -export function BucketWebsiteDialog({ - open, - onOpenChange, - bucket, - onSave, -}: BucketWebsiteDialogProps) { - const [enabled, setEnabled] = useState(false); - const [indexDocument, setIndexDocument] = useState('index.html'); - const [errorDocument, setErrorDocument] = useState(''); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (open && bucket) { - setEnabled(bucket.websiteAccess); - setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html'); - setErrorDocument(bucket.websiteConfig?.errorDocument ?? ''); - } - }, [open, bucket]); - - const handleSave = async () => { - if (!bucket) return; - setSaving(true); - const success = await onSave(bucket.name, { - enabled, - indexDocument: enabled ? indexDocument : undefined, - errorDocument: enabled && errorDocument ? errorDocument : undefined, - }); - setSaving(false); - if (success) onOpenChange(false); - }; - - return ( - - - - Website Hosting — {bucket?.name} - - Configure this bucket to serve a static website. - - - -
-
-
-

Website access

-

- Allow public HTTP access to bucket objects -

-
-
- - {enabled ? 'Enabled' : 'Disabled'} - - -
-
- - {enabled && ( -
-
- - setIndexDocument(e.target.value)} - placeholder="index.html" - /> -

- The file served when a directory is requested (e.g. index.html) -

-
- -
- - setErrorDocument(e.target.value)} - placeholder="404.html (optional)" - /> -

- The file served when an object is not found (optional) -

-
-
- )} -
- - - - - -
-
- ); -} diff --git a/frontend/src/components/buckets/CreateBucketDialog.tsx b/frontend/src/components/buckets/CreateBucketDialog.tsx index 6084cb3..d70dbdc 100644 --- a/frontend/src/components/buckets/CreateBucketDialog.tsx +++ b/frontend/src/components/buckets/CreateBucketDialog.tsx @@ -1,14 +1,17 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Database } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, + DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { IconTile } from '@/components/ui/icon-tile'; import { toast } from 'sonner'; interface CreateBucketDialogProps { @@ -20,6 +23,8 @@ interface CreateBucketDialogProps { export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) { const [bucketName, setBucketName] = useState(''); + useEffect(() => { if (!open) setBucketName(''); }, [open]); + const handleCreate = async () => { if (!bucketName) { toast.error('Please enter a bucket name'); @@ -37,15 +42,19 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat - Create New Bucket - - Create a new storage bucket for your objects - + } tone="primary" size="md" /> +
+ Create New Bucket + + Create a new storage bucket for your objects + +
-
+
setBucketName(e.target.value)} @@ -59,13 +68,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat Must be unique and follow DNS naming conventions

-
- - - - -
-
- ); -} diff --git a/frontend/src/components/buckets/DeleteObjectDialog.tsx b/frontend/src/components/buckets/DeleteObjectDialog.tsx index 7fa4179..ca25f4d 100644 --- a/frontend/src/components/buckets/DeleteObjectDialog.tsx +++ b/frontend/src/components/buckets/DeleteObjectDialog.tsx @@ -1,3 +1,4 @@ +import { Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -7,6 +8,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { IconTile } from '@/components/ui/icon-tile'; import type { S3Object } from '@/types'; interface DeleteObjectDialogProps { @@ -27,16 +29,19 @@ export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject }; return ( - + - Delete Object - - Are you sure you want to delete "{object?.key}"? This action cannot be undone. - + } tone="destructive" size="md" /> +
+ Delete Object + + Are you sure you want to delete "{object?.key}"? This action cannot be undone. + +
- - diff --git a/frontend/src/components/buckets/ObjectDetailsView.tsx b/frontend/src/components/buckets/ObjectDetailsView.tsx index c71a248..cb73f98 100644 --- a/frontend/src/components/buckets/ObjectDetailsView.tsx +++ b/frontend/src/components/buckets/ObjectDetailsView.tsx @@ -1,21 +1,55 @@ import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, Link } from 'react-router-dom'; import { objectsApi } from '@/lib/api'; import type { ObjectMetadata } from '@/types'; -import { Header } from '@/components/layout/header'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { IconTile } from '@/components/ui/icon-tile'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { formatBytes } from '@/lib/file-utils'; +function formatDate(value: string) { + return new Date(value).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function CardSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+ {children} +
+ ); +} + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + export function ObjectDetailsView() { const navigate = useNavigate(); const { bucketName, '*': encodedObjectKey } = useParams(); - // Decode the object key from the URL const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined; + const [metadata, setMetadata] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); useEffect(() => { if (!bucketName || !objectKey) { @@ -23,7 +57,6 @@ export function ObjectDetailsView() { setIsLoading(false); return; } - const fetchMetadata = async () => { try { setIsLoading(true); @@ -32,240 +65,184 @@ export function ObjectDetailsView() { setMetadata(data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load object metadata'); - console.error('Failed to fetch object metadata:', err); } finally { setIsLoading(false); } }; - fetchMetadata(); }, [bucketName, objectKey]); + const parentPath = objectKey?.split('/').slice(0, -1).join('/') ?? ''; + const fileName = objectKey?.split('/').pop() || objectKey || ''; + const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`; + const pathSegments = parentPath ? parentPath.split('/').filter(Boolean) : []; + + const copy = (text: string, label = 'Copied') => { + navigator.clipboard.writeText(text); + toast.success(label); + }; + const handleDownload = async () => { if (!bucketName || !objectKey) return; - try { const blob = await objectsApi.get(bucketName, objectKey); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = objectKey.split('/').pop() || 'download'; + a.download = fileName || 'download'; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); toast.success('Download started'); - } catch (err) { - console.error('Download failed:', err); + } catch { + toast.error('Download failed'); } }; const handleDelete = async () => { if (!bucketName || !objectKey) return; - - if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) { - return; - } - try { + setDeleting(true); await objectsApi.delete(bucketName, objectKey); - toast.success('Object deleted successfully'); - handleBackNavigation(); - } catch (err) { - console.error('Delete failed:', err); + toast.success('Object deleted'); + navigate(backHref); + } catch { + toast.error('Delete failed'); + } finally { + setDeleting(false); + setDeleteOpen(false); } }; - const handleBackNavigation = () => { - if (!bucketName) return; - - // Navigate back to the bucket explorer with the appropriate prefix - // Extract the folder path from the object key (everything before the last /) - const folderPath = objectKey?.split('/').slice(0, -1).join('/') || ''; - const prefix = folderPath ? `${folderPath}/` : ''; - - // Navigate to the bucket view with the correct prefix - navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`); - }; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast.success('Copied to clipboard'); - }; - - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short', - }); - }; - if (isLoading) { return ( -
-
-
-
-
Loading object details...
-
-
+
+ Loading object details…
); } if (error || !metadata) { return ( -
-
-
- -
-
{error || 'Object not found'}
-
+
+ +
+ {error || 'Object not found'}
); } - const fileName = objectKey?.split('/').pop() || objectKey || ''; - const pathParts = objectKey?.split('/').filter(part => part) || []; - const parentPath = pathParts.slice(0, -1).join('/'); - return ( -
-
-
- {/* Back Button and Actions */} -
- -
- - -
-
- - {/* File Name Header */} -
-
- -
-
-
-

- {parentPath && ( - /{parentPath}/ - )} - {fileName} -

- -
-
-
- - {/* Object Details Section */} -
-
-

Object Details

-
-
-
-
Date Created
-
- {formatDate(metadata.lastModified)} -
-
-
-
Type
-
- {metadata.contentType || 'application/octet-stream'} -
-
-
-
Storage Class
-
- {metadata.storageClass || 'Standard'} -
-
-
-
Size
-
- {formatBytes(metadata.size)} -
-
-
-
- - {/* Custom Metadata Section */} - {metadata.metadata && Object.keys(metadata.metadata).length > 0 && ( -
-
-

Custom Metadata

-
-
- - - - - - - - - {Object.entries(metadata.metadata).map(([key, value]) => ( - - - - - ))} - -
- Key - - Value -
- {key} - {value}
-
-
- )} - - {/* Object Preview Section */} -
-
-

Object Preview

-
-
-

No preview available

-
-
+
+ {/* Back + breadcrumb */} +
+ + + Objects + + {pathSegments.map((seg, i) => ( + + + {seg} + + ))} + + {fileName}
+ + {/* Hero */} +
+
+ } tone="primary" size="lg" /> +
+

{fileName}

+ +
+ {formatBytes(metadata.size)} + {metadata.contentType || 'application/octet-stream'} + {metadata.storageClass && {metadata.storageClass}} +
+
+
+
+ + +
+
+ + {/* Details */} + +
+ {formatBytes(metadata.size)} + {metadata.contentType || 'application/octet-stream'} + {metadata.storageClass || 'Standard'} + {formatDate(metadata.lastModified)} + + + + {metadata.versionId && ( + + {metadata.versionId} + + )} +
+
+ + {/* Custom metadata */} + {metadata.metadata && Object.keys(metadata.metadata).length > 0 && ( + +
+ {Object.entries(metadata.metadata).map(([key, value]) => ( + + {value} + + ))} +
+
+ )} + + {/* Preview */} + +
+ No preview available for this object. +
+
+ +
); } diff --git a/frontend/src/components/buckets/ObjectsTable.tsx b/frontend/src/components/buckets/ObjectsTable.tsx index 0e1c9c3..4c5dea8 100644 --- a/frontend/src/components/buckets/ObjectsTable.tsx +++ b/frontend/src/components/buckets/ObjectsTable.tsx @@ -282,7 +282,7 @@ export function ObjectsTable({ {obj.storageClass && ( - {obj.storageClass} + {obj.storageClass} )} {obj.isFolder ? null : formatBytes(obj.size)} @@ -400,7 +400,7 @@ export function ObjectsTable({
+ +
+
+ + + {/* Tabs */} + + +
+ +
+
+ ); +} diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index c5122d0..6904016 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -1,36 +1,67 @@ -import { Outlet } from 'react-router-dom'; +import { Outlet, useLocation, useParams } from 'react-router-dom'; import { Sidebar } from './sidebar'; -import { useState } from 'react'; +import { TopBar } from './top-bar'; +import { useState, useMemo } from 'react'; import { Menu } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import type { BreadcrumbItem } from '@/components/ui/breadcrumb'; + +function useCrumbs(): BreadcrumbItem[] { + const location = useLocation(); + const params = useParams(); + return useMemo(() => { + const path = location.pathname; + if (path === '/') return [{ label: 'Dashboard' }]; + if (path === '/cluster') return [{ label: 'Cluster' }]; + if (path === '/access') return [{ label: 'Access Control' }]; + if (path === '/buckets') return [{ label: 'Buckets' }]; + if (path.startsWith('/buckets/')) { + const bucketName = (params as { bucketName?: string }).bucketName ?? path.split('/')[2]; + const crumbs: BreadcrumbItem[] = [ + { label: 'Buckets', to: '/buckets' }, + { label: bucketName, to: `/buckets/${bucketName}/objects` }, + ]; + const segs = path.split('/').slice(3); // after /buckets/:name + if (segs[0] && segs[0] !== 'objects') { + const tabLabel = segs[0][0].toUpperCase() + segs[0].slice(1); + crumbs.push({ label: tabLabel }); + } + return crumbs; + } + return []; + }, [location.pathname, params]); +} export function Layout() { const [sidebarOpen, setSidebarOpen] = useState(false); + const crumbs = useCrumbs(); return ( -
- {/* Mobile menu button */} +
- {/* Mobile overlay */} {sidebarOpen && (
setSidebarOpen(false)} /> )} setSidebarOpen(false)} /> -
- -
+
+ +
+ +
+
); } diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index c7b846f..30e1f7f 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -1,8 +1,7 @@ -import {Link, useLocation} from 'react-router-dom'; -import {cn} from '@/lib/utils'; -import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react'; -import {useAuthStore} from '@/store/auth-store'; -import {Button} from '@/components/ui/button'; +import { Link, useLocation } from 'react-router-dom'; +import { cn } from '@/lib/utils'; +import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react'; +import { useAuthStore } from '@/store/auth-store'; import { useQuery } from '@tanstack/react-query'; import { healthApi, garageApi } from '@/lib/api'; @@ -12,26 +11,25 @@ interface NavItem { icon: React.ComponentType<{ className?: string }>; } -const navItems: NavItem[] = [ +interface NavGroup { + label?: string; + items: NavItem[]; +} + +const navGroups: NavGroup[] = [ { - title: 'Dashboard', - href: '/', - icon: LayoutDashboard, + items: [{ title: 'Dashboard', href: '/', icon: LayoutDashboard }], }, { - title: 'Buckets', - href: '/buckets', - icon: Database, + label: 'Storage', + items: [{ title: 'Buckets', href: '/buckets', icon: Database }], }, { - title: 'Cluster', - href: '/cluster', - icon: Server, - }, - { - title: 'Access Control', - href: '/access', - icon: Key, + label: 'Cluster', + items: [ + { title: 'Cluster', href: '/cluster', icon: Server }, + { title: 'Access Control', href: '/access', icon: Key }, + ], }, ]; @@ -42,11 +40,7 @@ interface SidebarProps { export function Sidebar({ isOpen, onClose }: SidebarProps) { const location = useLocation(); - const { user, config, logout } = useAuthStore(); - - const handleLogout = () => { - logout(); - }; + const { config } = useAuthStore(); const { data: uiVersion } = useQuery({ queryKey: ['ui-version'], @@ -60,80 +54,80 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { queryFn: () => garageApi.getNodeInfo('self'), staleTime: 5 * 60 * 1000, retry: false, + enabled: !!(config && (config.admin.enabled || config.oidc.enabled)), }); - const garageVersion = nodeInfo - ? Object.values(nodeInfo.success)[0]?.garageVersion - : undefined; + const garageVersion = nodeInfo ? Object.values(nodeInfo.success)[0]?.garageVersion : undefined; + + const isActive = (href: string) => + href === '/' + ? location.pathname === '/' + : location.pathname === href || location.pathname.startsWith(href + '/'); return ( -
-
- Garage UI Logo - Garage UI +
+ + Garage UI
- - {config && (config.admin.enabled || config.oidc.enabled) && user && ( -
-
-
- -
-
-

{user.name || user.username}

- {user.email && ( -

{user.email}

- )} -
+
- )} - {(uiVersion || garageVersion) && ( -
- {uiVersion && `UI ${uiVersion}`} - {uiVersion && garageVersion && ' | '} - {garageVersion && `Garage ${garageVersion}`} -
- )} -
+ ))} + +
+ + + Documentation + + {(uiVersion || garageVersion) && ( +
+ {uiVersion && UI {uiVersion}} + {uiVersion && garageVersion && } + {garageVersion && Garage {garageVersion}} +
+ )} +
+ ); } diff --git a/frontend/src/components/layout/theme-toggle.tsx b/frontend/src/components/layout/theme-toggle.tsx index 817c760..9007720 100644 --- a/frontend/src/components/layout/theme-toggle.tsx +++ b/frontend/src/components/layout/theme-toggle.tsx @@ -14,7 +14,7 @@ export function ThemeToggle() { return ( - + {menuOpen && ( +
+
+
{user?.name || user?.username}
+ {user?.email && ( +
{user.email}
+ )} +
+ +
+ )} +
+ )} +
+
+ ); +} + +function ThemeMiniToggle({ + theme, + setTheme, +}: { + theme: 'light' | 'dark' | 'system'; + setTheme: (t: 'light' | 'dark' | 'system') => void; +}) { + const next = theme === 'dark' ? 'light' : theme === 'light' ? 'system' : 'dark'; + const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor; + return ( + + ); +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 50624aa..a998727 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -3,21 +3,23 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-tight', { variants: { variant: { - default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary', - secondary: - 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary', - destructive: - 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive', - outline: 'text-foreground', + neutral: + 'bg-[var(--card)] border-[var(--border)] text-[var(--muted-foreground)]', + success: + 'bg-[var(--success-soft)] border-transparent text-[color:#2ca02c] dark:text-[color:#73bf69]', + warning: + 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]', + danger: + 'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]', + primary: + 'bg-[var(--primary)] border-transparent text-[var(--primary-foreground)]', }, }, - defaultVariants: { - variant: 'default', - }, + defaultVariants: { variant: 'neutral' }, } ); @@ -25,8 +27,8 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, ...props }: BadgeProps) { +export function Badge({ className, variant, ...props }: BadgeProps) { return
; } -export { Badge, badgeVariants }; +export { badgeVariants }; diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..3a7597c --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface BreadcrumbItem { + label: string; + to?: string; +} + +interface BreadcrumbProps extends React.HTMLAttributes { + items: BreadcrumbItem[]; +} + +export function Breadcrumb({ items, className, ...props }: BreadcrumbProps) { + return ( + + ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 62d6a63..1814792 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -3,28 +3,39 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none', + [ + 'inline-flex items-center justify-center gap-2 whitespace-nowrap', + 'rounded-md text-[14px] font-medium tracking-tight', + 'transition-colors ring-offset-background', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2', + 'disabled:pointer-events-none disabled:opacity-50', + '[&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:shrink-0', + ].join(' '), { variants: { variant: { - default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer', - default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed', - secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer', - outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed', - ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer', - link: 'text-primary underline-offset-4 hover:underline cursor-pointer', + primary: + 'bg-[var(--primary)] text-[var(--primary-foreground)] font-semibold hover:brightness-[1.04] cursor-pointer', + secondary: + 'bg-transparent border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--accent)] cursor-pointer', + ghost: + 'bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)] cursor-pointer', + destructive: + 'bg-[var(--destructive)] text-[var(--destructive-foreground)] font-semibold hover:brightness-[1.05] cursor-pointer', + link: + 'text-[var(--primary)] underline-offset-4 hover:underline cursor-pointer', }, size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10', + sm: 'h-8 px-3', + default: 'h-[38px] px-4', + lg: 'h-11 px-5', + 'icon-sm': 'h-8 w-8 p-0', + icon: 'h-[38px] w-[38px] p-0', + 'icon-lg': 'h-11 w-11 p-0', }, }, defaultVariants: { - variant: 'default', + variant: 'primary', size: 'default', }, } @@ -35,11 +46,9 @@ export interface ButtonProps VariantProps {} const Button = React.forwardRef( - ({ className, variant, size, ...props }, ref) => { - return ( - + + + +
+ ); +} diff --git a/frontend/src/components/ui/dangerous-confirm-dialog.tsx b/frontend/src/components/ui/dangerous-confirm-dialog.tsx new file mode 100644 index 0000000..6b35366 --- /dev/null +++ b/frontend/src/components/ui/dangerous-confirm-dialog.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { Trash2 } from 'lucide-react'; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './dialog'; +import { IconTile } from './icon-tile'; +import { Button } from './button'; +import { Input } from './input'; + +interface DangerousConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: React.ReactNode; + /** Exact string the user must type to enable the confirm button. */ + confirmationText: string; + confirmLabel?: string; + cancelLabel?: string; + icon?: React.ReactNode; + loading?: boolean; + onConfirm: () => void | Promise; +} + +export function DangerousConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmationText, + confirmLabel = 'Delete', + cancelLabel = 'Cancel', + icon, + loading = false, + onConfirm, +}: DangerousConfirmDialogProps) { + const [value, setValue] = React.useState(''); + React.useEffect(() => { if (!open) setValue(''); }, [open]); + + const matches = value === confirmationText; + + const submit = () => { if (matches && !loading) onConfirm(); }; + + return ( + + + + } tone="destructive" size="md" /> +
+ {title} + {description && {description}} +
+
+ +

+ This action cannot be undone. To confirm, type{' '} + + {confirmationText} + {' '} + below. +

+ setValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + placeholder={confirmationText} + aria-label={`Type ${confirmationText} to confirm`} + /> +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 5ba1182..251da47 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,149 +1,201 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; import { cn } from '@/lib/utils'; +type DialogSize = 'standard' | 'form' | 'destructive'; + interface DialogContextValue { open: boolean; onOpenChange: (open: boolean) => void; + size: DialogSize; } const DialogContext = React.createContext(undefined); function useDialog() { - const context = React.useContext(DialogContext); - if (!context) { - throw new Error('useDialog must be used within a Dialog'); - } - return context; + const ctx = React.useContext(DialogContext); + if (!ctx) throw new Error('useDialog must be used within a Dialog'); + return ctx; } interface DialogProps { open?: boolean; onOpenChange?: (open: boolean) => void; + size?: DialogSize; children: React.ReactNode; } -const Dialog: React.FC = ({ open = false, onOpenChange, children }) => { - return ( - {}) }}> - {children} - - ); -}; +const Dialog: React.FC = ({ open = false, onOpenChange, size = 'standard', children }) => ( + {}), size }}> + {children} + +); -const DialogTrigger = React.forwardRef< - HTMLButtonElement, - React.ButtonHTMLAttributes ->(({ onClick, ...props }, ref) => { - const { onOpenChange } = useDialog(); - return ( - - + , + document.body, ); } ); DialogContent.displayName = 'DialogContent'; -const DialogHeader: React.FC> = ({ - className, - ...props -}) =>
; +const DialogHeader: React.FC> = ({ className, ...props }) => ( +
+); DialogHeader.displayName = 'DialogHeader'; -const DialogFooter: React.FC> = ({ - className, - ...props -}) => ( +const DialogBody: React.FC> = ({ className, ...props }) => ( +
+); +DialogBody.displayName = 'DialogBody'; + +const DialogFooter: React.FC> = ({ className, ...props }) => (
); DialogFooter.displayName = 'DialogFooter'; -const DialogTitle = React.forwardRef>( +const DialogTitleText = React.forwardRef>( ({ className, ...props }, ref) => (

) ); -DialogTitle.displayName = 'DialogTitle'; +DialogTitleText.displayName = 'DialogTitle'; -const DialogDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)); +const DialogDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); DialogDescription.displayName = 'DialogDescription'; export { @@ -151,7 +203,8 @@ export { DialogTrigger, DialogContent, DialogHeader, + DialogBody, DialogFooter, - DialogTitle, + DialogTitleText as DialogTitle, DialogDescription, }; diff --git a/frontend/src/components/ui/empty-state.tsx b/frontend/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..34bf2ac --- /dev/null +++ b/frontend/src/components/ui/empty-state.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { IconTile } from './icon-tile'; +import { cn } from '@/lib/utils'; + +interface EmptyStateProps extends React.HTMLAttributes { + icon: React.ReactNode; + title: string; + description?: string; + action?: React.ReactNode; + tone?: 'primary' | 'neutral' | 'destructive'; +} + +export function EmptyState({ icon, title, description, action, tone = 'primary', className, ...props }: EmptyStateProps) { + return ( +

+ +
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/frontend/src/components/ui/icon-tile.tsx b/frontend/src/components/ui/icon-tile.tsx new file mode 100644 index 0000000..05385d5 --- /dev/null +++ b/frontend/src/components/ui/icon-tile.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const iconTileVariants = cva( + 'inline-flex items-center justify-center shrink-0 border', + { + variants: { + tone: { + primary: 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]', + destructive: 'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]', + neutral: 'bg-muted border-border text-muted-foreground', + }, + size: { + sm: 'h-8 w-8 rounded-md [&>svg]:h-4 [&>svg]:w-4', + md: 'h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5', + lg: 'h-14 w-14 rounded-xl [&>svg]:h-7 [&>svg]:w-7', + }, + }, + defaultVariants: { tone: 'primary', size: 'md' }, + } +); + +export interface IconTileProps + extends React.HTMLAttributes, + VariantProps { + icon: React.ReactNode; +} + +export function IconTile({ icon, tone, size, className, ...props }: IconTileProps) { + return ( +
+ {icon} +
+ ); +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 6206c14..86084fb 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,22 +1,24 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; -export interface InputProps extends React.InputHTMLAttributes {} +export type InputProps = React.InputHTMLAttributes; const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ); - } + ({ className, type, ...props }, ref) => ( + + ) ); Input.displayName = 'Input'; diff --git a/frontend/src/components/ui/page-header.tsx b/frontend/src/components/ui/page-header.tsx new file mode 100644 index 0000000..976dcb0 --- /dev/null +++ b/frontend/src/components/ui/page-header.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface PageHeaderProps extends React.HTMLAttributes { + title: string; + subtitle?: React.ReactNode; + actions?: React.ReactNode; +} + +export function PageHeader({ title, subtitle, actions, className, ...props }: PageHeaderProps) { + return ( +
+
+

{title}

+ {subtitle && ( +
{subtitle}
+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index a8d8b74..4ef15b7 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -53,7 +53,7 @@ const TabsList = React.forwardRef( ({ className, value: triggerValue, onClick, ...props }, ref) => { const { value, onValueChange } = useTabs(); const isActive = value === triggerValue; - return ( + {maskable && ( + + )} + +

+
+ ); +} + export function AccessControl() { const [keys, setKeys] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -363,9 +439,7 @@ export function AccessControl() { return (
-
+
@@ -488,7 +562,7 @@ export function AccessControl() {
- + {key.status} @@ -496,12 +570,12 @@ export function AccessControl() {
{key.permissions.slice(0, 2).map((perm, idx) => ( - + {perm.bucketName}: {formatPermissions(perm)} ))} {key.permissions.length > 2 && ( - + +{key.permissions.length - 2} more )} @@ -566,123 +640,82 @@ export function AccessControl() {
{/* Create Key Dialog */} - - + + {newlyCreatedKey ? ( - // Success state - show the secret key <> - API Key Created Successfully - - Save your secret access key now. You won't be able to see it again. - + } tone="primary" size="md" /> +
+ API key created + + Copy your secret access key now — this is the only time it will be shown. + +
-
-
- -
{newlyCreatedKey.name}
+ +
+ +
{newlyCreatedKey.name}
-
- -
- { - navigator.clipboard.writeText(newlyCreatedKey.accessKeyId); - toast.success('Access Key ID copied to clipboard'); - }} - > - {newlyCreatedKey.accessKeyId} - - + + +
+ +
+

+ Save this key now +

+

+ The secret access key cannot be retrieved again. If lost, you'll need to create a new key. +

-
- -
- { - if (newlyCreatedKey.secretKey) { - navigator.clipboard.writeText(newlyCreatedKey.secretKey); - toast.success('Secret Access Key copied to clipboard'); - } - }} - > - {newlyCreatedKey.secretKey} - - -
-
-
-
- -
-

- Important: Save This Key Now -

-

- This is the only time you'll see the secret access key. Make sure to copy and save it securely. - If you lose it, you'll need to create a new key. -

-
-
-
-
+ - + ) : ( - // Creation form <> - Create API Key - - Create a new API key with optional bucket permissions - + } tone="primary" size="md" /> +
+ Create API key + + Generate a new access key pair. You can optionally grant bucket permissions in the same step. + +
-
-
- + +
+ setNewKeyName(e.target.value)} + autoFocus /> -

- A friendly name to identify this API key +

+ A friendly name to identify this key in the console.

- {/* Optional: Grant permissions during creation */} -
-
{/* Key Details Dialog */} - - + + - API Key Details - - View and manage your API key credentials and permissions - + } tone="primary" size="md" /> +
+ {viewingKey?.name || 'API key'} + View credentials and bucket permissions for this key. +
-
- {/* Key Name and Status */} -
-
- -
{viewingKey?.name}
-
-
- -
- - {viewingKey?.status} - -
-
-
- - {/* Access Key ID */} -
- + + {/* Meta strip */} +
- { - if (viewingKey?.accessKeyId) { - navigator.clipboard.writeText(viewingKey.accessKeyId); - setCopiedAccessKeyId(true); - setTimeout(() => setCopiedAccessKeyId(false), 2000); - toast.success('Access Key ID copied to clipboard'); - } - }} - > - {viewingKey?.accessKeyId} - - + + Status + + + {viewingKey?.status} +
-
- - {/* Secret Access Key */} -
- -
- {isLoadingDetailsSecretKey ? ( -
- - Loading secret key... -
- ) : ( - <> - { - if (detailsSecretKey) { - navigator.clipboard.writeText(detailsSecretKey); - setCopiedSecretKey(true); - setTimeout(() => setCopiedSecretKey(false), 2000); - toast.success('Secret Access Key copied to clipboard'); - } - }} - > - {'•'.repeat(40)} - - - - )} -
-
- - {/* Metadata */} -
-
- -
{viewingKey && formatDate(viewingKey.createdAt)}
+
+ + Created + + {viewingKey && formatDate(viewingKey.createdAt)} +
{viewingKey?.expiration && ( -
- -
{formatDate(viewingKey.expiration)}
+
+ + Expires + {formatDate(viewingKey.expiration)}
)}
+ {/* Credentials */} +
+ + +
+ {/* Bucket Permissions */} -
- +
+
+ + {viewingKey && viewingKey.permissions.length > 0 && ( + + {viewingKey.permissions.length} bucket + {viewingKey.permissions.length === 1 ? '' : 's'} + + )} +
{viewingKey && viewingKey.permissions.length > 0 ? ( -
+
{viewingKey.permissions.map((perm, idx) => ( -
-
-
{perm.bucketName}
-
- {formatPermissions(perm)} -
+
+
+ + {perm.bucketName}
-
- {perm.read && ( - - Read - - )} - {perm.write && ( - - Write - - )} - {perm.owner && ( - - Owner - - )} +
+ {perm.read && Read} + {perm.write && Write} + {perm.owner && Owner}
))}
) : ( -
-

- This key has no bucket permissions yet +

+

+ No bucket permissions yet.

)}
-
- + + - +
@@ -1266,13 +1234,13 @@ export function AccessControl() {

{bucketPermission.read && ( - Read + Read )} {bucketPermission.write && ( - Write + Write )} {bucketPermission.owner && ( - Owner + Owner )}

@@ -1302,9 +1270,9 @@ export function AccessControl() {

{perm.bucketName}
- {perm.read && R} - {perm.write && W} - {perm.owner && O} + {perm.read && R} + {perm.write && W} + {perm.owner && O}
))} @@ -1314,7 +1282,7 @@ export function AccessControl() { )}
- +
+
+ + +
+
+ +

Granted

+
+ {granted.length === 0 ? ( +
+ } + tone="neutral" + title="No access granted" + description="Grant at least one access key to make this bucket usable." + /> +
+ ) : ( +
    + {granted.map(({ key, perm }) => ( +
  • +
    +
    {key.name}
    +
    + {key.accessKeyId} +
    +
    +
    + {perm!.read && Read} + {perm!.write && Write} + {perm!.owner && Owner} +
    +
  • + ))} +
+ )} +
+ + ); +} + +function PermRow({ + id, + checked, + onChange, + title, + description, +}: { + id: string; + checked: boolean; + onChange: (v: boolean) => void; + title: string; + description: string; +}) { + return ( +
+ onChange(c as boolean)} /> +
+ +

{description}

+
+
+ ); +} diff --git a/frontend/src/pages/BucketSettings.tsx b/frontend/src/pages/BucketSettings.tsx new file mode 100644 index 0000000..781c8d2 --- /dev/null +++ b/frontend/src/pages/BucketSettings.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { AlertTriangle, Info } from 'lucide-react'; +import { useBuckets, useDeleteBucket } from '@/hooks/useApi'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog'; +import { toast } from 'sonner'; + +function formatBytes(n?: number) { + if (n == null) return '—'; + if (n < 1024) return `${n} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let v = n / 1024; + for (const u of units) { + if (v < 1024) return `${v.toFixed(v >= 10 ? 0 : 1)} ${u}`; + v /= 1024; + } + return `${v.toFixed(0)} PB`; +} + +function formatDate(iso?: string) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +export function BucketSettings() { + const { bucketName = '' } = useParams<{ bucketName: string }>(); + const navigate = useNavigate(); + const { data: buckets = [], isLoading } = useBuckets(); + const bucket = buckets.find((b) => b.name === bucketName); + const deleteMutation = useDeleteBucket(); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + if (isLoading) { + return
Loading…
; + } + if (!bucket) { + return ( +
+ } + tone="neutral" + title="Bucket not found" + description="The bucket you're looking for doesn't exist or you don't have access." + /> +
+ ); + } + + const confirmDelete = async () => { + setDeleting(true); + try { + await deleteMutation.mutateAsync(bucket.name); + toast.success('Bucket deleted'); + navigate('/buckets'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + setDeleting(false); + } + }; + + return ( +
+ {/* Info */} +
+
+ +

Bucket info

+
+
+ {bucket.name}} /> + + + + + + {bucket.websiteAccess ? 'Enabled' : 'Disabled'} + + } + /> +
+
+ + {/* Danger zone */} +
+
+

Danger zone

+

+ Destructive actions for this bucket. +

+
+
+
+
Delete bucket
+
+ All objects in this bucket will be permanently removed. +
+
+ +
+
+ + { + if (!o && !deleting) setDeleteOpen(false); + }} + title={`Delete bucket "${bucket.name}"?`} + description="This action cannot be undone." + confirmationText={bucket.name} + confirmLabel="Delete bucket" + loading={deleting} + onConfirm={confirmDelete} + /> +
+ ); +} + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/pages/BucketWebsite.tsx b/frontend/src/pages/BucketWebsite.tsx new file mode 100644 index 0000000..e174614 --- /dev/null +++ b/frontend/src/pages/BucketWebsite.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, Globe } from 'lucide-react'; +import { useBuckets } from '@/hooks/useApi'; +import { bucketsApi } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { toast } from 'sonner'; + +export function BucketWebsite() { + const { bucketName = '' } = useParams<{ bucketName: string }>(); + const queryClient = useQueryClient(); + const { data: buckets = [], isLoading } = useBuckets(); + const bucket = buckets.find((b) => b.name === bucketName); + + const [enabled, setEnabled] = useState(false); + const [indexDocument, setIndexDocument] = useState('index.html'); + const [errorDocument, setErrorDocument] = useState(''); + const [saving, setSaving] = useState(false); + + // Sync local form state whenever the underlying bucket changes. + useEffect(() => { + if (!bucket) return; + setEnabled(bucket.websiteAccess); + setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html'); + setErrorDocument(bucket.websiteConfig?.errorDocument ?? ''); + }, [bucket?.name, bucket?.websiteAccess, bucket?.websiteConfig?.indexDocument, bucket?.websiteConfig?.errorDocument]); + + if (isLoading) { + return
Loading…
; + } + if (!bucket) { + return ( +
+ } + tone="neutral" + title="Bucket not found" + description="The bucket you're looking for doesn't exist or you don't have access." + /> +
+ ); + } + + const wasEnabled = bucket.websiteAccess; + const disabling = wasEnabled && !enabled; + + const handleReset = () => { + setEnabled(bucket.websiteAccess); + setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html'); + setErrorDocument(bucket.websiteConfig?.errorDocument ?? ''); + }; + + const handleSave = async () => { + setSaving(true); + try { + await bucketsApi.updateBucketWebsite(bucketName, { + enabled, + indexDocument: enabled ? indexDocument : undefined, + errorDocument: enabled && errorDocument ? errorDocument : undefined, + }); + await queryClient.invalidateQueries({ queryKey: ['buckets'] }); + toast.success(disabling ? 'Website disabled' : 'Website configuration updated'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + const saveDisabled = saving || (enabled && !indexDocument); + + return ( +
+
+
+ +

Static website hosting

+
+ +
+
+
+

Website access

+

+ Allow public HTTP access to bucket objects +

+
+
+ + {enabled ? 'Enabled' : 'Disabled'} + + +
+
+ + {enabled && ( +
+
+ + setIndexDocument(e.target.value)} + placeholder="index.html" + /> +

+ The file served when a directory is requested (e.g. index.html) +

+
+ +
+ + setErrorDocument(e.target.value)} + placeholder="404.html (optional)" + /> +

+ The file served when an object is not found (optional) +

+
+
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/Buckets.tsx b/frontend/src/pages/Buckets.tsx index 9e66dba..d35f308 100644 --- a/frontend/src/pages/Buckets.tsx +++ b/frontend/src/pages/Buckets.tsx @@ -1,289 +1,90 @@ -import { useState, useEffect } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useSearchParams } from 'react-router-dom'; -import { Header } from '@/components/layout/header'; -import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi'; -import { useBucketObjects } from '@/hooks/useBucketObjects'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Plus } from 'lucide-react'; +import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi'; import { BucketListView } from '@/components/buckets/BucketListView'; -import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView'; import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog'; -import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog'; -import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog'; -import { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog'; +import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog'; +import { PageHeader } from '@/components/ui/page-header'; +import { Button } from '@/components/ui/button'; import type { Bucket } from '@/types'; import { toast } from 'sonner'; -import { bucketsApi } from '@/lib/api'; export function Buckets() { - const [searchParams, setSearchParams] = useSearchParams(); - - // Bucket state + const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false); - const [selectedBucket, setSelectedBucket] = useState(null); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); - const [settingsBucket, setSettingsBucket] = useState(null); - const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false); - const [websiteBucket, setWebsiteBucket] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); - // Object browser state - initialize from URL params - const [viewingBucket, setViewingBucket] = useState(searchParams.get('bucket')); - const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') || ''); - const [objectSearchQuery, setObjectSearchQuery] = useState(''); - const [initialPageToken, setInitialPageToken] = useState( - searchParams.get('page') || undefined - ); - const [initialItemsPerPage, setInitialItemsPerPage] = useState( - parseInt(searchParams.get('limit') || '25', 10) - ); + const { data: buckets = [], isLoading } = useBuckets(); + const createMutation = useCreateBucket(); + const deleteMutation = useDeleteBucket(); - // Sync URL params with state on mount and when URL changes - useEffect(() => { - const bucketParam = searchParams.get('bucket'); - const prefixParam = searchParams.get('prefix') || ''; - const pageParam = searchParams.get('page') || undefined; - const limitParam = parseInt(searchParams.get('limit') || '25', 10); - - if (bucketParam !== viewingBucket) { - setViewingBucket(bucketParam); - } - if (prefixParam !== currentPath) { - setCurrentPath(prefixParam); - } - setInitialPageToken(pageParam); - setInitialItemsPerPage(limitParam); - }, [searchParams]); - - // Custom hooks - const queryClient = useQueryClient(); - const { data: buckets = [], isLoading: bucketsLoading } = useBuckets(); - const createBucketMutation = useCreateBucket(); - const deleteBucketMutation = useDeleteBucket(); - const grantPermissionMutation = useGrantBucketPermission(); - const { - objects, - isLoading: objectsLoading, - isRefreshing, - isNavigating, - isTruncated, - nextContinuationToken, - itemsPerPage, - setItemsPerPage, - uploadFiles, - uploadTasks, - deleteObject, - deleteMultipleObjects, - createDirectory, - fetchObjects - } = useBucketObjects( - viewingBucket, - currentPath - ); - - const handleViewBucket = (bucketName: string) => { - setViewingBucket(bucketName); - setCurrentPath(''); - setObjectSearchQuery(''); - setSearchParams({ bucket: bucketName }); - }; - - const handleBackToBuckets = () => { - setViewingBucket(null); - setCurrentPath(''); - setObjectSearchQuery(''); - setSearchParams({}); - }; - - const handleNavigateToFolder = (path: string) => { - setCurrentPath(path); - if (viewingBucket) { - const params: Record = { bucket: viewingBucket }; - if (path) { - params.prefix = path; - } - // Reset pagination when navigating to a new folder - setSearchParams(params); - } - }; - - const handlePageChange = (token?: string) => { - fetchObjects(token); - // Update URL with page token - if (viewingBucket) { - const params: Record = { bucket: viewingBucket }; - if (currentPath) { - params.prefix = currentPath; - } - if (token) { - params.page = token; - } - if (itemsPerPage !== 25) { - params.limit = itemsPerPage.toString(); - } - setSearchParams(params); - } - }; - - const handleItemsPerPageChange = (count: number) => { - setItemsPerPage(count); - // Update URL with new limit - if (viewingBucket) { - const params: Record = { bucket: viewingBucket }; - if (currentPath) { - params.prefix = currentPath; - } - if (count !== 25) { - params.limit = count.toString(); - } - setSearchParams(params); - } - }; - - const handleOpenSettings = (bucket: Bucket) => { - setSettingsBucket(bucket); - setSettingsDialogOpen(true); - }; - - const handleOpenWebsiteSettings = (bucket: Bucket) => { - setWebsiteBucket(bucket); - setWebsiteDialogOpen(true); - }; - - const handleSaveWebsite = async ( - bucketName: string, - payload: { enabled: boolean; indexDocument?: string; errorDocument?: string } - ): Promise => { - try { - await bucketsApi.updateBucketWebsite(bucketName, payload); - queryClient.invalidateQueries({ queryKey: ['buckets'] }); - toast.success('Website configuration updated'); - return true; - } catch { - return false; - } - }; - - const handleRefreshObjects = async () => { - if (isRefreshing) return; - try { - await fetchObjects(undefined, true); - toast.success('Objects refreshed successfully'); - } catch (error) { - console.error('Refresh error:', error); - } - }; - - // Wrapper functions for mutations to match dialog APIs const createBucket = async (name: string, region?: string) => { try { - await createBucketMutation.mutateAsync({ name, region }); + await createMutation.mutateAsync({ name, region }); + toast.success(`Bucket "${name}" created`); return true; - } catch (error) { + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Create failed'); return false; } }; - const deleteBucket = async (name: string) => { + const confirmDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); try { - await deleteBucketMutation.mutateAsync(name); - return true; - } catch (error) { - return false; + await deleteMutation.mutateAsync(deleteTarget.name); + toast.success('Bucket deleted'); + setDeleteTarget(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + } finally { + setDeleting(false); } }; - const grantPermission = async ( - bucketName: string, - accessKeyId: string, - permissions: { read: boolean; write: boolean; owner: boolean } - ) => { - try { - await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions }); - return true; - } catch (error) { - return false; - } - }; - - // If viewing a bucket's objects, show the object browser view - if (viewingBucket) { - return ( - - ); - } - - // Default view: show buckets list return (
-
+ setCreateOpen(true)}> + Create bucket + + } + />
setCreateDialogOpen(true)} - onDeleteBucket={(bucket) => { - setSelectedBucket(bucket); - setDeleteBucketDialogOpen(true); - }} + onViewBucket={(name) => navigate(`/buckets/${name}/objects`)} + onOpenSettings={(b) => navigate(`/buckets/${b.name}/settings`)} + onWebsiteSettings={(b) => navigate(`/buckets/${b.name}/website`)} + onDeleteBucket={(b) => setDeleteTarget(b)} />
- {/* Dialogs */} - - - - - !o && setDeleteTarget(null)} + title={deleteTarget ? `Delete bucket "${deleteTarget.name}"?` : ''} + description="All objects in this bucket will be permanently removed." + confirmationText={deleteTarget?.name ?? ''} + confirmLabel="Delete bucket" + loading={deleting} + onConfirm={confirmDelete} />
); diff --git a/frontend/src/pages/Cluster.tsx b/frontend/src/pages/Cluster.tsx index 4a52ce3..9693306 100644 --- a/frontend/src/pages/Cluster.tsx +++ b/frontend/src/pages/Cluster.tsx @@ -1,5 +1,5 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; -import {Header} from '@/components/layout/header'; +import {PageHeader} from '@/components/ui/page-header'; import {formatBytes} from '@/lib/file-utils'; import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react'; import {useQuery} from '@tanstack/react-query'; @@ -83,7 +83,7 @@ export function Cluster() { if (isLoading) { return (
-
+
@@ -96,7 +96,7 @@ export function Cluster() { return (
-
+
{/* Cluster Health Overview */}
@@ -215,7 +215,7 @@ export function Cluster() {
Status
- + {nodeStatus.label}
@@ -393,7 +393,7 @@ export function Cluster() {
Garage Features
{(info as LocalNodeInfo).garageFeatures!.map((feature) => ( - + {feature} ))} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index acd5730..18a61ea 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,244 +1,237 @@ -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; -import {Header} from '@/components/layout/header'; -import {formatBytes} from '@/lib/file-utils'; -import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react'; -import {BucketUsageChart} from '@/components/charts/BucketUsageChart'; -import {useDashboardData} from '@/hooks/useApi'; -import type {ClusterHealth} from '@/types'; +import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react'; +import { PageHeader } from '@/components/ui/page-header'; +import { IconTile } from '@/components/ui/icon-tile'; +import { EmptyState } from '@/components/ui/empty-state'; +import { BucketUsageChart } from '@/components/charts/BucketUsageChart'; +import { useDashboardData } from '@/hooks/useApi'; +import { formatBytes } from '@/lib/file-utils'; +import type { ClusterHealth } from '@/types'; + +type StatTone = 'primary' | 'destructive' | 'neutral'; +type HealthLabel = 'Healthy' | 'Degraded' | 'Unhealthy' | 'Unknown'; + +function deriveHealth(health: ClusterHealth | null): { label: HealthLabel; tone: StatTone } { + if (!health) return { label: 'Unknown', tone: 'neutral' }; + if ( + health.storageNodesUp === health.storageNodes && + health.partitionsAllOk === health.partitions && + health.connectedNodes === health.knownNodes + ) return { label: 'Healthy', tone: 'primary' }; + if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) return { label: 'Degraded', tone: 'primary' }; + return { label: 'Unhealthy', tone: 'destructive' }; +} export function Dashboard() { const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData(); - const metrics = metricsQuery.data; - const buckets = bucketsQuery.data || []; - const clusterHealth = healthQuery.data; - - const getHealthStatus = (health: ClusterHealth | null) => { - if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle }; - if ( - health.storageNodesUp === health.storageNodes && - health.partitionsAllOk === health.partitions && - health.connectedNodes === health.knownNodes - ) { - return { color: 'text-green-500', label: 'Healthy', icon: Zap }; - } - if ( - health.storageNodesUp > 0 && - health.partitionsQuorum > 0 - ) { - return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle }; - } - return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle }; - }; - - const healthStatus = getHealthStatus(clusterHealth ?? null); - - if (isLoading) { - return ( -
-
-
-
-
-

Loading dashboard...

-
-
-
- ); - } + const buckets = bucketsQuery.data ?? []; + const clusterHealth = healthQuery.data ?? null; + const health = deriveHealth(clusterHealth); return (
-
-
- {/* Top Stats Grid */} -
- - - Total Storage - - - -
- {metrics ? formatBytes(metrics.totalSize) : '---'} -
-

- Across {metrics?.bucketCount || 0} buckets -

-
-
+ - - - Total Objects - - - -
- {metrics?.objectCount.toLocaleString() || '---'} -
-

- Files and folders -

-
-
- - - - Buckets - - - -
{metrics?.bucketCount || '---'}
-

- Active storage buckets -

-
-
+ {isLoading ? ( +
+
+
+

Loading dashboard…

+
+ ) : ( +
+ {/* KPI row */} +
+ } + /> + } + /> + } + /> + : } + iconTone={health.tone} + /> +
- {/* Cluster Status */} -
- - - Cluster Status - - - -
-
- {healthStatus.label} -
-
-

- {clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected -

-
-
+ {/* Cluster row */} +
+ } + /> + } + /> + } + /> +
- - - Storage Nodes - - - -
- {clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0} -
-

- Healthy storage nodes -

-
-
- - - - Partitions - - - -
- {clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0} -
-

- Healthy partitions -

-
-
-
- - {/* Charts Row 1 */} -
- {/* Bucket Usage Chart */} - - - Storage Usage by Bucket - Distribution of storage across buckets - - + {/* Charts */} +
+ {metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? ( ) : ( -
No data available
+
No data available
)} - -
+ - {/* Bucket Details Table */} - - - Storage Usage by Bucket (Table) - Detailed breakdown of storage across all buckets - - -
- {metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? ( - metrics.usageByBucket.map((bucket) => ( -
-
- {bucket.bucketName} -
- - {bucket.objectCount.toLocaleString()} objects - - {formatBytes(bucket.size)} - - {bucket.percentage.toFixed(1)}% - -
-
-
-
-
+ + {metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? ( +
+ {metrics.usageByBucket.map((bucket) => ( +
+
+ {bucket.bucketName} +
+ {bucket.objectCount.toLocaleString()} objects + {formatBytes(bucket.size)} + {bucket.percentage.toFixed(1)}%
- )) - ) : ( -
No buckets available
- )} -
- - -
- - {/* Recent Buckets */} - - - Recent Buckets - Your most recently created buckets - - -
- {buckets.slice(0, 5).map((bucket) => ( -
-
-
- +
+
+
+
-
-

{bucket.name}

-

+ ))} +

+ ) : ( +
No buckets available
+ )} + +
+ + {/* Recent buckets */} + + {buckets.length === 0 ? ( + } + title="No buckets yet" + description="Create your first bucket from the Buckets page to start storing objects." + tone="neutral" + /> + ) : ( +
    + {buckets.slice(0, 5).map((bucket) => ( +
  • + } tone="primary" size="md" /> +
    +

    {bucket.name}

    +

    Created {new Date(bucket.creationDate).toLocaleDateString()}

    -
-
-

{bucket.objectCount?.toLocaleString()} objects

-

- {bucket.size ? formatBytes(bucket.size) : '---'} -

-
-
- ))} -
- -
-
+
+

{bucket.objectCount?.toLocaleString() ?? '—'} objects

+

+ {bucket.size ? formatBytes(bucket.size) : '—'} +

+
+ + ))} + + )} + +
+ )}
); } + +function StatCard({ + label, + value, + sub, + icon, + iconTone = 'primary', + valueTone = 'neutral', +}: { + label: string; + value: string; + sub?: string; + icon: React.ReactNode; + iconTone?: StatTone; + valueTone?: StatTone; +}) { + const valueColor = + valueTone === 'primary' + ? 'text-[var(--primary)]' + : valueTone === 'destructive' + ? 'text-[var(--destructive)]' + : 'text-[var(--foreground)]'; + return ( +
+
+ + {label} + + +
+
+ {value} +
+ {sub &&
{sub}
} +
+ ); +} + +function Card({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+ ); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 92408a9..c4bbd50 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -7,6 +7,10 @@ export default { ], theme: { extend: { + fontFamily: { + sans: ['Geist Sans', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'system-ui', 'sans-serif'], + mono: ['Geist Mono', 'ui-monospace', '"SF Mono"', 'Menlo', 'Consolas', 'monospace'], + }, colors: { border: 'var(--border)', input: 'var(--input)', @@ -16,6 +20,8 @@ export default { primary: { DEFAULT: 'var(--primary)', foreground: 'var(--primary-foreground)', + soft: 'var(--accent-primary-soft)', + softBorder: 'var(--accent-primary-border)', }, secondary: { DEFAULT: 'var(--secondary)', @@ -24,6 +30,11 @@ export default { destructive: { DEFAULT: 'var(--destructive)', foreground: 'var(--destructive-foreground)', + soft: 'var(--danger-soft)', + softBorder: 'var(--danger-border)', + }, + success: { + soft: 'var(--success-soft)', }, muted: { DEFAULT: 'var(--muted)', @@ -41,6 +52,7 @@ export default { DEFAULT: 'var(--card)', foreground: 'var(--card-foreground)', }, + sunken: 'var(--surface-sunken)', }, borderRadius: { lg: 'var(--radius)',