mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
Add files
Initial Files
This commit is contained in:
+33
-3
@@ -27,6 +27,36 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env*
|
||||
!config.yaml.example
|
||||
docker-compose.*.yml
|
||||
|
||||
data/
|
||||
meta/
|
||||
garage.toml
|
||||
|
||||
docs/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,171 @@
|
||||
# Garage UI - Frontend
|
||||
|
||||
Modern React frontend for Garage S3-compatible object storage management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard** - Overview of storage metrics and recent activity
|
||||
- **Bucket Management** - Create, view, and manage S3 buckets
|
||||
- **Object Browser** - Navigate folders, upload/download files with drag-and-drop
|
||||
- **Access Control** - Manage API keys and permissions
|
||||
- **Dark/Light Mode** - System-aware theme with manual toggle
|
||||
- **Data-Dense UI** - Optimized for power users with comprehensive tables
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: React 19 + TypeScript
|
||||
- **Build Tool**: Vite 7
|
||||
- **Routing**: React Router v6
|
||||
- **State Management**: Zustand
|
||||
- **UI Components**: shadcn/ui (built on Radix UI primitives)
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Forms**: React Hook Form + Zod
|
||||
- **Tables**: TanStack Table
|
||||
- **File Upload**: React Dropzone
|
||||
- **Icons**: Lucide React
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Update `.env` with your backend API URL:
|
||||
```
|
||||
VITE_API_URL=http://localhost:8080/api
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at http://localhost:3000
|
||||
|
||||
### Building for Production
|
||||
|
||||
Build the application:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Preview the production build:
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui base components
|
||||
│ ├── layout/ # Layout components (Sidebar, Header)
|
||||
│ ├── buckets/ # Bucket-specific components
|
||||
│ ├── objects/ # Object browser components
|
||||
│ └── access/ # Access control components
|
||||
├── pages/ # Page components
|
||||
│ ├── Dashboard.tsx
|
||||
│ ├── Buckets.tsx
|
||||
│ ├── Objects.tsx
|
||||
│ └── AccessControl.tsx
|
||||
├── lib/
|
||||
│ ├── api.ts # API client with mock data
|
||||
│ └── utils.ts # Utility functions
|
||||
├── hooks/ # Custom React hooks
|
||||
├── types/ # TypeScript type definitions
|
||||
└── stores/ # Zustand stores
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
The app is currently using mock data. To connect to your Go backend:
|
||||
|
||||
1. Ensure your backend is running on `http://localhost:8080`
|
||||
2. In `src/lib/api.ts`, uncomment the real API calls
|
||||
3. Comment out or remove the mock data returns
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
// Before (mock data)
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
return mockBuckets;
|
||||
},
|
||||
|
||||
// After (real API)
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
const response = await api.get('/buckets');
|
||||
return response.data;
|
||||
},
|
||||
```
|
||||
|
||||
## Features by Page
|
||||
|
||||
### Dashboard
|
||||
- Storage metrics overview
|
||||
- Bucket count and object statistics
|
||||
- Storage distribution visualization
|
||||
- Recent buckets list
|
||||
|
||||
### Buckets
|
||||
- List all buckets with search
|
||||
- Create new buckets
|
||||
- View bucket details
|
||||
- Delete buckets
|
||||
- Region information
|
||||
|
||||
### Objects
|
||||
- Browse objects and folders
|
||||
- Breadcrumb navigation
|
||||
- Drag-and-drop file upload
|
||||
- Multiple file selection
|
||||
- Download objects
|
||||
- Delete objects
|
||||
- Object metadata
|
||||
|
||||
### Access Control
|
||||
- API key management
|
||||
- Create keys with permissions
|
||||
- Activate/deactivate keys
|
||||
- Copy access key IDs
|
||||
- Permission configuration
|
||||
- Bucket policies (coming soon)
|
||||
|
||||
## Theme Customization
|
||||
|
||||
The app uses CSS variables for theming. Customize colors in `src/index.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary: 240 5.9% 10%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
/* ... more variables */
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a custom frontend for Garage storage. Feel free to extend with additional features:
|
||||
- Analytics dashboards
|
||||
- Versioning management
|
||||
- Lifecycle policies UI
|
||||
- Replication configuration
|
||||
- Metrics visualization
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/garage.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Garage UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5532
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {BrowserRouter, 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 {Dashboard} from '@/pages/Dashboard';
|
||||
import {Buckets} from '@/pages/Buckets';
|
||||
import {Cluster} from '@/pages/Cluster';
|
||||
import {AccessControl} from '@/pages/AccessControl';
|
||||
import {Toaster} from 'sonner';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
|
||||
function ThemedToaster() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return <Toaster richColors position="bottom-right" theme={theme} />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="cluster" element={<Cluster />} />
|
||||
<Route path="access" element={<AccessControl />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ThemedToaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,147 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes, formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketListViewProps {
|
||||
buckets: Bucket[];
|
||||
searchQuery: string;
|
||||
isLoading?: boolean;
|
||||
onSearchChange: (query: string) => void;
|
||||
onViewBucket: (bucketName: string) => void;
|
||||
onOpenSettings: (bucket: Bucket) => void;
|
||||
onCreateBucket: () => void;
|
||||
onDeleteBucket: (bucket: Bucket) => void;
|
||||
}
|
||||
|
||||
export function BucketListView({
|
||||
buckets,
|
||||
searchQuery,
|
||||
isLoading = false,
|
||||
onSearchChange,
|
||||
onViewBucket,
|
||||
onOpenSettings,
|
||||
onCreateBucket,
|
||||
onDeleteBucket,
|
||||
}: BucketListViewProps) {
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search buckets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={onCreateBucket} className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Buckets Table */}
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Region</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Objects</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading buckets...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredBuckets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? 'No buckets found matching your search' : 'No buckets yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredBuckets.map((bucket) => (
|
||||
<TableRow
|
||||
key={bucket.name}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
|
||||
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{formatDate(bucket.creationDate)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="-m-3 top-1 relative">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewBucket(bucket.name);
|
||||
}}>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
View Objects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(bucket);
|
||||
}}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBucket(bucket);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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 { accessApi } from '@/lib/api';
|
||||
import type { AccessKey, 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<boolean>;
|
||||
}
|
||||
|
||||
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
|
||||
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
|
||||
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
loadAccessKeys();
|
||||
resetForm();
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const loadAccessKeys = async () => {
|
||||
try {
|
||||
const keys = await accessApi.listKeys();
|
||||
setAvailableKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('Failed to load access keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bucket Settings - {bucket?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grant access key permissions for this bucket
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Access Key</label>
|
||||
<Select
|
||||
value={selectedAccessKey}
|
||||
onChange={(value) => handleAccessKeyChange(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select an access key --</SelectOption>
|
||||
{availableKeys.map((key) => (
|
||||
<SelectOption key={key.accessKeyId} value={key.accessKeyId}>
|
||||
{key.name} ({key.accessKeyId})
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-3 border rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="permission-read"
|
||||
checked={permissionRead}
|
||||
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="permission-read"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Read
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="permission-write"
|
||||
checked={permissionWrite}
|
||||
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="permission-write"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Write
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="permission-owner"
|
||||
checked={permissionOwner}
|
||||
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="permission-owner"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Owner
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleGrantPermission} disabled={!selectedAccessKey}>
|
||||
Grant Permission
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } 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 { toast } from 'sonner';
|
||||
|
||||
interface CreateBucketDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBucket: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
|
||||
const [bucketName, setBucketName] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!bucketName) {
|
||||
toast.error('Please enter a bucket name');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateBucket(bucketName);
|
||||
if (success) {
|
||||
setBucketName('');
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Bucket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new storage bucket for your objects
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bucket Name</label>
|
||||
<Input
|
||||
placeholder="my-bucket-name"
|
||||
value={bucketName}
|
||||
onChange={(e) => setBucketName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be unique and follow DNS naming conventions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="space-y-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={!bucketName ? 'default_disabled' : 'default'}
|
||||
onClick={handleCreate}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } 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 { toast } from 'sonner';
|
||||
|
||||
interface CreateDirectoryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentPath: string;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
|
||||
const [dirName, setDirName] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!dirName) {
|
||||
toast.error('Please enter a directory name');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateDirectory(dirName);
|
||||
if (success) {
|
||||
setDirName('');
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Directory</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new directory in {currentPath || 'the root'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Directory Name</label>
|
||||
<Input
|
||||
placeholder="my-directory"
|
||||
value={dirName}
|
||||
onChange={(e) => setDirName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!dirName}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface DeleteBucketDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bucket: Bucket | null;
|
||||
onDeleteBucket: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!bucket) return;
|
||||
|
||||
const success = await onDeleteBucket(bucket.name);
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Bucket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{bucket?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { S3Object } from '@/types';
|
||||
|
||||
interface DeleteObjectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
object: S3Object | null;
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject }: DeleteObjectDialogProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!object) return;
|
||||
|
||||
const success = await onDeleteObject(object.key);
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Object</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { ObjectsTable } from './ObjectsTable';
|
||||
import { CreateDirectoryDialog } from './CreateDirectoryDialog';
|
||||
import { DeleteObjectDialog } from './DeleteObjectDialog';
|
||||
import { ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload } from 'lucide-react';
|
||||
import { getBreadcrumbs } from '@/lib/file-utils';
|
||||
import type { S3Object } from '@/types';
|
||||
|
||||
interface ObjectBrowserViewProps {
|
||||
bucketName: string;
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
isLoading?: boolean;
|
||||
isTruncated?: boolean;
|
||||
nextContinuationToken?: string;
|
||||
itemsPerPage: number;
|
||||
onSearchChange: (query: string) => void;
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onPageChange: (token?: string) => void;
|
||||
onItemsPerPageChange: (count: number) => void;
|
||||
isRefreshing: boolean;
|
||||
isNavigating: boolean;
|
||||
initialPageToken?: string;
|
||||
initialItemsPerPage?: number;
|
||||
}
|
||||
|
||||
export function ObjectBrowserView({
|
||||
bucketName,
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
onDeleteObject,
|
||||
onDeleteMultipleObjects,
|
||||
onCreateDirectory,
|
||||
onRefresh,
|
||||
onPageChange,
|
||||
onItemsPerPageChange,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectBrowserViewProps) {
|
||||
const [showUploadZone, setShowUploadZone] = useState(false);
|
||||
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
|
||||
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
|
||||
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
|
||||
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: async (acceptedFiles, fileRejections, event) => {
|
||||
// Get files with their full paths from DataTransferItems API
|
||||
const filesWithPaths: File[] = [];
|
||||
|
||||
if (event.dataTransfer?.items) {
|
||||
// Use DataTransferItemList API to preserve folder structure
|
||||
const items = Array.from(event.dataTransfer.items);
|
||||
await Promise.all(items.map(async (item) => {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
await traverseFileTree(entry, '', filesWithPaths);
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Fallback to standard files
|
||||
filesWithPaths.push(...acceptedFiles);
|
||||
}
|
||||
|
||||
await onUploadFiles(filesWithPaths.length > 0 ? filesWithPaths : acceptedFiles);
|
||||
setShowUploadZone(false);
|
||||
},
|
||||
noClick: true,
|
||||
});
|
||||
|
||||
// Helper function to traverse file/directory tree
|
||||
const traverseFileTree = async (item: any, path: string, files: File[]): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file: File) => {
|
||||
// Add the relative path to the file object
|
||||
const fullPath = path + file.name;
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: fullPath,
|
||||
writable: false
|
||||
});
|
||||
files.push(file);
|
||||
resolve();
|
||||
});
|
||||
} else if (item.isDirectory) {
|
||||
const dirReader = item.createReader();
|
||||
dirReader.readEntries(async (entries: any[]) => {
|
||||
for (const entry of entries) {
|
||||
await traverseFileTree(entry, path + item.name + '/', files);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleFileSelection = (key: string) => {
|
||||
const newSelected = new Set(selectedFileKeys);
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
setSelectedFileKeys(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAllFiles = () => {
|
||||
const fileKeys = objects
|
||||
.filter(obj => !obj.isFolder)
|
||||
.map(obj => obj.key);
|
||||
|
||||
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
|
||||
setSelectedFileKeys(new Set());
|
||||
} else {
|
||||
setSelectedFileKeys(new Set(fileKeys));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteFiles = async () => {
|
||||
if (selectedFileKeys.size === 0) return;
|
||||
|
||||
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
|
||||
setSelectedFileKeys(new Set());
|
||||
};
|
||||
|
||||
const handleDeleteObject = async (key: string): Promise<boolean> => {
|
||||
const success = await onDeleteObject(key);
|
||||
if (success) {
|
||||
setDeleteObjectDialogOpen(false);
|
||||
setSelectedObject(null);
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
await onUploadFiles(files);
|
||||
setShowUploadZone(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title={`Objects in ${bucketName}`} />
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Back Button */}
|
||||
<Button variant="outline" onClick={onBackToBuckets} className="text-sm sm:text-base">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back to Buckets</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm overflow-x-auto">
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
{getBreadcrumbs(currentPath).map((crumb, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{index > 0 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
<button
|
||||
onClick={() => onNavigateToFolder(crumb.path)}
|
||||
className={
|
||||
index === getBreadcrumbs(currentPath).length - 1
|
||||
? 'font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search objects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedFileKeys.size > 0 && (
|
||||
<Button
|
||||
onClick={handleBulkDeleteFiles}
|
||||
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
||||
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</Button>
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Add Directory</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
|
||||
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && (
|
||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-20 h-20 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<p className="text-sm">
|
||||
Drag and drop files/folders or{' '}
|
||||
<label
|
||||
htmlFor="file-input"
|
||||
className="font-medium text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
select files
|
||||
</label>
|
||||
{' / '}
|
||||
<label
|
||||
htmlFor="folder-input"
|
||||
className="font-medium text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
select folder
|
||||
</label>
|
||||
</p>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
uploadFiles(files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input
|
||||
id="folder-input"
|
||||
type="file"
|
||||
{...({ webkitdirectory: '', directory: '', mozdirectory: '' } as any)}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
uploadFiles(files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative border rounded-lg transition-all duration-200 overflow-visible ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5 border-2 shadow-lg'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{/* Drag & Drop Overlay */}
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 z-50 bg-primary/10 backdrop-blur-sm rounded-lg flex items-center justify-center pointer-events-none">
|
||||
<div className="bg-background/95 border-2 border-primary border-dashed rounded-lg p-8 shadow-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<Upload className="h-16 w-16 text-primary animate-bounce" />
|
||||
<div className="absolute inset-0 h-16 w-16 text-primary opacity-30 animate-ping">
|
||||
<Upload className="h-16 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-lg font-semibold text-primary">Drop files here to upload</p>
|
||||
<p className="text-sm text-muted-foreground">Files will be uploaded to {currentPath || 'root'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ObjectsTable
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
selectedFileKeys={selectedFileKeys}
|
||||
isDragActive={isDragActive}
|
||||
isLoading={isLoading && !isRefreshing && !isNavigating}
|
||||
isTruncated={isTruncated}
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onNavigateToFolder={onNavigateToFolder}
|
||||
onDeleteObject={(obj) => {
|
||||
setSelectedObject(obj);
|
||||
setDeleteObjectDialogOpen(true);
|
||||
}}
|
||||
onToggleFileSelection={handleToggleFileSelection}
|
||||
onSelectAllFiles={handleSelectAllFiles}
|
||||
onPageChange={onPageChange}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
initialPageToken={initialPageToken}
|
||||
initialItemsPerPage={initialItemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Directory Dialog */}
|
||||
<CreateDirectoryDialog
|
||||
open={createDirDialogOpen}
|
||||
onOpenChange={setCreateDirDialogOpen}
|
||||
currentPath={currentPath}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
/>
|
||||
|
||||
{/* Delete Object Dialog */}
|
||||
<DeleteObjectDialog
|
||||
open={deleteObjectDialogOpen}
|
||||
onOpenChange={setDeleteObjectDialogOpen}
|
||||
object={selectedObject}
|
||||
onDeleteObject={handleDeleteObject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatRelativeTime, getFileType} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
selectedFileKeys: Set<string>;
|
||||
isDragActive: boolean;
|
||||
isLoading?: boolean;
|
||||
isTruncated?: boolean;
|
||||
nextContinuationToken?: string;
|
||||
itemsPerPage: number;
|
||||
onNavigateToFolder: (key: string) => void;
|
||||
onDeleteObject: (object: S3Object) => void;
|
||||
onToggleFileSelection: (key: string) => void;
|
||||
onSelectAllFiles: () => void;
|
||||
onPageChange: (token?: string) => void;
|
||||
onItemsPerPageChange: (count: number) => void;
|
||||
initialPageToken?: string;
|
||||
initialItemsPerPage?: number;
|
||||
}
|
||||
|
||||
type SortColumn = 'name' | 'size' | 'modified';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function ObjectsTable({
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
selectedFileKeys,
|
||||
isDragActive,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
onNavigateToFolder,
|
||||
onDeleteObject,
|
||||
onToggleFileSelection,
|
||||
onSelectAllFiles,
|
||||
onPageChange,
|
||||
onItemsPerPageChange,
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectsTableProps) {
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
||||
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Initialize from URL params on first load
|
||||
useEffect(() => {
|
||||
if (!initialized && initialItemsPerPage && initialItemsPerPage !== itemsPerPage) {
|
||||
onItemsPerPageChange(initialItemsPerPage);
|
||||
setInitialized(true);
|
||||
}
|
||||
if (!initialized && initialPageToken && initialPageToken !== nextContinuationToken) {
|
||||
// If we have an initial page token, trigger page change
|
||||
onPageChange(initialPageToken);
|
||||
setInitialized(true);
|
||||
}
|
||||
if (!initialized && !initialPageToken && !initialItemsPerPage) {
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
|
||||
const sortObjects = (objList: S3Object[]): S3Object[] => {
|
||||
const sorted = [...objList].sort((a, b) => {
|
||||
// Always put folders before files
|
||||
const aIsFolder = a.isFolder ? 1 : 0;
|
||||
const bIsFolder = b.isFolder ? 1 : 0;
|
||||
if (aIsFolder !== bIsFolder) {
|
||||
return bIsFolder - aIsFolder;
|
||||
}
|
||||
|
||||
let compareValue = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name': {
|
||||
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
||||
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
||||
compareValue = aName.localeCompare(bName);
|
||||
break;
|
||||
}
|
||||
case 'size':
|
||||
compareValue = a.size - b.size;
|
||||
break;
|
||||
case 'modified': {
|
||||
const aDate = new Date(a.lastModified).getTime();
|
||||
const bDate = new Date(b.lastModified).getTime();
|
||||
compareValue = aDate - bDate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
// Reset pagination when path/search changes
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
if (nextContinuationToken && isTruncated) {
|
||||
setPageTokens(prev => {
|
||||
const newTokens = [...prev];
|
||||
// Only add the token if we don't have it yet
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
if (nextIndex >= newTokens.length) {
|
||||
newTokens[nextIndex] = nextContinuationToken;
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
}
|
||||
}, [nextContinuationToken, isTruncated, currentPageIndex]);
|
||||
|
||||
const hasPrevious = currentPageIndex > 0;
|
||||
const hasNext = isTruncated;
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasNext && nextContinuationToken) {
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
setCurrentPageIndex(nextIndex);
|
||||
onPageChange(nextContinuationToken);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (hasPrevious) {
|
||||
const prevIndex = currentPageIndex - 1;
|
||||
setCurrentPageIndex(prevIndex);
|
||||
const previousToken = pageTokens[prevIndex];
|
||||
onPageChange(previousToken);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
onItemsPerPageChange(Number(value));
|
||||
setPageTokens([undefined]); // Reset to first page
|
||||
setCurrentPageIndex(0);
|
||||
};
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
||||
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
||||
}
|
||||
onCheckedChange={onSelectAllFiles}
|
||||
aria-label="Select all files"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Storage Class</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('size')}
|
||||
>
|
||||
Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('modified')}
|
||||
>
|
||||
Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading objects...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredObjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No objects found matching your search'
|
||||
: isDragActive
|
||||
? 'Drop files or folders here'
|
||||
: 'No objects in this location'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredObjects.map((obj) => (
|
||||
<TableRow key={obj.key}>
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
<Checkbox
|
||||
disabled
|
||||
checked={false}
|
||||
className="opacity-50 cursor-not-allowed bg-muted"
|
||||
aria-label="Folders cannot be selected"
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedFileKeys.has(obj.key)}
|
||||
onCheckedChange={() => onToggleFileSelection(obj.key)}
|
||||
aria-label={`Select file ${obj.key}`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{obj.isFolder ? (
|
||||
<FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{obj.isFolder ? (
|
||||
<button
|
||||
onClick={() => onNavigateToFolder(obj.key)}
|
||||
className="font-medium cursor-pointer underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{obj.key.replace(currentPath, '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
<Badge variant="secondary">{obj.storageClass}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>
|
||||
{obj.lastModified ?
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{new Date(obj.lastModified).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(new Date(obj.lastModified))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{new Date(obj.lastModified).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>: null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteObject(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{(filteredObjects.length > 0 || hasPrevious) && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-4 border-t bg-background">
|
||||
{/* Items per page selector */}
|
||||
<div className="flex items-center gap-2 text-sm relative z-10">
|
||||
<span className="text-muted-foreground">Items per page:</span>
|
||||
<Select value={itemsPerPage.toString()} onChange={handleItemsPerPageChange}>
|
||||
<SelectOption value="10">10</SelectOption>
|
||||
<SelectOption value="25">25</SelectOption>
|
||||
<SelectOption value="50">50</SelectOption>
|
||||
<SelectOption value="100">100</SelectOption>
|
||||
<SelectOption value="200">200</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Pagination info and controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={hasPrevious ? "default": "default_disabled"}
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!hasPrevious}
|
||||
className="h-8"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={hasNext ? "default": "default_disabled"}
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasNext}
|
||||
className="h-8"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { BucketListView } from './BucketListView';
|
||||
export { ObjectBrowserView } from './ObjectBrowserView';
|
||||
export { ObjectsTable } from './ObjectsTable';
|
||||
export { CreateBucketDialog } from './CreateBucketDialog';
|
||||
export { DeleteBucketDialog } from './DeleteBucketDialog';
|
||||
export { BucketSettingsDialog } from './BucketSettingsDialog';
|
||||
export { CreateDirectoryDialog } from './CreateDirectoryDialog';
|
||||
export { DeleteObjectDialog } from './DeleteObjectDialog';
|
||||
@@ -0,0 +1,65 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
|
||||
import type {BucketUsage} from '@/types';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
|
||||
|
||||
interface BucketUsageChartProps {
|
||||
data: BucketUsage[];
|
||||
}
|
||||
|
||||
export function BucketUsageChart({ data }: BucketUsageChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if dark mode is enabled
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
// Listen for theme changes
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? chartColorPalette.dark : chartColorPalette.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
name: item.bucketName,
|
||||
value: item.size,
|
||||
displaySize: formatBytes(item.size),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name }) => `${name}`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => formatBytes(value as number)}
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import type {ClusterHealth} from '@/types';
|
||||
import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
|
||||
|
||||
interface ClusterHealthChartProps {
|
||||
data: ClusterHealth;
|
||||
}
|
||||
|
||||
export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const gridColor = getGridColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db';
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
metric: 'Nodes',
|
||||
healthy: data.storageNodesUp,
|
||||
unhealthy: data.storageNodes - data.storageNodesUp,
|
||||
},
|
||||
{
|
||||
metric: 'Partitions',
|
||||
healthy: data.partitionsAllOk,
|
||||
unhealthy: data.partitions - data.partitionsAllOk,
|
||||
},
|
||||
{
|
||||
metric: 'Connected',
|
||||
healthy: data.connectedNodes,
|
||||
unhealthy: data.knownNodes - data.connectedNodes,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
|
||||
<XAxis dataKey="metric" stroke={textColor} />
|
||||
<YAxis stroke={textColor} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
<Bar dataKey="healthy" stackId="a" fill={colors.green} name="Healthy" />
|
||||
<Bar dataKey="unhealthy" stackId="a" fill={unhealthyColor} name="Unhealthy" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import type { RequestMetrics } from '@/types';
|
||||
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
|
||||
|
||||
interface RequestMetricsChartProps {
|
||||
data: RequestMetrics;
|
||||
}
|
||||
|
||||
export function RequestMetricsChart({ data }: RequestMetricsChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const gridColor = getGridColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: 'Requests (24h)',
|
||||
GET: data.getRequests,
|
||||
PUT: data.putRequests,
|
||||
DELETE: data.deleteRequests,
|
||||
LIST: data.listRequests,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
|
||||
<XAxis dataKey="name" stroke={textColor} />
|
||||
<YAxis stroke={textColor} />
|
||||
<Tooltip
|
||||
formatter={(value) => (value as number).toLocaleString()}
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
<Bar dataKey="GET" stackId="a" fill={colors.blue} />
|
||||
<Bar dataKey="PUT" stackId="a" fill={colors.green} />
|
||||
<Bar dataKey="DELETE" stackId="a" fill={colors.red} />
|
||||
<Bar dataKey="LIST" stackId="a" fill={colors.orange} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function Header({ title }: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b" style={{ backgroundColor: 'var(--background)' }}>
|
||||
<div className="flex h-16 items-center gap-2 sm:gap-4 px-4 sm:px-6 md:pl-6">
|
||||
<div className="flex-1 min-w-0 md:ml-0 ml-12">
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-semibold truncate">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative hidden sm:block w-32 md:w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
className="pl-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { useState } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed top-4 left-4 z-50 md:hidden"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: '/',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'Buckets',
|
||||
href: '/buckets',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
title: 'Cluster',
|
||||
href: '/cluster',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
title: 'Access Control',
|
||||
href: '/access',
|
||||
icon: Key,
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
|
||||
'fixed md:static z-50',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
style={{ backgroundColor: 'var(--background)' }}
|
||||
>
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
|
||||
<span className="text-lg font-semibold">Garage UI</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
style={isActive ? { backgroundColor: 'var(--primary)', color: '#000000' } : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/*<div className="border-t p-4">*/}
|
||||
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
|
||||
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
|
||||
{/* AD*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="flex-1 overflow-hidden">*/}
|
||||
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
|
||||
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
interface ThemeProviderState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'garage-ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
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',
|
||||
{
|
||||
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',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
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',
|
||||
{
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, onChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(e.target.checked);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 border border-primary rounded cursor-pointer ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogContextValue {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
|
||||
|
||||
function useDialog() {
|
||||
const context = React.useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error('useDialog must be used within a Dialog');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const DialogTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ onClick, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog();
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={(e) => {
|
||||
onOpenChange(true);
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogTrigger.displayName = 'DialogTrigger';
|
||||
|
||||
const DialogPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { open } = useDialog();
|
||||
if (!open) return null;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog();
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
DialogOverlay.displayName = 'DialogOverlay';
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog();
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ backgroundColor: 'var(--background)' }}
|
||||
className={cn(
|
||||
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
);
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left bg-background', className)} {...props} />;
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end space-y-2 space-y-reverse sm:space-y-0 sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
interface DropdownMenuContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | undefined>(undefined);
|
||||
|
||||
function useDropdownMenu() {
|
||||
const context = React.useContext(DropdownMenuContext);
|
||||
if (!context) {
|
||||
throw new Error('useDropdownMenu must be used within a DropdownMenu');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<DropdownMenuContext.Provider value={{ open, setOpen, triggerRef }}>
|
||||
<div className="relative inline-block text-left">{children}</div>
|
||||
</DropdownMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ onClick, ...props }, ref) => {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenu();
|
||||
|
||||
// Merge the forwarded ref with the context triggerRef
|
||||
React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={(e) => {
|
||||
setOpen(!open);
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
|
||||
|
||||
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
align?: 'start' | 'end' | 'center';
|
||||
}
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
|
||||
({ className, children, align = 'start', ...props }) => {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenu();
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
||||
|
||||
// Calculate position based on trigger element
|
||||
React.useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
if (open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
|
||||
let left = rect.left + scrollX;
|
||||
const top = rect.bottom + scrollY + 8; // 8px gap (mt-2)
|
||||
|
||||
// Adjust horizontal alignment
|
||||
if (align === 'end') {
|
||||
left = rect.right + scrollX - 224; // 224px = w-56
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
if (open) {
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [open, align, triggerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const isClickOnTrigger = triggerRef.current?.contains(event.target as Node);
|
||||
const isClickOnContent = contentRef.current?.contains(event.target as Node);
|
||||
|
||||
if (!isClickOnContent && !isClickOnTrigger) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [open, setOpen, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
backgroundColor: 'var(--popover)',
|
||||
position: 'fixed',
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
className={cn(
|
||||
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
);
|
||||
DropdownMenuContent.displayName = 'DropdownMenuContent';
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, onClick, ...props }, ref) => {
|
||||
const { setOpen } = useDropdownMenu();
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
onClick?.(e);
|
||||
setOpen(false);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
)
|
||||
);
|
||||
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {ChevronDown, Check} from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const SelectContext = React.createContext<{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const useSelectContext = () => {
|
||||
const context = React.useContext(SelectContext);
|
||||
if (!context) {
|
||||
throw new Error('Select components must be used within a Select');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState(value);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const displayValue = React.useMemo(() => {
|
||||
const currentValue = value ?? internalValue;
|
||||
if (!currentValue) return placeholder;
|
||||
|
||||
// Extract label from children
|
||||
const options = React.Children.toArray(children);
|
||||
const selectedOption = options.find((child) => {
|
||||
if (React.isValidElement<SelectOptionProps>(child) && child.type === SelectOption) {
|
||||
return child.props.value === currentValue;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (React.isValidElement<SelectOptionProps>(selectedOption)) {
|
||||
return selectedOption.props.children;
|
||||
}
|
||||
|
||||
return currentValue;
|
||||
}, [value, internalValue, children, placeholder]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue);
|
||||
onChange?.(newValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={{ value: value ?? internalValue, onChange: handleChange, open, setOpen }}>
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground',
|
||||
'flex items-center justify-between',
|
||||
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!internalValue && !value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
onClick={() => !disabled && setOpen(!open)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
<span className="truncate">{displayValue}</span>
|
||||
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
|
||||
style={{ backgroundColor: 'var(--popover)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export interface SelectOptionProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SelectOption = React.forwardRef<HTMLDivElement, SelectOptionProps>(
|
||||
({ className, children, value: optionValue, disabled, ...props }, ref) => {
|
||||
const { value, onChange } = useSelectContext();
|
||||
const isSelected = value === optionValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex items-center w-full px-3 py-2 text-sm cursor-pointer select-none bg-transparent',
|
||||
'transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
onChange?.(optionValue);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span className="flex-1">{children}</span>
|
||||
{isSelected && <Check className="h-4 w-4 ml-2" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SelectOption.displayName = 'SelectOption';
|
||||
|
||||
export { Select, SelectOption };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined);
|
||||
|
||||
function useTabs() {
|
||||
const context = React.useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('useTabs must be used within a Tabs');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Tabs: React.FC<TabsProps> = ({
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue || '');
|
||||
const value = controlledValue !== undefined ? controlledValue : internalValue;
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (controlledValue === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onValueChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<div className={className}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TabsList.displayName = 'TabsList';
|
||||
|
||||
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||
({ className, value: triggerValue, onClick, ...props }, ref) => {
|
||||
const { value, onValueChange } = useTabs();
|
||||
const isActive = value === triggerValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-accent hover:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
onValueChange(triggerValue);
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
TabsTrigger.displayName = 'TabsTrigger';
|
||||
|
||||
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||
({ className, value: contentValue, ...props }, ref) => {
|
||||
const { value } = useTabs();
|
||||
if (value !== contentValue) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
TabsContent.displayName = 'TabsContent';
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-neutral-200 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-50 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useDashboardData } from './useApi';
|
||||
export { useBuckets } from './useBuckets';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bucketsApi, objectsApi, accessApi, garageApi, analyticsApi } from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ===========================
|
||||
// Bucket Hooks
|
||||
// ===========================
|
||||
|
||||
export function useBuckets() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.buckets.list(),
|
||||
queryFn: () => bucketsApi.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBucket(name: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.buckets.detail(name),
|
||||
queryFn: () => bucketsApi.get(name),
|
||||
enabled: enabled && !!name,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBucket() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, region }: { name: string; region?: string }) =>
|
||||
bucketsApi.create(name, region),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('Bucket created successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBucket() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => bucketsApi.delete(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('Bucket deleted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrantBucketPermission() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucketName, accessKeyId, permissions }: {
|
||||
bucketName: string;
|
||||
accessKeyId: string;
|
||||
permissions: { read: boolean; write: boolean; owner: boolean };
|
||||
}) => bucketsApi.grantPermission(bucketName, accessKeyId, permissions),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
toast.success('Permissions granted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Object Hooks
|
||||
// ===========================
|
||||
|
||||
export function useObjects(bucket: string, prefix?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.objects.list(bucket, prefix),
|
||||
queryFn: () => objectsApi.list(bucket, prefix),
|
||||
enabled: enabled && !!bucket,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadObject() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, key, file }: { bucket: string; key: string; file: File }) =>
|
||||
objectsApi.upload(bucket, key, file),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('File uploaded successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadMultipleObjects() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, files }: { bucket: string; files: File[] }) =>
|
||||
objectsApi.uploadMultiple(bucket, files),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('Files uploaded successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteObject() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, key }: { bucket: string; key: string }) =>
|
||||
objectsApi.delete(bucket, key),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('File deleted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMultipleObjects() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, keys, prefix }: { bucket: string; keys: string[]; prefix?: string }) =>
|
||||
objectsApi.deleteMultiple(bucket, keys, prefix),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success(`${variables.keys.length} files deleted successfully`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Access Key Hooks
|
||||
// ===========================
|
||||
|
||||
export function useAccessKeys() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accessKeys.list(),
|
||||
queryFn: () => accessApi.listKeys(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccessKey(keyId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accessKeys.detail(keyId),
|
||||
queryFn: () => accessApi.getKey(keyId),
|
||||
enabled: enabled && !!keyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, permissions }: { name: string; permissions?: any[] }) =>
|
||||
accessApi.createKey(name, permissions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
toast.success('Access key created successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (keyId: string) => accessApi.deleteKey(keyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
toast.success('Access key deleted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ keyId, updates }: { keyId: string; updates: any }) =>
|
||||
accessApi.updateKey(keyId, updates),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.detail(variables.keyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.list() });
|
||||
toast.success('Access key updated successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Cluster Hooks
|
||||
// ===========================
|
||||
|
||||
export function useClusterHealth() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cluster.health(),
|
||||
queryFn: () => garageApi.getClusterHealth(),
|
||||
staleTime: 30 * 1000, // Refresh health every 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useClusterStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cluster.status(),
|
||||
queryFn: () => garageApi.getClusterStatus(),
|
||||
staleTime: 60 * 1000, // Refresh status every minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useClusterStatistics() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cluster.statistics(),
|
||||
queryFn: () => garageApi.getClusterStatistics(),
|
||||
staleTime: 60 * 1000, // Refresh statistics every minute
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dashboard Hooks
|
||||
// ===========================
|
||||
|
||||
export function useDashboardMetrics() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.metrics(),
|
||||
queryFn: () => analyticsApi.getMetrics(),
|
||||
staleTime: 2 * 60 * 1000, // Refresh dashboard every 2 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Combined hook for dashboard data
|
||||
export function useDashboardData() {
|
||||
const metrics = useDashboardMetrics();
|
||||
const buckets = useBuckets();
|
||||
const health = useClusterHealth();
|
||||
|
||||
return {
|
||||
metrics,
|
||||
buckets,
|
||||
health,
|
||||
isLoading: metrics.isLoading || buckets.isLoading || health.isLoading,
|
||||
isError: metrics.isError || buckets.isError || health.isError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setIsRefreshing(true);
|
||||
} else if (isNav) {
|
||||
setIsNavigating(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const response = await objectsApi.list(bucketName, currentPath, itemsPerPage, continuationToken);
|
||||
setObjects(response.objects);
|
||||
setIsTruncated(response.isTruncated);
|
||||
setNextContinuationToken(response.nextContinuationToken);
|
||||
setCurrentContinuationToken(continuationToken);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch objects:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsNavigating(false);
|
||||
}
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Detect if this is a path change (navigation) or initial load
|
||||
const isPathChange = previousPath !== currentPath && objects.length > 0;
|
||||
setPreviousPath(currentPath);
|
||||
|
||||
// Use navigation mode if it's a path change, otherwise use normal loading
|
||||
fetchObjects(undefined, false, isPathChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
|
||||
const uploadFiles = useCallback(async (files: File[]) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
folders.add(parts[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
// Use webkitRelativePath if available (for folder uploads), otherwise use file.name
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
await objectsApi.upload(bucketName, key, file);
|
||||
}
|
||||
|
||||
if (hasRelativePaths && folders.size > 0) {
|
||||
const folderNames = Array.from(folders).join(', ');
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
||||
} else {
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteObject = useCallback(async (key: string) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the object from the UI
|
||||
setObjects(prev => prev.filter(obj => obj.key !== key));
|
||||
|
||||
await objectsApi.delete(bucketName, key);
|
||||
toast.success(`Object "${key}" deleted successfully`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete object error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteMultipleObjects = useCallback(async (keys: string[]) => {
|
||||
if (!bucketName || keys.length === 0) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the objects from the UI
|
||||
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
|
||||
|
||||
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
|
||||
toast.success(`Successfully deleted ${keys.length} file${keys.length > 1 ? 's' : ''}`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Bulk delete error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const createDirectory = useCallback(async (dirName: string) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
|
||||
await objectsApi.upload(bucketName, dirKey, new File([], ''));
|
||||
toast.success(`Directory "${dirName}" created successfully`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Create directory error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
return {
|
||||
objects,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
error,
|
||||
isTruncated,
|
||||
nextContinuationToken,
|
||||
currentContinuationToken,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
fetchObjects,
|
||||
uploadFiles,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBuckets() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchBuckets = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch buckets:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuckets();
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const createBucket = useCallback(async (name: string, region?: string) => {
|
||||
try {
|
||||
await bucketsApi.create(name, region);
|
||||
toast.success(`Bucket "${name}" created successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Create bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const deleteBucket = useCallback(async (name: string) => {
|
||||
try {
|
||||
await bucketsApi.delete(name);
|
||||
toast.success(`Bucket "${name}" deleted successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const grantPermission = useCallback(async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await bucketsApi.grantPermission(bucketName, accessKeyId, permissions);
|
||||
toast.success('Permissions granted successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Grant permission error:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
buckets,
|
||||
isLoading,
|
||||
error,
|
||||
fetchBuckets,
|
||||
createBucket,
|
||||
deleteBucket,
|
||||
grantPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light Theme Colors */
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0f;
|
||||
--popover: #fafafa;
|
||||
--popover-foreground: #0a0a0f;
|
||||
--primary: #ff9447;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #18181b;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--accent: #f4f4f5;
|
||||
--accent-foreground: #18181b;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--ring: #ff9447;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Grafana Chart Colors */
|
||||
--chart-blue: #1f77b4;
|
||||
--chart-orange: #ff7f0e;
|
||||
--chart-green: #2ca02c;
|
||||
--chart-red: #d62728;
|
||||
--chart-purple: #9467bd;
|
||||
--chart-brown: #8c564b;
|
||||
--chart-pink: #e377c2;
|
||||
--chart-gray: #7f7f7f;
|
||||
--chart-olive: #bcbd22;
|
||||
--chart-cyan: #17becf;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark Theme Colors - VS Code Inspired */
|
||||
--background: #1a1d29;
|
||||
--foreground: #e8eaed;
|
||||
--card: #252834;
|
||||
--card-foreground: #e8eaed;
|
||||
--popover: #2d3142;
|
||||
--popover-foreground: #e8eaed;
|
||||
--primary: #ff9447;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #3a3f52;
|
||||
--secondary-foreground: #e8eaed;
|
||||
--muted: #4a5064;
|
||||
--muted-foreground: #a0a4b8;
|
||||
--accent: #3a3f52;
|
||||
--accent-foreground: #e8eaed;
|
||||
--destructive: #ef5350;
|
||||
--destructive-foreground: #e8eaed;
|
||||
--border: #3a3f52;
|
||||
--input: #3a3f52;
|
||||
--ring: #ff9447;
|
||||
|
||||
/* Grafana Chart Colors */
|
||||
--chart-blue: #3eb0ff;
|
||||
--chart-orange: #ff9830;
|
||||
--chart-green: #73bf69;
|
||||
--chart-red: #f2495c;
|
||||
--chart-purple: #b581d8;
|
||||
--chart-brown: #c4a2e0;
|
||||
--chart-pink: #ff9d96;
|
||||
--chart-gray: #9ba3af;
|
||||
--chart-olive: #fac858;
|
||||
--chart-cyan: #37b7c3;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--muted-foreground) 20%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import axios from 'axios';
|
||||
import {toast} from 'sonner';
|
||||
import type {
|
||||
AccessKey,
|
||||
Bucket,
|
||||
BucketDetails,
|
||||
ClusterHealth,
|
||||
ClusterStatistics,
|
||||
ClusterStatus,
|
||||
GarageMetrics,
|
||||
MultiNodeResponse,
|
||||
MultiNodeStatisticsResponse,
|
||||
ObjectListResponse,
|
||||
ObjectMetadata,
|
||||
S3Object,
|
||||
StorageMetrics,
|
||||
} from '@/types';
|
||||
|
||||
// Configure axios instance with base URL
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for authentication
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Add response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// If response has success=false in data, treat it as an error
|
||||
if (response.data && response.data.success === false && response.data.error) {
|
||||
const error = response.data.error;
|
||||
const errorMessage = error.message || 'An error occurred';
|
||||
const errorCode = error.code || 'UNKNOWN_ERROR';
|
||||
|
||||
// Display toast with error details
|
||||
toast.error(errorMessage, {
|
||||
description: `Error Code: ${errorCode}`,
|
||||
});
|
||||
|
||||
// Reject the promise so it's treated as an error
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Handle axios errors
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
const data = error.response.data;
|
||||
|
||||
if (data && data.error) {
|
||||
const errorMessage = data.error.message || 'An error occurred';
|
||||
const errorCode = data.error.code || 'UNKNOWN_ERROR';
|
||||
|
||||
toast.error(errorMessage, {
|
||||
description: `Error Code: ${errorCode}`,
|
||||
});
|
||||
} else {
|
||||
// Generic HTTP error
|
||||
toast.error(`Request failed: ${error.response.status}`, {
|
||||
description: error.response.statusText || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} else if (error.request) {
|
||||
// Request made but no response received
|
||||
toast.error('Network Error', {
|
||||
description: 'Unable to reach the server. Please check your connection.',
|
||||
});
|
||||
} else {
|
||||
// Something else happened
|
||||
toast.error('Error', {
|
||||
description: error.message || 'An unexpected error occurred',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Bucket API
|
||||
export const bucketsApi = {
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
const response = await api.get('/v1/buckets');
|
||||
return response.data.data.buckets || [];
|
||||
},
|
||||
|
||||
get: async (name: string): Promise<BucketDetails> => {
|
||||
const response = await api.get(`/v1/buckets/${name}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (bucketName: string, bucketRegion?: string): Promise<void> => {
|
||||
await api.post('/v1/buckets', { name: bucketName, region: bucketRegion });
|
||||
},
|
||||
|
||||
delete: async (name: string): Promise<void> => {
|
||||
await api.delete(`/v1/buckets/${name}`);
|
||||
},
|
||||
|
||||
grantPermission: async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
): Promise<void> => {
|
||||
await api.post(`/v1/buckets/${bucketName}/permissions`, {
|
||||
accessKeyId,
|
||||
permissions,
|
||||
});
|
||||
},
|
||||
|
||||
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
await api.patch(`/v1/buckets/${name}/settings`, settings);
|
||||
},
|
||||
};
|
||||
|
||||
// Objects API
|
||||
export const objectsApi = {
|
||||
list: async (bucket: string, prefix?: string, maxKeys?: number, continuationToken?: string): Promise<ObjectListResponse> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = {};
|
||||
if (prefix) params.prefix = prefix;
|
||||
if (maxKeys) params.max_keys = maxKeys;
|
||||
if (continuationToken) params.continuation_token = continuationToken;
|
||||
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
|
||||
const data = response.data.data;
|
||||
|
||||
// Combine objects and prefixes (folders)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const objects: S3Object[] = data.objects?.map((obj: any) => ({
|
||||
key: obj.key,
|
||||
size: obj.size,
|
||||
lastModified: obj.last_modified,
|
||||
etag: obj.etag,
|
||||
storageClass: obj.storage_class,
|
||||
isFolder: false,
|
||||
})) || [];
|
||||
|
||||
const folders: S3Object[] = data.prefixes?.map((prefix: string) => ({
|
||||
key: prefix,
|
||||
size: 0,
|
||||
lastModified: null,
|
||||
isFolder: true,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
bucket: data.bucket,
|
||||
objects: [...folders, ...objects],
|
||||
prefixes: data.prefixes || [],
|
||||
count: data.count,
|
||||
isTruncated: data.is_truncated || false,
|
||||
nextContinuationToken: data.next_continuation_token,
|
||||
};
|
||||
},
|
||||
|
||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
|
||||
const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
upload: async (bucket: string, key: string, file: File): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('key', key);
|
||||
await api.post(`/v1/buckets/${bucket}/objects`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
|
||||
uploadStream: async (bucket: string, key: string, data: Blob | File, contentType?: string): Promise<void> => {
|
||||
await api.put(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, data, {
|
||||
headers: { 'Content-Type': contentType || 'application/octet-stream' },
|
||||
});
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadMultiple: async (bucket: string, files: File[]): Promise<any> => {
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
const response = await api.post(`/v1/buckets/${bucket}/objects/upload-multiple`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (bucket: string, key: string): Promise<void> => {
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
},
|
||||
|
||||
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
|
||||
const payload = { keys, ...(prefix && { prefix }) };
|
||||
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
|
||||
},
|
||||
|
||||
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
|
||||
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
|
||||
params: { expires_in: expiresIn }
|
||||
});
|
||||
return response.data.data.url;
|
||||
},
|
||||
};
|
||||
|
||||
// Access Control API (Users/Keys)
|
||||
export const accessApi = {
|
||||
listKeys: async (): Promise<AccessKey[]> => {
|
||||
const response = await api.get('/v1/users');
|
||||
return response.data.data.users || [];
|
||||
},
|
||||
|
||||
getKey: async (accessKey: string): Promise<AccessKey> => {
|
||||
const response = await api.get(`/v1/users/${accessKey}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createKey: async (name: string, permissions?: any[]): Promise<AccessKey> => {
|
||||
const response = await api.post('/v1/users', { name, permissions });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateKey: async (accessKey: string, updates: any): Promise<void> => {
|
||||
await api.patch(`/v1/users/${accessKey}`, updates);
|
||||
},
|
||||
|
||||
deleteKey: async (accessKey: string): Promise<void> => {
|
||||
await api.delete(`/v1/users/${accessKey}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Analytics API
|
||||
export const analyticsApi = {
|
||||
getMetrics: async (): Promise<StorageMetrics> => {
|
||||
const response = await api.get('/v1/monitoring/dashboard');
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Garage Cluster & Monitoring API
|
||||
export const garageApi = {
|
||||
getClusterHealth: async (): Promise<ClusterHealth> => {
|
||||
const response = await api.get('/v1/cluster/health');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getClusterStatus: async (): Promise<ClusterStatus> => {
|
||||
const response = await api.get('/v1/cluster/status');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getClusterStatistics: async (): Promise<ClusterStatistics> => {
|
||||
const response = await api.get('/v1/cluster/statistics');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getNodeInfo: async (nodeId: string = 'self'): Promise<MultiNodeResponse> => {
|
||||
const response = await api.get(`/v1/cluster/nodes/${nodeId}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getNodeStatistics: async (nodeId: string): Promise<MultiNodeStatisticsResponse> => {
|
||||
const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getFullMetrics: async (): Promise<GarageMetrics> => {
|
||||
// Fetch all cluster-related metrics
|
||||
const [health, statistics, storageMetrics] = await Promise.all([
|
||||
garageApi.getClusterHealth(),
|
||||
garageApi.getClusterStatistics(),
|
||||
analyticsApi.getMetrics(),
|
||||
]);
|
||||
|
||||
return {
|
||||
...storageMetrics,
|
||||
clusterHealth: health,
|
||||
clusterStatistics: statistics,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Monitoring API
|
||||
export const monitoringApi = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getMetrics: async (): Promise<any> => {
|
||||
const response = await api.get('/v1/monitoring/metrics');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
checkAdminHealth: async (): Promise<any> => {
|
||||
const response = await api.get('/v1/monitoring/admin-health');
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,75 @@
|
||||
// Grafana-inspired color palette for charts
|
||||
export const grafanaColors = {
|
||||
light: {
|
||||
blue: '#1f77b4',
|
||||
orange: '#ff7f0e',
|
||||
green: '#2ca02c',
|
||||
red: '#d62728',
|
||||
purple: '#9467bd',
|
||||
brown: '#8c564b',
|
||||
pink: '#e377c2',
|
||||
gray: '#7f7f7f',
|
||||
olive: '#bcbd22',
|
||||
cyan: '#17becf',
|
||||
},
|
||||
dark: {
|
||||
blue: '#3eb0ff',
|
||||
orange: '#ff9830',
|
||||
green: '#73bf69',
|
||||
red: '#f2495c',
|
||||
purple: '#b581d8',
|
||||
brown: '#c4a2e0',
|
||||
pink: '#ff9d96',
|
||||
gray: '#9ba3af',
|
||||
olive: '#fac858',
|
||||
cyan: '#37b7c3',
|
||||
},
|
||||
};
|
||||
|
||||
export const chartColorPalette = {
|
||||
light: [
|
||||
'#1f77b4', // Blue
|
||||
'#ff7f0e', // Orange
|
||||
'#2ca02c', // Green
|
||||
'#d62728', // Red
|
||||
'#9467bd', // Purple
|
||||
'#8c564b', // Brown
|
||||
'#e377c2', // Pink
|
||||
'#7f7f7f', // Gray
|
||||
'#bcbd22', // Olive
|
||||
'#17becf', // Cyan
|
||||
],
|
||||
dark: [
|
||||
'#3eb0ff', // Bright Blue
|
||||
'#ff9830', // Orange
|
||||
'#73bf69', // Green
|
||||
'#f2495c', // Red
|
||||
'#b581d8', // Purple
|
||||
'#c4a2e0', // Pink
|
||||
'#ff9d96', // Light Pink
|
||||
'#9ba3af', // Gray
|
||||
'#fac858', // Yellow
|
||||
'#37b7c3', // Cyan
|
||||
],
|
||||
};
|
||||
|
||||
export const getChartColors = (isDark: boolean = false) => {
|
||||
return isDark ? chartColorPalette.dark : chartColorPalette.light;
|
||||
};
|
||||
|
||||
export const getTextColor = (isDark: boolean = false) => {
|
||||
return isDark ? '#e0e0e0' : '#333333';
|
||||
};
|
||||
|
||||
export const getGridColor = (isDark: boolean = false) => {
|
||||
return isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
};
|
||||
|
||||
export const getTooltipStyle = (isDark: boolean = false) => {
|
||||
return {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
|
||||
borderRadius: '6px',
|
||||
color: isDark ? '#e0e0e0' : '#333333',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Get the file type based on file extension
|
||||
*/
|
||||
export function getFileType(filename: string): string {
|
||||
if (!filename) return 'Unknown';
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (!extension) return 'File';
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
// Images
|
||||
'png': 'Image',
|
||||
'jpg': 'Image',
|
||||
'jpeg': 'Image',
|
||||
'gif': 'Image',
|
||||
'svg': 'Image',
|
||||
'webp': 'Image',
|
||||
|
||||
// Documents
|
||||
'pdf': 'PDF',
|
||||
'doc': 'Document',
|
||||
'docx': 'Document',
|
||||
'xls': 'Spreadsheet',
|
||||
'xlsx': 'Spreadsheet',
|
||||
'ppt': 'Presentation',
|
||||
'pptx': 'Presentation',
|
||||
'txt': 'Text',
|
||||
|
||||
// Archives
|
||||
'zip': 'Archive',
|
||||
'rar': 'Archive',
|
||||
'gz': 'Archive',
|
||||
'tar': 'Archive',
|
||||
|
||||
// Video/Audio
|
||||
'mp4': 'Video',
|
||||
'avi': 'Video',
|
||||
'mov': 'Video',
|
||||
'mkv': 'Video',
|
||||
'webm': 'Video',
|
||||
'mp3': 'Audio',
|
||||
'wav': 'Audio',
|
||||
'flac': 'Audio',
|
||||
|
||||
// Code
|
||||
'js': 'JavaScript',
|
||||
'ts': 'TypeScript',
|
||||
'tsx': 'TypeScript',
|
||||
'jsx': 'JavaScript',
|
||||
'py': 'Python',
|
||||
'java': 'Java',
|
||||
'cpp': 'C++',
|
||||
'c': 'C',
|
||||
'html': 'HTML',
|
||||
'css': 'CSS',
|
||||
'json': 'JSON',
|
||||
'xml': 'XML',
|
||||
'sql': 'SQL',
|
||||
|
||||
// Data
|
||||
'csv': 'CSV',
|
||||
};
|
||||
|
||||
return typeMap[extension] || extension.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate breadcrumbs from a file path
|
||||
*/
|
||||
export function getBreadcrumbs(currentPath: string): Array<{ label: string; path: string }> {
|
||||
if (!currentPath) return [{ label: 'Root', path: '' }];
|
||||
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const breadcrumbs = [{ label: 'Root', path: '' }];
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const path = parts.slice(0, index + 1).join('/') + '/';
|
||||
breadcrumbs.push({ label: part, path });
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time from a date
|
||||
*/
|
||||
export function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`;
|
||||
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
// Create a query client with default options
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Cache data for 10 minutes (formerly cacheTime)
|
||||
retry: 1, // Retry failed requests once
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
refetchOnMount: false, // Don't refetch on component mount if data exists
|
||||
placeholderData: (previousData) => previousData, // Keep previous data while fetching new data
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Query keys for consistent cache management
|
||||
export const queryKeys = {
|
||||
buckets: {
|
||||
all: ['buckets'] as const,
|
||||
list: () => [...queryKeys.buckets.all, 'list'] as const,
|
||||
detail: (name: string) => [...queryKeys.buckets.all, 'detail', name] as const,
|
||||
},
|
||||
objects: {
|
||||
all: ['objects'] as const,
|
||||
list: (bucket: string, prefix?: string) => [...queryKeys.objects.all, 'list', bucket, prefix] as const,
|
||||
},
|
||||
accessKeys: {
|
||||
all: ['accessKeys'] as const,
|
||||
list: () => [...queryKeys.accessKeys.all, 'list'] as const,
|
||||
detail: (keyId: string) => [...queryKeys.accessKeys.all, 'detail', keyId] as const,
|
||||
},
|
||||
cluster: {
|
||||
all: ['cluster'] as const,
|
||||
health: () => [...queryKeys.cluster.all, 'health'] as const,
|
||||
status: () => [...queryKeys.cluster.all, 'status'] as const,
|
||||
statistics: () => [...queryKeys.cluster.all, 'statistics'] as const,
|
||||
},
|
||||
dashboard: {
|
||||
all: ['dashboard'] as const,
|
||||
metrics: () => [...queryKeys.dashboard.all, 'metrics'] as const,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function truncateString(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return `${str.slice(0, maxLength)}...`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,947 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {accessApi, bucketsApi} from '@/lib/api';
|
||||
import {formatDate} from '@/lib/utils';
|
||||
import type {AccessKey, Bucket, BucketPermission} from '@/types';
|
||||
import {Copy, Edit, Key, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
export function AccessControl() {
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<AccessKey | null>(null);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
|
||||
// Create key with permissions state
|
||||
const [createAvailableBuckets, setCreateAvailableBuckets] = useState<Bucket[]>([]);
|
||||
const [createSelectedBucket, setCreateSelectedBucket] = useState<string>('');
|
||||
const [createPermissionRead, setCreatePermissionRead] = useState(false);
|
||||
const [createPermissionWrite, setCreatePermissionWrite] = useState(false);
|
||||
const [createPermissionOwner, setCreatePermissionOwner] = useState(false);
|
||||
const [createGrantPermissions, setCreateGrantPermissions] = useState(false);
|
||||
|
||||
// Edit permissions state
|
||||
const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<AccessKey | null>(null);
|
||||
const [availableBuckets, setAvailableBuckets] = useState<Bucket[]>([]);
|
||||
const [selectedBucket, setSelectedBucket] = useState<string>('');
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
|
||||
// Key settings state (activation/expiration)
|
||||
// const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
// const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
// const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
// const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
// const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
setFilteredKeys(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch keys:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchKeys();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = keys.filter(
|
||||
(key) =>
|
||||
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredKeys(filtered);
|
||||
}, [searchQuery, keys]);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName) {
|
||||
toast.error('Please enter a key name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newKey = await accessApi.createKey(newKeyName);
|
||||
|
||||
// If user wants to grant permissions inline
|
||||
if (createGrantPermissions && createSelectedBucket) {
|
||||
if (createPermissionRead || createPermissionWrite || createPermissionOwner) {
|
||||
try {
|
||||
await bucketsApi.grantPermission(createSelectedBucket, newKey.accessKeyId, {
|
||||
read: createPermissionRead,
|
||||
write: createPermissionWrite,
|
||||
owner: createPermissionOwner,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to grant permissions:', error);
|
||||
// Continue even if permission grant fails - key is already created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
setCreateGrantPermissions(false);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
toast.success(`API Key "${newKeyName}" created successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Create key error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreateDialog = async () => {
|
||||
setCreateDialogOpen(true);
|
||||
setNewKeyName('');
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
setCreateGrantPermissions(false);
|
||||
|
||||
// Load available buckets
|
||||
try {
|
||||
const buckets = await bucketsApi.list();
|
||||
setCreateAvailableBuckets(buckets);
|
||||
} catch (error) {
|
||||
console.error('Failed to load buckets:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
if (!selectedKey) return;
|
||||
|
||||
try {
|
||||
await accessApi.deleteKey(selectedKey.accessKeyId);
|
||||
const keyName = selectedKey.name;
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedKey(null);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
toast.success(`API Key "${keyName}" deleted successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Delete key error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSettings = (key: AccessKey) => {
|
||||
setSettingsKey(key);
|
||||
setKeyStatus(key.status);
|
||||
setSettingsDialogOpen(true);
|
||||
|
||||
// Set expiration date if it exists
|
||||
if (key.expiration) {
|
||||
const expDate = new Date(key.expiration);
|
||||
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
||||
const formattedDate = expDate.toISOString().slice(0, 16);
|
||||
setExpirationDate(formattedDate);
|
||||
setNeverExpires(false);
|
||||
} else {
|
||||
setExpirationDate('');
|
||||
setNeverExpires(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKeySettings = async () => {
|
||||
if (!settingsKey) return;
|
||||
|
||||
try {
|
||||
const updates: { status?: string; expiration?: string } = {};
|
||||
|
||||
// Add status change
|
||||
updates.status = keyStatus;
|
||||
|
||||
// Add expiration if set and not "never expires"
|
||||
if (!neverExpires && expirationDate) {
|
||||
updates.expiration = new Date(expirationDate).toISOString();
|
||||
} else if (neverExpires) {
|
||||
// Clear expiration by setting status to active
|
||||
updates.status = 'active';
|
||||
}
|
||||
|
||||
await accessApi.updateKey(settingsKey.accessKeyId, updates);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
|
||||
setSettingsDialogOpen(false);
|
||||
toast.success(`Key settings updated successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Update key settings error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditPermissions = async (key: AccessKey) => {
|
||||
setEditingKey(key);
|
||||
setEditPermissionsDialogOpen(true);
|
||||
setSelectedBucket('');
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
|
||||
// Load available buckets
|
||||
try {
|
||||
const buckets = await bucketsApi.list();
|
||||
setAvailableBuckets(buckets);
|
||||
} catch (error) {
|
||||
console.error('Failed to load buckets:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBucketChange = (bucketName: string) => {
|
||||
setSelectedBucket(bucketName);
|
||||
|
||||
if (!bucketName || !editingKey) {
|
||||
// Reset permissions if no bucket selected
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find if this key already has permissions on the selected bucket
|
||||
const bucketPermission = editingKey.permissions.find(
|
||||
perm => perm.bucketName === bucketName || perm.bucketId === bucketName
|
||||
);
|
||||
|
||||
if (bucketPermission) {
|
||||
// Set the checkboxes to reflect current permissions
|
||||
setPermissionRead(bucketPermission.read);
|
||||
setPermissionWrite(bucketPermission.write);
|
||||
setPermissionOwner(bucketPermission.owner);
|
||||
} else {
|
||||
// No permissions set yet, reset checkboxes
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGrantBucketPermission = async () => {
|
||||
if (!editingKey || !selectedBucket) {
|
||||
toast.error('Please select a bucket');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permissionRead && !permissionWrite && !permissionOwner) {
|
||||
toast.error('Please select at least one permission');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend API to grant bucket permissions
|
||||
await bucketsApi.grantPermission(selectedBucket, editingKey.accessKeyId, {
|
||||
read: permissionRead,
|
||||
write: permissionWrite,
|
||||
owner: permissionOwner,
|
||||
});
|
||||
|
||||
toast.success(`Permissions granted on bucket "${selectedBucket}" successfully`);
|
||||
setEditPermissionsDialogOpen(false);
|
||||
setSelectedBucket('');
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
|
||||
// Refresh keys list to update permissions
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Grant permission error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format permission flags as a readable string
|
||||
const formatPermissions = (perm: BucketPermission): string => {
|
||||
const perms = [];
|
||||
if (perm.read) perms.push('Read');
|
||||
if (perm.write) perms.push('Write');
|
||||
if (perm.owner) perms.push('Owner');
|
||||
return perms.join(', ') || 'None';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
title="Access Control"
|
||||
/>
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
<Tabs defaultValue="keys">
|
||||
<TabsList className="w-full sm:w-auto">
|
||||
<TabsTrigger value="keys" className="flex-1 sm:flex-initial">API Keys</TabsTrigger>
|
||||
<TabsTrigger value="policies" className="flex-1 sm:flex-initial">Bucket Policies</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="keys" className="space-y-4 sm:space-y-6 mt-4 sm:mt-6">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{keys.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Keys</CardTitle>
|
||||
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{keys.filter((k) => k.status === 'active').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Inactive Keys</CardTitle>
|
||||
<ShieldX className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{keys.filter((k) => k.status === 'inactive').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search keys..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreateDialog} className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Keys Table */}
|
||||
<div className="border rounded-lg overflow-visible">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Last Used</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Permissions</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading API keys...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableRow key={key.accessKeyId}>
|
||||
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block">
|
||||
{key.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={key.status === 'active' ? 'default' : 'secondary'}>
|
||||
{key.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.permissions.slice(0, 2).map((perm, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{perm.bucketName}: {formatPermissions(perm)}
|
||||
</Badge>
|
||||
))}
|
||||
{key.permissions.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{key.permissions.length - 2} more
|
||||
</Badge>
|
||||
)}
|
||||
{key.permissions.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">No permissions</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpenEditPermissions(key)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Permissions
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleOpenSettings(key)}>
|
||||
{key.status === 'active' ? (
|
||||
<>
|
||||
<ShieldX className="h-4 w-4" />
|
||||
Manage Status
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Manage Status
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setSelectedKey(key);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="policies" className="space-y-6 mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bucket Policies</CardTitle>
|
||||
<CardDescription>
|
||||
Manage resource-based policies for your buckets
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
Bucket policy editor coming soon...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Create Key Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key with optional bucket permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="My Application Key"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this API key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Optional: Grant permissions during creation */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="grant-permissions-on-create"
|
||||
checked={createGrantPermissions}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateGrantPermissions(checked as boolean);
|
||||
if (!checked) {
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">Grant bucket permissions now</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also grant permissions later from the Edit Permissions menu
|
||||
</p>
|
||||
|
||||
{createGrantPermissions && (
|
||||
<div className="space-y-4 pl-6 pt-2">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={createSelectedBucket}
|
||||
onChange={(value) => setCreateSelectedBucket(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
{createAvailableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
{createSelectedBucket && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-2 border rounded-lg p-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-read"
|
||||
checked={createPermissionRead}
|
||||
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Read</span>
|
||||
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-write"
|
||||
checked={createPermissionWrite}
|
||||
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Write</span>
|
||||
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-owner"
|
||||
checked={createPermissionOwner}
|
||||
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Owner</span>
|
||||
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Key Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedKey?.name}"? Applications using this key
|
||||
will lose access immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteKey}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Settings Dialog */}
|
||||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Key Settings - {settingsKey?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage activation status and expiration date for this API key
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Status */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={keyStatus === 'active'}
|
||||
onChange={(e) => setKeyStatus(e.target.value as 'active' | 'inactive')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="inactive"
|
||||
checked={keyStatus === 'inactive'}
|
||||
onChange={(e) => setKeyStatus(e.target.value as 'active' | 'inactive')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inactive keys cannot be used for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Expiration</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="never-expires"
|
||||
checked={neverExpires}
|
||||
onCheckedChange={(checked) => setNeverExpires(checked as boolean)}
|
||||
/>
|
||||
<span className="text-sm">Never expires</span>
|
||||
</label>
|
||||
|
||||
{!neverExpires && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Expiration Date & Time</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={expirationDate}
|
||||
onChange={(e) => setExpirationDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Key will automatically become inactive after this date
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Display */}
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Status:</span>
|
||||
<Badge variant={settingsKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{settingsKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{settingsKey?.expiration && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Expiration:</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(settingsKey.expiration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveKeySettings}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Bucket Permissions - {editingKey?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grant this access key permissions on buckets
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={selectedBucket}
|
||||
onChange={(value) => handleBucketChange(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
{availableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which bucket this key should have permissions on. Current permissions will be displayed when selected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-3 border rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="edit-permission-read"
|
||||
checked={permissionRead}
|
||||
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="edit-permission-read"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Read
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="edit-permission-write"
|
||||
checked={permissionWrite}
|
||||
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="edit-permission-write"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Write
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="edit-permission-owner"
|
||||
checked={permissionOwner}
|
||||
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="edit-permission-owner"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Owner
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Permissions Info */}
|
||||
{selectedBucket && editingKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Current Status</label>
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
{(() => {
|
||||
const bucketPermission = editingKey.permissions.find(
|
||||
perm => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket
|
||||
);
|
||||
|
||||
if (bucketPermission) {
|
||||
const hasPermissions = bucketPermission.read || bucketPermission.write || bucketPermission.owner;
|
||||
if (hasPermissions) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
This key currently has the following permissions on this bucket:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bucketPermission.read && (
|
||||
<Badge variant="secondary">Read</Badge>
|
||||
)}
|
||||
{bucketPermission.write && (
|
||||
<Badge variant="secondary">Write</Badge>
|
||||
)}
|
||||
{bucketPermission.owner && (
|
||||
<Badge variant="secondary">Owner</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Modify the checkboxes above to update permissions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This key has no permissions on this bucket yet. Select permissions above to grant access.
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Bucket Permissions List */}
|
||||
{editingKey && editingKey.permissions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Current Bucket Permissions</label>
|
||||
<div className="border rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{editingKey.permissions.map((perm, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm p-2 bg-muted/30 rounded">
|
||||
<span className="font-medium">{perm.bucketName}</span>
|
||||
<div className="flex gap-1">
|
||||
{perm.read && <Badge variant="outline" className="text-xs">R</Badge>}
|
||||
{perm.write && <Badge variant="outline" className="text-xs">W</Badge>}
|
||||
{perm.owner && <Badge variant="outline" className="text-xs">O</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditPermissionsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGrantBucketPermission}
|
||||
disabled={!selectedBucket}
|
||||
>
|
||||
Grant Permission
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { useBuckets } from '@/hooks/useBuckets';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
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 type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function Buckets() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Bucket state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false);
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsBucket, setSettingsBucket] = useState<Bucket | null>(null);
|
||||
|
||||
// Object browser state - initialize from URL params
|
||||
const [viewingBucket, setViewingBucket] = useState<string | null>(searchParams.get('bucket'));
|
||||
const [currentPath, setCurrentPath] = useState<string>(searchParams.get('prefix') || '');
|
||||
const [objectSearchQuery, setObjectSearchQuery] = useState('');
|
||||
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
|
||||
searchParams.get('page') || undefined
|
||||
);
|
||||
const [initialItemsPerPage, setInitialItemsPerPage] = useState<number>(
|
||||
parseInt(searchParams.get('limit') || '25', 10)
|
||||
);
|
||||
|
||||
// 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 { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets();
|
||||
const {
|
||||
objects,
|
||||
isLoading: objectsLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
isTruncated,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
uploadFiles,
|
||||
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<string, string> = { 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<string, string> = { 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<string, string> = { 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 handleRefreshObjects = async () => {
|
||||
if (isRefreshing) return;
|
||||
try {
|
||||
await fetchObjects(undefined, true);
|
||||
toast.success('Objects refreshed successfully');
|
||||
} catch (error) {
|
||||
console.error('Refresh error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the object browser view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
<ObjectBrowserView
|
||||
bucketName={viewingBucket}
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={objectSearchQuery}
|
||||
isLoading={objectsLoading}
|
||||
isTruncated={isTruncated}
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onSearchChange={setObjectSearchQuery}
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
onDeleteObject={deleteObject}
|
||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
||||
onCreateDirectory={createDirectory}
|
||||
onRefresh={handleRefreshObjects}
|
||||
onPageChange={handlePageChange}
|
||||
onItemsPerPageChange={handleItemsPerPageChange}
|
||||
isRefreshing={isRefreshing}
|
||||
isNavigating={isNavigating}
|
||||
initialPageToken={initialPageToken}
|
||||
initialItemsPerPage={initialItemsPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default view: show buckets list
|
||||
return (
|
||||
<div>
|
||||
<Header title="Buckets" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<BucketListView
|
||||
buckets={buckets}
|
||||
searchQuery={searchQuery}
|
||||
isLoading={bucketsLoading}
|
||||
onSearchChange={setSearchQuery}
|
||||
onViewBucket={handleViewBucket}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onCreateBucket={() => setCreateDialogOpen(true)}
|
||||
onDeleteBucket={(bucket) => {
|
||||
setSelectedBucket(bucket);
|
||||
setDeleteBucketDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateBucketDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreateBucket={createBucket}
|
||||
/>
|
||||
|
||||
<DeleteBucketDialog
|
||||
open={deleteBucketDialogOpen}
|
||||
onOpenChange={setDeleteBucketDialogOpen}
|
||||
bucket={selectedBucket}
|
||||
onDeleteBucket={deleteBucket}
|
||||
/>
|
||||
|
||||
<BucketSettingsDialog
|
||||
open={settingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
bucket={settingsBucket}
|
||||
onGrantPermission={grantPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {garageApi} from '@/lib/api';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import type {ClusterNode, LocalNodeInfo, NodeStatistics} from '@/types';
|
||||
import {useState} from 'react';
|
||||
|
||||
export function Cluster() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
const { data: health, isLoading: healthLoading } = useQuery({
|
||||
queryKey: ['cluster-health'],
|
||||
queryFn: () => garageApi.getClusterHealth(),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['cluster-status'],
|
||||
queryFn: () => garageApi.getClusterStatus(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: statistics, isLoading: statisticsLoading } = useQuery({
|
||||
queryKey: ['cluster-statistics'],
|
||||
queryFn: () => garageApi.getClusterStatistics(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: nodeInfo, isLoading: nodeInfoLoading } = useQuery({
|
||||
queryKey: ['node-info', selectedNodeId || '*'],
|
||||
queryFn: () => garageApi.getNodeInfo(selectedNodeId || '*'),
|
||||
enabled: !!selectedNodeId || selectedNodeId === null,
|
||||
});
|
||||
|
||||
const { data: nodeStats } = useQuery({
|
||||
queryKey: ['node-statistics', selectedNodeId || '*'],
|
||||
queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'),
|
||||
enabled: !!selectedNodeId,
|
||||
});
|
||||
|
||||
const isLoading = healthLoading || statusLoading || statisticsLoading;
|
||||
|
||||
const getHealthStatus = () => {
|
||||
if (!health) return { color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Unknown', icon: AlertCircle };
|
||||
if (
|
||||
health.storageNodesUp === health.storageNodes &&
|
||||
health.partitionsAllOk === health.partitions &&
|
||||
health.connectedNodes === health.knownNodes
|
||||
) {
|
||||
return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Healthy', icon: CheckCircle2 };
|
||||
}
|
||||
if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) {
|
||||
return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Degraded', icon: AlertCircle };
|
||||
}
|
||||
return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Unhealthy', icon: XCircle };
|
||||
};
|
||||
|
||||
const healthStatus = getHealthStatus();
|
||||
const HealthIcon = healthStatus.icon;
|
||||
|
||||
const getNodeStatus = (node: ClusterNode) => {
|
||||
if (!node.isUp) {
|
||||
return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Down', icon: XCircle };
|
||||
}
|
||||
if (node.draining) {
|
||||
return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Draining', icon: AlertCircle };
|
||||
}
|
||||
return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Up', icon: CheckCircle2 };
|
||||
};
|
||||
|
||||
const formatUptime = (seconds?: number) => {
|
||||
if (!seconds) return 'N/A';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Cluster" />
|
||||
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading cluster information...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Cluster Management" />
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Cluster Health Overview */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
|
||||
<HealthIcon className={`h-4 w-4 ${healthStatus.color}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${healthStatus.color}`}>{healthStatus.label}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Layout v{status?.layoutVersion || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Connected Nodes</CardTitle>
|
||||
<Network className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.connectedNodes || 0}/{health?.knownNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nodes online
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.storageNodesUp || 0}/{health?.storageNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy storage nodes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.partitionsAllOk || 0}/{health?.partitions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy partitions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs for different views */}
|
||||
<Tabs defaultValue="nodes" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="nodes">Nodes</TabsTrigger>
|
||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Nodes Tab */}
|
||||
<TabsContent value="nodes" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cluster Nodes</CardTitle>
|
||||
<CardDescription>
|
||||
Overview of all nodes in the Garage cluster
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{status?.nodes && status.nodes.length > 0 ? (
|
||||
status.nodes.map((node) => {
|
||||
const nodeStatus = getNodeStatus(node);
|
||||
const NodeIcon = nodeStatus.icon;
|
||||
const dataUsage = node.dataPartition
|
||||
? ((node.dataPartition.total - node.dataPartition.available) / node.dataPartition.total) * 100
|
||||
: 0;
|
||||
const metadataUsage = node.metadataPartition
|
||||
? ((node.metadataPartition.total - node.metadataPartition.available) / node.metadataPartition.total) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
selectedNodeId === node.id ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<NodeIcon className={`h-5 w-5 ${nodeStatus.color}`} />
|
||||
<div>
|
||||
<div className="font-mono text-sm font-medium">
|
||||
{node.id.substring(0, 16)}...
|
||||
</div>
|
||||
{node.hostname && (
|
||||
<div className="text-xs text-muted-foreground">{node.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Status</div>
|
||||
<Badge variant={node.isUp ? 'default' : 'destructive'} className="mt-1">
|
||||
{nodeStatus.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{node.addr && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Address</div>
|
||||
<div className="text-sm font-mono">{node.addr}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.garageVersion && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Version</div>
|
||||
<div className="text-sm">{node.garageVersion}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.role && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Zone</div>
|
||||
<div className="text-sm">{node.role.zone}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.role?.capacity && (
|
||||
<div className="pt-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Capacity: {formatBytes(node.role.capacity)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(node.dataPartition || node.metadataPartition) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||
{node.dataPartition && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Data Partition: {formatBytes(node.dataPartition.total - node.dataPartition.available)} / {formatBytes(node.dataPartition.total)}
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
dataUsage > 90 ? 'bg-red-500' : dataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${dataUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.metadataPartition && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Metadata Partition: {formatBytes(node.metadataPartition.total - node.metadataPartition.available)} / {formatBytes(node.metadataPartition.total)}
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
metadataUsage > 90 ? 'bg-red-500' : metadataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${metadataUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!node.isUp && node.lastSeenSecsAgo !== undefined && (
|
||||
<div className="text-xs text-muted-foreground pt-2">
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
Last seen: {node.lastSeenSecsAgo === null ? 'Never' : formatUptime(node.lastSeenSecsAgo) + ' ago'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Server className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No nodes found in the cluster</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Statistics Tab */}
|
||||
<TabsContent value="statistics" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cluster Statistics</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed statistics and metrics from the Garage cluster
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{statistics ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{statistics.freeform}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Activity className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No statistics available</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Information</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed information for node: {selectedNodeId.substring(0, 16)}...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nodeInfoLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading node info...</p>
|
||||
</div>
|
||||
) : nodeInfo ? (
|
||||
<div className="space-y-4">
|
||||
{/* Success responses */}
|
||||
{Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => (
|
||||
<div key={nodeId} className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<h4 className="font-medium">
|
||||
Node: {nodeId.substring(0, 16)}...
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Node ID</div>
|
||||
<div className="font-mono text-sm break-all">{(info as LocalNodeInfo).nodeId}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Garage Version</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).garageVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Rust Version</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).rustVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Database Engine</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).dbEngine}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">Garage Features</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(info as LocalNodeInfo).garageFeatures!.map((feature) => (
|
||||
<Badge key={feature} variant="secondary">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Error responses */}
|
||||
{Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => (
|
||||
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
|
||||
</div>
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Info className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No node information available</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{nodeStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Statistics</CardTitle>
|
||||
<CardDescription>
|
||||
Performance metrics for the selected node
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Success responses */}
|
||||
{Object.entries(nodeStats.success || {}).map(([nodeId, stats]) => (
|
||||
<div key={nodeId} className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
<h4 className="font-medium">
|
||||
Statistics for: {nodeId.substring(0, 16)}...
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{(stats as NodeStatistics).freeform}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Error responses */}
|
||||
{Object.entries(nodeStats.error || {}).map(([nodeId, error]) => (
|
||||
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
|
||||
</div>
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<Server className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">Select a Node</p>
|
||||
<p className="text-sm">
|
||||
Click on a node in the Nodes tab to view detailed information and statistics
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/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';
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Header title="Dashboard" />
|
||||
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Dashboard" />
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Top Stats Grid */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics ? formatBytes(metrics.totalSize) : '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across {metrics?.bucketCount || 0} buckets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Objects</CardTitle>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics?.objectCount.toLocaleString() || '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Files and folders
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Buckets</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics?.bucketCount || '---'}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active storage buckets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cluster Status */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-2xl font-bold ${healthStatus.color}`}>
|
||||
{healthStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy storage nodes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy partitions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{/* Bucket Usage Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage Usage by Bucket</CardTitle>
|
||||
<CardDescription>Distribution of storage across buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
<BucketUsageChart data={metrics.usageByBucket} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No data available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bucket Details Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
|
||||
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
metrics.usageByBucket.map((bucket) => (
|
||||
<div key={bucket.bucketName} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm flex-wrap gap-2">
|
||||
<span className="font-medium">{bucket.bucketName}</span>
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.objectCount.toLocaleString()} objects
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(bucket.size)}</span>
|
||||
<span className="text-muted-foreground w-12 text-right">
|
||||
{bucket.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${bucket.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Buckets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Buckets</CardTitle>
|
||||
<CardDescription>Your most recently created buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<div
|
||||
key={bucket.name}
|
||||
className="flex items-center justify-between py-3 border-b last:border-0 gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{bucket.name}</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-medium text-sm sm:text-base">{bucket.objectCount?.toLocaleString()} objects</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
{bucket.size ? formatBytes(bucket.size) : '---'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Bucket types
|
||||
export interface Bucket {
|
||||
name: string;
|
||||
creationDate: string;
|
||||
objectCount?: number;
|
||||
size?: number;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
versioning?: boolean;
|
||||
encryption?: boolean;
|
||||
publicAccess?: boolean;
|
||||
lifecycleRules?: LifecycleRule[];
|
||||
}
|
||||
|
||||
export interface LifecycleRule {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
prefix?: string;
|
||||
expirationDays?: number;
|
||||
transitions?: Transition[];
|
||||
}
|
||||
|
||||
export interface Transition {
|
||||
days: number;
|
||||
storageClass: string;
|
||||
}
|
||||
|
||||
// Object types
|
||||
export interface S3Object {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
etag?: string;
|
||||
storageClass?: string;
|
||||
isFolder?: boolean;
|
||||
}
|
||||
|
||||
export interface ObjectListResponse {
|
||||
bucket: string;
|
||||
objects: S3Object[];
|
||||
prefixes: string[];
|
||||
count: number;
|
||||
isTruncated: boolean;
|
||||
nextContinuationToken?: string;
|
||||
}
|
||||
|
||||
export interface ObjectMetadata {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
metadata?: Record<string, string>;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
// Access Control types
|
||||
export interface AccessKey {
|
||||
accessKeyId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: BucketPermission[];
|
||||
expiration?: string;
|
||||
}
|
||||
|
||||
export interface BucketPermission {
|
||||
bucketId: string;
|
||||
bucketName: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
owner: boolean;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
resource: string;
|
||||
actions: string[];
|
||||
effect: 'Allow' | 'Deny';
|
||||
}
|
||||
|
||||
export interface BucketPolicy {
|
||||
bucketName: string;
|
||||
policy: PolicyStatement[];
|
||||
}
|
||||
|
||||
export interface PolicyStatement {
|
||||
sid?: string;
|
||||
effect: 'Allow' | 'Deny';
|
||||
principal: string | string[];
|
||||
action: string | string[];
|
||||
resource: string | string[];
|
||||
condition?: Record<string, any>;
|
||||
}
|
||||
|
||||
// User types
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user' | 'readonly';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Storage Analytics types
|
||||
export interface StorageMetrics {
|
||||
totalSize: number;
|
||||
objectCount: number;
|
||||
bucketCount: number;
|
||||
usageByBucket: BucketUsage[];
|
||||
requestMetrics: RequestMetrics;
|
||||
}
|
||||
|
||||
export interface BucketUsage {
|
||||
bucketName: string;
|
||||
size: number;
|
||||
objectCount: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface RequestMetrics {
|
||||
getRequests: number;
|
||||
putRequests: number;
|
||||
deleteRequests: number;
|
||||
listRequests: number;
|
||||
period: string;
|
||||
}
|
||||
|
||||
// Upload types
|
||||
export interface UploadTask {
|
||||
id: string;
|
||||
file: File;
|
||||
key: string;
|
||||
bucket: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'completed' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Filter and Sort types
|
||||
export interface TableFilter {
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
pageSize?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
// Garage Cluster types
|
||||
export interface ClusterHealth {
|
||||
status: string;
|
||||
connectedNodes: number;
|
||||
knownNodes: number;
|
||||
storageNodes: number;
|
||||
storageNodesUp: number;
|
||||
partitions: number;
|
||||
partitionsQuorum: number;
|
||||
partitionsAllOk: number;
|
||||
}
|
||||
|
||||
export interface ClusterStatistics {
|
||||
timestamp: number;
|
||||
uptime: number;
|
||||
freeform: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ClusterStatus {
|
||||
layoutVersion: number;
|
||||
nodes: ClusterNode[];
|
||||
}
|
||||
|
||||
export interface ClusterNode {
|
||||
id: string;
|
||||
isUp: boolean;
|
||||
lastSeenSecsAgo?: number;
|
||||
hostname?: string;
|
||||
addr?: string;
|
||||
garageVersion?: string;
|
||||
role?: NodeRole;
|
||||
draining: boolean;
|
||||
dataPartition?: FreeSpaceInfo;
|
||||
metadataPartition?: FreeSpaceInfo;
|
||||
}
|
||||
|
||||
export interface NodeRole {
|
||||
zone: string;
|
||||
capacity?: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface FreeSpaceInfo {
|
||||
available: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LocalNodeInfo {
|
||||
nodeId: string;
|
||||
garageVersion: string;
|
||||
rustVersion: string;
|
||||
dbEngine: string;
|
||||
garageFeatures?: string[];
|
||||
}
|
||||
|
||||
export interface MultiNodeResponse {
|
||||
success: Record<string, LocalNodeInfo>;
|
||||
error: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NodeStatistics {
|
||||
freeform: string;
|
||||
}
|
||||
|
||||
export interface MultiNodeStatisticsResponse {
|
||||
success: Record<string, NodeStatistics>;
|
||||
error: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
nodeId: string;
|
||||
version: string;
|
||||
rustVersion: string;
|
||||
uptime: number;
|
||||
dbSize: number;
|
||||
blockReferenceTableSize: number;
|
||||
blockMetricsTableSize: number;
|
||||
objectTableSize: number;
|
||||
objectVersionTableSize: number;
|
||||
bucketTableSize: number;
|
||||
bucketAliasTableSize: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface RequestTypeMetrics {
|
||||
read: number;
|
||||
write: number;
|
||||
delete: number;
|
||||
list: number;
|
||||
}
|
||||
|
||||
export interface GarageMetrics extends StorageMetrics {
|
||||
clusterHealth?: ClusterHealth;
|
||||
clusterStatistics?: ClusterStatistics;
|
||||
nodeInfo?: NodeInfo;
|
||||
requestTypeMetrics?: RequestTypeMetrics;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'var(--border)',
|
||||
input: 'var(--input)',
|
||||
ring: 'var(--ring)',
|
||||
background: 'var(--background)',
|
||||
foreground: 'var(--foreground)',
|
||||
primary: {
|
||||
DEFAULT: 'var(--primary)',
|
||||
foreground: 'var(--primary-foreground)',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'var(--secondary)',
|
||||
foreground: 'var(--secondary-foreground)',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'var(--destructive)',
|
||||
foreground: 'var(--destructive-foreground)',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'var(--muted)',
|
||||
foreground: 'var(--muted-foreground)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'var(--accent)',
|
||||
foreground: 'var(--accent-foreground)',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'var(--popover)',
|
||||
foreground: 'var(--popover-foreground)',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'var(--card)',
|
||||
foreground: 'var(--card-foreground)',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
module Noooste/garage-ui
|
||||
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/Noooste/azuretls-client v1.12.9
|
||||
github.com/Noooste/swagger v1.2.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Noooste/fhttp v1.0.15 // indirect
|
||||
github.com/Noooste/go-socks4 v0.0.2 // indirect
|
||||
github.com/Noooste/uquic-go v1.0.1 // indirect
|
||||
github.com/Noooste/utls v1.3.20 // indirect
|
||||
github.com/Noooste/websocket v1.0.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect
|
||||
github.com/aws/smithy-go v1.23.2 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gaukas/clienthellod v0.4.2 // indirect
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/refraction-networking/utls v1.8.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
github.com/tinylib/msgp v1.5.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE=
|
||||
github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU=
|
||||
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
|
||||
github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE=
|
||||
github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng=
|
||||
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
|
||||
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
|
||||
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
|
||||
github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8=
|
||||
github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A=
|
||||
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
|
||||
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
|
||||
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
|
||||
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 h1:Cso4Ev/XauMVsbwdhYEoxg8rxZWw43CFqqaPB5w3W2c=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI=
|
||||
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
|
||||
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
|
||||
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
|
||||
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
|
||||
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
|
||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE=
|
||||
github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
|
||||
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
||||
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,303 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
config *config.AuthConfig
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
}
|
||||
|
||||
// UserInfo represents authenticated user information
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
|
||||
service := &AuthService{
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
// Initialize OIDC if enabled
|
||||
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
|
||||
if err := service.initOIDC(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// initOIDC initializes the OIDC provider and configuration
|
||||
func (a *AuthService) initOIDC() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create OIDC provider
|
||||
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
a.oidcProvider = provider
|
||||
|
||||
// Create ID token verifier
|
||||
verifierConfig := &oidc.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
|
||||
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
|
||||
}
|
||||
a.oidcVerifier = provider.Verifier(verifierConfig)
|
||||
|
||||
// Create OAuth2 config
|
||||
a.oauth2Config = &oauth2.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
ClientSecret: a.config.OIDC.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: a.config.OIDC.Scopes,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateBasicAuth validates basic authentication credentials
|
||||
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
usernameMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(username),
|
||||
[]byte(a.config.Basic.Username),
|
||||
) == 1
|
||||
|
||||
passwordMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(password),
|
||||
[]byte(a.config.Basic.Password),
|
||||
) == 1
|
||||
|
||||
return usernameMatch && passwordMatch
|
||||
}
|
||||
|
||||
// ParseBasicAuth parses the Authorization header for basic auth
|
||||
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
|
||||
if authHeader == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Check if it's a Basic auth header
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(authHeader, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Decode base64 credentials
|
||||
encoded := authHeader[len(prefix):]
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Split username:password
|
||||
credentials := string(decoded)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// GetAuthorizationURL returns the OIDC authorization URL for login
|
||||
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return "", fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
return a.oauth2Config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for tokens
|
||||
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
token, err := a.oauth2Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyIDToken verifies an OIDC ID token and extracts user info
|
||||
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
|
||||
if a.oidcVerifier == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
// Verify the ID token
|
||||
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify ID token: %w", err)
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims map[string]interface{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Extract user information using configured attributes
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information from the OIDC provider
|
||||
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
|
||||
if a.oidcProvider == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
// Create OAuth2 token source
|
||||
tokenSource := a.oauth2Config.TokenSource(ctx, token)
|
||||
|
||||
// Get user info from the provider
|
||||
userInfoEndpoint, err := a.oidcProvider.UserInfo(ctx, tokenSource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims map[string]interface{}
|
||||
if err := userInfoEndpoint.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
|
||||
}
|
||||
|
||||
// Build user info
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.config.OIDC.AdminRole == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, role := range userInfo.Roles {
|
||||
if role == a.config.OIDC.AdminRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// extractClaim extracts a string claim from the claims map
|
||||
func extractClaim(claims map[string]interface{}, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := claims[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// extractRoles extracts roles from nested claim path (e.g., "resource_access.garage-ui.roles")
|
||||
func extractRoles(claims map[string]interface{}, path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split the path by dots to navigate nested claims
|
||||
parts := strings.Split(path, ".")
|
||||
|
||||
current := claims
|
||||
for i, part := range parts {
|
||||
value, ok := current[part]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If this is the last part, it should be the roles array
|
||||
if i == len(parts)-1 {
|
||||
return extractStringArray(value)
|
||||
}
|
||||
|
||||
// Navigate to next level
|
||||
next, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractStringArray converts an interface{} to []string if possible
|
||||
func extractStringArray(value interface{}) []string {
|
||||
// Try direct string array
|
||||
if strArray, ok := value.([]string); ok {
|
||||
return strArray
|
||||
}
|
||||
|
||||
// Try interface array and convert to strings
|
||||
if array, ok := value.([]interface{}); ok {
|
||||
result := make([]string, 0, len(array))
|
||||
for _, item := range array {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Garage GarageConfig `mapstructure:"garage"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
}
|
||||
|
||||
// ServerConfig contains server-related configuration
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Environment string `mapstructure:"environment"`
|
||||
}
|
||||
|
||||
// GarageConfig contains Garage S3 connection settings
|
||||
type GarageConfig struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
Region string `mapstructure:"region"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
AdminEndpoint string `mapstructure:"admin_endpoint"`
|
||||
AdminToken string `mapstructure:"admin_token"`
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
|
||||
Basic BasicAuthConfig `mapstructure:"basic"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
}
|
||||
|
||||
// BasicAuthConfig contains basic authentication settings
|
||||
type BasicAuthConfig struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// OIDCConfig contains OIDC authentication settings
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ProviderName string `mapstructure:"provider_name"`
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
Scopes []string `mapstructure:"scopes"`
|
||||
IssuerURL string `mapstructure:"issuer_url"`
|
||||
AuthURL string `mapstructure:"auth_url"`
|
||||
TokenURL string `mapstructure:"token_url"`
|
||||
UserinfoURL string `mapstructure:"userinfo_url"`
|
||||
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
|
||||
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
|
||||
EmailAttribute string `mapstructure:"email_attribute"`
|
||||
UsernameAttribute string `mapstructure:"username_attribute"`
|
||||
NameAttribute string `mapstructure:"name_attribute"`
|
||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||
AdminRole string `mapstructure:"admin_role"`
|
||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||
SessionMaxAge int `mapstructure:"session_max_age"`
|
||||
CookieName string `mapstructure:"cookie_name"`
|
||||
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
|
||||
CookieSameSite string `mapstructure:"cookie_same_site"`
|
||||
}
|
||||
|
||||
// CORSConfig contains CORS settings for frontend communication
|
||||
type CORSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||||
AllowedMethods []string `mapstructure:"allowed_methods"`
|
||||
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
||||
AllowCredentials bool `mapstructure:"allow_credentials"`
|
||||
MaxAge int `mapstructure:"max_age"`
|
||||
}
|
||||
|
||||
// LoggingConfig contains logging configuration
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
}
|
||||
|
||||
// Load reads the configuration from the specified file
|
||||
func Load(configPath string) (*Config, error) {
|
||||
// Set default config file name if not specified
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("config file not found: %s", configPath)
|
||||
}
|
||||
|
||||
// Configure viper to read the config file
|
||||
viper.SetConfigFile(configPath)
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// Allow environment variables to override config values
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// Read the configuration file
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the configuration into our Config struct
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Validate the configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server config
|
||||
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
||||
return fmt.Errorf("invalid server port: %d", c.Server.Port)
|
||||
}
|
||||
|
||||
// Validate Garage config
|
||||
if c.Garage.Endpoint == "" {
|
||||
return fmt.Errorf("garage endpoint is required")
|
||||
}
|
||||
if c.Garage.AccessKey == "" {
|
||||
return fmt.Errorf("garage access_key is required")
|
||||
}
|
||||
if c.Garage.SecretKey == "" {
|
||||
return fmt.Errorf("garage secret_key is required")
|
||||
}
|
||||
if c.Garage.AdminEndpoint == "" {
|
||||
return fmt.Errorf("garage admin_endpoint is required")
|
||||
}
|
||||
if c.Garage.AdminToken == "" {
|
||||
return fmt.Errorf("garage admin_token is required")
|
||||
}
|
||||
|
||||
// Validate auth mode
|
||||
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
|
||||
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
|
||||
}
|
||||
|
||||
// Validate basic auth if enabled
|
||||
if c.Auth.Mode == "basic" {
|
||||
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
|
||||
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate OIDC config if enabled
|
||||
if c.Auth.Mode == "oidc" {
|
||||
if c.Auth.OIDC.ClientID == "" {
|
||||
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
|
||||
}
|
||||
if c.Auth.OIDC.IssuerURL == "" {
|
||||
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
|
||||
}
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAddress returns the full server address (host:port)
|
||||
func (c *Config) GetAddress() string {
|
||||
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
|
||||
}
|
||||
|
||||
// IsDevelopment returns true if running in development mode
|
||||
func (c *Config) IsDevelopment() bool {
|
||||
return c.Server.Environment == "development"
|
||||
}
|
||||
|
||||
// IsProduction returns true if running in production mode
|
||||
func (c *Config) IsProduction() bool {
|
||||
return c.Server.Environment == "production"
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// BucketHandler handles bucket-related operations
|
||||
type BucketHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewBucketHandler creates a new bucket handler
|
||||
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
|
||||
return &BucketHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// ListBuckets lists all buckets
|
||||
//
|
||||
// @Summary List all buckets
|
||||
// @Description Retrieves a list of all buckets in the Garage storage system with object count and size
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.BucketListResponse} "Successfully retrieved list of buckets"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list buckets"
|
||||
// @Router /api/v1/buckets [get]
|
||||
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// List all buckets from Garage Admin API
|
||||
adminBuckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert admin bucket response to BucketInfo
|
||||
buckets := make([]models.BucketInfo, 0, len(adminBuckets))
|
||||
for _, adminBucket := range adminBuckets {
|
||||
// Get the bucket name from global aliases
|
||||
var bucketName string
|
||||
if len(adminBucket.GlobalAliases) > 0 {
|
||||
bucketName = adminBucket.GlobalAliases[0]
|
||||
} else {
|
||||
// Skip buckets without global aliases
|
||||
continue
|
||||
}
|
||||
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "", // Garage doesn't have regions
|
||||
}
|
||||
|
||||
// Try to get bucket statistics (object count and size)
|
||||
// This is done asynchronously to avoid blocking the response
|
||||
// If it fails, we still return the bucket info without stats
|
||||
stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName)
|
||||
if err == nil && stats != nil {
|
||||
bucketInfo.ObjectCount = &stats.ObjectCount
|
||||
bucketInfo.Size = &stats.TotalSize
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
}
|
||||
|
||||
response := models.BucketListResponse{
|
||||
Buckets: buckets,
|
||||
Count: len(buckets),
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket
|
||||
//
|
||||
// @Summary Create a new bucket
|
||||
// @Description Creates a new bucket in the Garage storage system
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param payload body models.CreateBucketRequest true "Bucket creation payload"
|
||||
// @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required"
|
||||
// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket"
|
||||
// @Router /api/v1/buckets [post]
|
||||
func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Parse request body
|
||||
var req models.CreateBucketRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate bucket name
|
||||
if req.Name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create the bucket
|
||||
createBucketReq := models.CreateBucketAdminRequest{
|
||||
GlobalAlias: &req.Name,
|
||||
}
|
||||
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"bucket": req.Name,
|
||||
"message": "Bucket created successfully",
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
//
|
||||
// @Summary Delete a bucket
|
||||
// @Description Deletes an existing bucket from the Garage storage system. The bucket must be empty before deletion.
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket to delete"
|
||||
// @Success 200 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket deleted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete bucket"
|
||||
// @Router /api/v1/buckets/{name} [delete]
|
||||
func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the bucket
|
||||
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"bucket": bucketName,
|
||||
"message": "Bucket deleted successfully",
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetBucketInfo returns information about a specific bucket
|
||||
//
|
||||
// @Summary Get bucket information
|
||||
// @Description Retrieves detailed information about a specific bucket including creation date and region
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket to retrieve information for"
|
||||
// @Success 200 {object} models.APIResponse{data=models.BucketInfo} "Successfully retrieved bucket information"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve bucket information"
|
||||
// @Router /api/v1/buckets/{name} [get]
|
||||
func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(bucketInfo))
|
||||
}
|
||||
|
||||
// GrantBucketPermission grants permissions for an access key on a bucket
|
||||
//
|
||||
// @Summary Grant bucket permissions
|
||||
// @Description Grants read/write/owner permissions for an access key on a specific bucket
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.GrantBucketPermissionRequest true "Permission grant request"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Permissions granted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to grant permissions"
|
||||
// @Router /api/v1/buckets/{name}/permissions [post]
|
||||
func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req models.GrantBucketPermissionRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate access key ID
|
||||
if req.AccessKeyID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get bucket info to retrieve bucket ID
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
// Build the permission request for Garage Admin API
|
||||
permRequest := models.BucketKeyPermRequest{
|
||||
BucketID: bucketInfo.ID,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
Permissions: models.BucketKeyPermission{
|
||||
Read: req.Permissions.Read,
|
||||
Write: req.Permissions.Write,
|
||||
Owner: req.Permissions.Owner,
|
||||
},
|
||||
}
|
||||
|
||||
// Grant permissions using Garage Admin API
|
||||
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ClusterHandler handles cluster management operations
|
||||
type ClusterHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
}
|
||||
|
||||
// NewClusterHandler creates a new cluster handler
|
||||
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
|
||||
return &ClusterHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHealth returns the health status of the cluster
|
||||
//
|
||||
// @Summary Get cluster health
|
||||
// @Description Retrieves the overall health status of the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health"
|
||||
// @Router /api/v1/cluster/health [get]
|
||||
func (h *ClusterHandler) GetHealth(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
health, err := h.adminService.GetClusterHealth(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(health))
|
||||
}
|
||||
|
||||
// GetStatus returns the status of the cluster
|
||||
//
|
||||
// @Summary Get cluster status
|
||||
// @Description Retrieves the current status of the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status"
|
||||
// @Router /api/v1/cluster/status [get]
|
||||
func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
status, err := h.adminService.GetClusterStatus(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(status))
|
||||
}
|
||||
|
||||
// GetStatistics returns global cluster statistics
|
||||
// GET /api/v1/cluster/statistics
|
||||
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
stats, err := h.adminService.GetClusterStatistics(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
//
|
||||
// @Summary Get node information
|
||||
// @Description Retrieves detailed information about a specific node in the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param node_id path string true "ID of the node to retrieve information for"
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information"
|
||||
// @Router /api/v1/cluster/nodes/{node_id} [get]
|
||||
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(info))
|
||||
}
|
||||
|
||||
// GetNodeStatistics returns statistics for a specific node
|
||||
//
|
||||
// @Summary Get node statistics
|
||||
// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param node_id path string true "ID of the node to retrieve statistics for"
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics"
|
||||
// @Router /api/v1/cluster/nodes/{node_id}/statistics [get]
|
||||
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// HealthHandler handles health check requests
|
||||
type HealthHandler struct {
|
||||
version string
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new health check handler
|
||||
func NewHealthHandler(version string) *HealthHandler {
|
||||
return &HealthHandler{
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
// Check returns the health status of the service
|
||||
//
|
||||
// @Summary Health check
|
||||
// @Description Returns the health status of the API service along with version information
|
||||
// @Tags Health
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.HealthResponse} "Service is healthy"
|
||||
// @Router /api/v1/health [get]
|
||||
func (h *HealthHandler) Check(c fiber.Ctx) error {
|
||||
response := models.HealthResponse{
|
||||
Status: "healthy",
|
||||
Timestamp: time.Now(),
|
||||
Version: h.version,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// MonitoringHandler handles monitoring operations
|
||||
type MonitoringHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewMonitoringHandler creates a new monitoring handler
|
||||
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics retrieves system metrics from the Admin API
|
||||
//
|
||||
// @Summary Get system metrics
|
||||
// @Description Retrieves system metrics from the Garage Admin API for monitoring purposes
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Success 200 {string} string "System metrics in plain text format"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics"
|
||||
// @Router /api/v1/monitoring/metrics [get]
|
||||
func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
metrics, err := h.adminService.GetMetrics(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get metrics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return metrics as plain text
|
||||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.SendString(metrics)
|
||||
}
|
||||
|
||||
// CheckAdminHealth checks if the Admin API is reachable
|
||||
//
|
||||
// @Summary Check Admin API health
|
||||
// @Description Performs a health check on the Garage Admin API to verify connectivity and availability
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object{status=string,message=string}} "Admin API is healthy"
|
||||
// @Failure 503 {object} models.APIResponse{error=models.APIError} "Admin API health check failed"
|
||||
// @Router /api/v1/monitoring/admin-health [get]
|
||||
func (h *MonitoringHandler) CheckAdminHealth(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
err := h.adminService.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Admin API health check failed: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"message": "Admin API is reachable",
|
||||
}))
|
||||
}
|
||||
|
||||
// GetDashboardMetrics retrieves aggregated dashboard metrics
|
||||
//
|
||||
// @Summary Get dashboard metrics
|
||||
// @Description Retrieves aggregated metrics for the dashboard including storage, buckets, and request metrics
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.DashboardMetrics} "Successfully retrieved dashboard metrics"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get dashboard metrics"
|
||||
// @Router /api/v1/monitoring/dashboard [get]
|
||||
func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket list
|
||||
buckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get buckets: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate aggregated metrics
|
||||
var totalSize int64
|
||||
var totalObjects int64
|
||||
usageByBucket := make([]models.BucketUsage, 0)
|
||||
|
||||
for _, bucket := range buckets {
|
||||
// Get bucket info to calculate size and object count
|
||||
bucketInfo, err := h.adminService.GetBucketInfo(ctx, bucket.ID)
|
||||
if err != nil {
|
||||
continue // Skip buckets we can't access
|
||||
}
|
||||
|
||||
// Get size and object count from bucket info
|
||||
bucketSize := bucketInfo.Bytes
|
||||
objectCount := bucketInfo.Objects
|
||||
|
||||
totalSize += bucketSize
|
||||
totalObjects += objectCount
|
||||
|
||||
// Get bucket name from aliases
|
||||
bucketName := bucket.ID
|
||||
if len(bucket.LocalAliases) > 0 {
|
||||
bucketName = bucket.LocalAliases[0].Alias
|
||||
} else if len(bucket.GlobalAliases) > 0 {
|
||||
bucketName = bucket.GlobalAliases[0]
|
||||
}
|
||||
|
||||
usageByBucket = append(usageByBucket, models.BucketUsage{
|
||||
BucketName: bucketName,
|
||||
Size: bucketSize,
|
||||
ObjectCount: objectCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
for i := range usageByBucket {
|
||||
if totalSize > 0 {
|
||||
usageByBucket[i].Percentage = float64(usageByBucket[i].Size) / float64(totalSize) * 100
|
||||
}
|
||||
}
|
||||
|
||||
dashboardMetrics := models.DashboardMetrics{
|
||||
TotalSize: totalSize,
|
||||
ObjectCount: totalObjects,
|
||||
BucketCount: len(buckets),
|
||||
UsageByBucket: usageByBucket,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(dashboardMetrics))
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ObjectHandler handles object-related operations
|
||||
type ObjectHandler struct {
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler
|
||||
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
|
||||
return &ObjectHandler{
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// ListObjects lists objects in a bucket with optional filtering and pagination
|
||||
//
|
||||
// @Summary List objects in a bucket
|
||||
// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to list objects from"
|
||||
// @Param prefix query string false "Filter objects by prefix"
|
||||
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
|
||||
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects [get]
|
||||
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get query parameters for filtering and pagination
|
||||
prefix := c.Query("prefix", "")
|
||||
continuationToken := c.Query("continuation_token", "")
|
||||
|
||||
maxKeysStr := c.Query("max_keys", "100")
|
||||
maxKeys, err := strconv.Atoi(maxKeysStr)
|
||||
if err != nil || maxKeys <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"),
|
||||
)
|
||||
}
|
||||
|
||||
// List objects in the bucket
|
||||
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(objects))
|
||||
}
|
||||
|
||||
// UploadObject uploads an object to a bucket
|
||||
//
|
||||
// @Summary Upload object to bucket
|
||||
// @Description Uploads an object to the specified bucket using multipart/form-data
|
||||
// @Tags Objects
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the object to"
|
||||
// @Param file formData file true "File to upload"
|
||||
// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects [post]
|
||||
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get file from multipart form
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Get object key (path in bucket)
|
||||
key := c.FormValue("key")
|
||||
if key == "" {
|
||||
// Use filename as key if not provided
|
||||
key = file.Filename
|
||||
}
|
||||
|
||||
// Open the uploaded file
|
||||
fileHandle, err := file.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer fileHandle.Close()
|
||||
|
||||
// Get content type
|
||||
contentType := file.Header.Get("Content-Type")
|
||||
|
||||
// Upload to Garage
|
||||
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
}
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
//
|
||||
// @Summary Get object from bucket
|
||||
// @Description Retrieves an object stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce application/octet-stream
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Param download query bool false "Set to true to download the object as an attachment"
|
||||
// @Success 200 {file} binary "Successfully retrieved the object"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
|
||||
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get object from Garage
|
||||
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", objectInfo.ContentType)
|
||||
c.Set("Content-Length", string(rune(objectInfo.Size)))
|
||||
c.Set("ETag", objectInfo.ETag)
|
||||
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
||||
|
||||
// Check if client wants to download or view inline
|
||||
if c.Query("download") == "true" {
|
||||
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
|
||||
}
|
||||
|
||||
// Stream the object body to the client
|
||||
return c.SendStream(body)
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
//
|
||||
// @Summary Delete object from bucket
|
||||
// @Description Deletes an object stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [delete]
|
||||
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if object exists
|
||||
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the object
|
||||
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := models.ObjectDeleteResponse{
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Deleted: true,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetObjectMetadata returns metadata for an object without downloading it
|
||||
//
|
||||
// @Summary Get object metadata
|
||||
// @Description Retrieves metadata information about an object without downloading the actual content
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get]
|
||||
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get object metadata
|
||||
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(metadata))
|
||||
}
|
||||
|
||||
// GetPresignedURL generates a pre-signed URL for accessing an object
|
||||
//
|
||||
// @Summary Get pre-signed URL for object
|
||||
// @Description Generates a pre-signed URL that allows temporary access to the specified object
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get]
|
||||
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get expiration time from query parameter (default: 1 hour)
|
||||
expiresInStr := c.Query("expires_in", "3600")
|
||||
expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate expiration time (1 second to 7 days)
|
||||
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if object exists
|
||||
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
|
||||
)
|
||||
}
|
||||
|
||||
// Generate pre-signed URL
|
||||
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
response := models.PresignedURLResponse{
|
||||
URL: url,
|
||||
ExpiresIn: expiresIn,
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
//
|
||||
// @Summary Delete multiple objects from bucket
|
||||
// @Description Deletes multiple objects stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the objects"
|
||||
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post]
|
||||
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body to get keys and optional prefix
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if len(req.Keys) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete multiple objects
|
||||
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
response := models.ObjectDeleteMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
Deleted: len(req.Keys),
|
||||
Keys: req.Keys,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// UploadObjectStream uploads an object from request body stream (for large files)
|
||||
//
|
||||
// @Summary Upload object via stream
|
||||
// @Description Uploads an object directly from the request body stream, suitable for large files
|
||||
// @Tags Objects
|
||||
// @Accept application/octet-stream
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the object to"
|
||||
// @Param key path string true "Object key (path in bucket)"
|
||||
// @Param Content-Type header string false "Content type of the object being uploaded"
|
||||
// @Param body body string true "Raw binary data of the object"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [put]
|
||||
func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get content type from header
|
||||
contentType := c.Get("Content-Type", "application/octet-stream")
|
||||
|
||||
// Get request body as reader
|
||||
bodyReader := c.Request().BodyStream()
|
||||
|
||||
// Upload to Garage
|
||||
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, io.NopCloser(bodyReader), contentType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
}
|
||||
|
||||
// UploadMultipleObjects uploads multiple objects to a bucket
|
||||
//
|
||||
// @Summary Upload multiple objects to bucket
|
||||
// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop.
|
||||
// @Tags Objects
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the objects to"
|
||||
// @Param files formData file true "Files to upload (can be multiple)"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post]
|
||||
func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse multipart form to get all files
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Get all files from the form (they should all be under "files" field)
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare upload data structure
|
||||
uploadFiles := make([]struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}, len(files))
|
||||
|
||||
// Open all files and prepare for upload
|
||||
for i, fileHeader := range files {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Use filename as the key
|
||||
key := fileHeader.Filename
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
uploadFiles[i] = struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}{
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
}
|
||||
}
|
||||
|
||||
// Upload all files using the service method
|
||||
results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles)
|
||||
|
||||
// Process results and categorize successes and failures
|
||||
var successFiles []models.ObjectUploadResult
|
||||
var failedFiles []models.ObjectUploadFailedResult
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
|
||||
for _, result := range results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
successFiles = append(successFiles, models.ObjectUploadResult{
|
||||
Key: result.Key,
|
||||
ETag: result.ETag,
|
||||
Size: result.Size,
|
||||
ContentType: result.ContentType,
|
||||
})
|
||||
} else {
|
||||
failureCount++
|
||||
failedFiles = append(failedFiles, models.ObjectUploadFailedResult{
|
||||
Key: result.Key,
|
||||
Error: result.Error.Error(),
|
||||
ContentType: result.ContentType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := models.ObjectUploadMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
TotalFiles: len(files),
|
||||
SuccessCount: successCount,
|
||||
FailureCount: failureCount,
|
||||
SuccessFiles: successFiles,
|
||||
FailedFiles: failedFiles,
|
||||
}
|
||||
|
||||
// Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed
|
||||
statusCode := fiber.StatusCreated
|
||||
if failureCount > 0 && successCount > 0 {
|
||||
statusCode = fiber.StatusMultiStatus // 207
|
||||
} else if failureCount > 0 && successCount == 0 {
|
||||
statusCode = fiber.StatusInternalServerError
|
||||
}
|
||||
|
||||
return c.Status(statusCode).JSON(models.SuccessResponse(response))
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// UserHandler handles user/key management operations using Garage Admin API
|
||||
type UserHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers lists all users/access keys
|
||||
//
|
||||
// @Summary List all users
|
||||
// @Description Retrieves a list of all users/access keys
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserListResponse} "List of users retrieved successfully"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list users"
|
||||
// @Router /api/v1/users [get]
|
||||
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
keys, err := h.adminService.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
users := make([]models.UserInfo, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
// Get full key info to retrieve bucket permissions
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, key.ID, false)
|
||||
if err != nil {
|
||||
// If we can't get full info, skip this key or use basic info
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status based on expiration
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
users = append(users, models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(models.UserListResponse{
|
||||
Users: users,
|
||||
Count: len(users),
|
||||
}))
|
||||
}
|
||||
|
||||
// convertBucketPermissionsToBucketPermissions converts Garage bucket permissions to frontend BucketPermission format
|
||||
func convertBucketPermissionsToBucketPermissions(buckets []models.KeyBucketInfo) []models.BucketPermission {
|
||||
permissions := make([]models.BucketPermission, 0, len(buckets))
|
||||
|
||||
for _, bucket := range buckets {
|
||||
// Get bucket name from aliases
|
||||
var bucketName string
|
||||
if len(bucket.GlobalAliases) > 0 {
|
||||
bucketName = bucket.GlobalAliases[0]
|
||||
} else if len(bucket.LocalAliases) > 0 {
|
||||
bucketName = bucket.LocalAliases[0]
|
||||
} else {
|
||||
bucketName = bucket.ID
|
||||
}
|
||||
|
||||
// Create bucket permission with simple read/write/owner flags
|
||||
permissions = append(permissions, models.BucketPermission{
|
||||
BucketID: bucket.ID,
|
||||
BucketName: bucketName,
|
||||
Read: bucket.Permissions.Read,
|
||||
Write: bucket.Permissions.Write,
|
||||
Owner: bucket.Permissions.Owner,
|
||||
})
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
// CreateUser creates a new user/access key
|
||||
//
|
||||
// @Summary Create a new user
|
||||
// @Description Creates a new user/access key with optional name
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.CreateUserRequest true "User creation request"
|
||||
// @Success 201 {object} models.APIResponse{data=models.UserInfo} "User created successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create user"
|
||||
// @Router /api/v1/users [post]
|
||||
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
var req models.CreateUserRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare create key request
|
||||
createReq := models.CreateKeyRequest{}
|
||||
if req.Name != "" {
|
||||
createReq.Name = &req.Name
|
||||
}
|
||||
|
||||
// Create the key
|
||||
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
SecretKey: keyInfo.SecretAccessKey,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user/access key
|
||||
//
|
||||
// @Summary Delete a user
|
||||
// @Description Deletes a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to delete"
|
||||
// @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user"
|
||||
// @Router /api/v1/users/{access_key} [delete]
|
||||
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the key
|
||||
err := h.adminService.DeleteKey(ctx, accessKey)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
"access_key": accessKey,
|
||||
"deleted": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUser retrieves information about a specific user/access key
|
||||
//
|
||||
// @Summary Get user information
|
||||
// @Description Retrieves information about a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to retrieve"
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User information retrieved successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get user info"
|
||||
// @Router /api/v1/users/{access_key} [get]
|
||||
func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get key information (without secret key)
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates user permissions
|
||||
//
|
||||
// @Summary Update user permissions
|
||||
// @Description Updates the permissions and settings for a specific user/access key
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to update"
|
||||
// @Param request body models.UpdateUserRequest true "User update request with new permissions"
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User updated successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required or invalid request body"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update user"
|
||||
// @Router /api/v1/users/{access_key} [patch]
|
||||
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
var req models.UpdateUserRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare update request
|
||||
updateReq := models.UpdateKeyRequest{}
|
||||
|
||||
// Handle status change (activate/deactivate)
|
||||
if req.Status != nil {
|
||||
if *req.Status == "inactive" {
|
||||
// Deactivate by setting expiration to the past
|
||||
pastTime := time.Now().Add(-24 * time.Hour)
|
||||
updateReq.Expiration = &pastTime
|
||||
updateReq.NeverExpires = false
|
||||
} else if *req.Status == "active" {
|
||||
// Activate by removing expiration (set to never expire)
|
||||
updateReq.NeverExpires = true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle explicit expiration date setting
|
||||
if req.Expiration != nil && *req.Expiration != "" {
|
||||
expirationTime, err := time.Parse(time.RFC3339, *req.Expiration)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration date format: "+err.Error()),
|
||||
)
|
||||
}
|
||||
updateReq.Expiration = &expirationTime
|
||||
updateReq.NeverExpires = false
|
||||
}
|
||||
|
||||
// Update the key
|
||||
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware returns a Fiber middleware for authentication
|
||||
// It handles different auth modes: none, basic, and OIDC
|
||||
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
// If auth mode is "none", allow all requests
|
||||
if cfg.Mode == "none" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Handle basic authentication
|
||||
if cfg.Mode == "basic" {
|
||||
return handleBasicAuth(c, authService)
|
||||
}
|
||||
|
||||
// Handle OIDC authentication
|
||||
if cfg.Mode == "oidc" {
|
||||
return handleOIDCAuth(c, authService, &cfg.OIDC)
|
||||
}
|
||||
|
||||
// Unknown auth mode - deny access
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBasicAuth validates basic authentication credentials
|
||||
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
|
||||
// Get Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse basic auth credentials
|
||||
username, password, ok := auth.ParseBasicAuth(authHeader)
|
||||
if !ok {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
if !authService.ValidateBasicAuth(username, password) {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
|
||||
)
|
||||
}
|
||||
|
||||
// Store username in context for later use
|
||||
c.Locals("username", username)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// handleOIDCAuth validates OIDC session/token
|
||||
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
|
||||
// Get session cookie
|
||||
sessionCookie := c.Cookies(oidcCfg.CookieName)
|
||||
if sessionCookie == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
|
||||
)
|
||||
}
|
||||
|
||||
// In a production implementation, you would:
|
||||
// 1. Validate the session token from the cookie
|
||||
// 2. Look up the session in a session store (Redis, memory, etc.)
|
||||
// 3. Verify the session hasn't expired
|
||||
// 4. Extract user information from the session
|
||||
//
|
||||
// For now, we'll implement a basic token verification
|
||||
// You should extend this based on your session management strategy
|
||||
|
||||
// Verify ID token if it's stored in the session
|
||||
ctx := c.Context()
|
||||
userInfo, err := authService.VerifyIDToken(ctx, sessionCookie)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
|
||||
)
|
||||
}
|
||||
|
||||
// Store user info in context for handlers to use
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
c.Locals("email", userInfo.Email)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// RequireAuth is a simpler middleware that just checks if auth is enabled
|
||||
// Use this for routes that should only be accessible when any auth is active
|
||||
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if cfg.Mode == "none" {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
|
||||
)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin is middleware that checks if the user has admin role (OIDC only)
|
||||
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
// Get user info from context (set by AuthMiddleware)
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
if userInfoInterface == nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
userInfo, ok := userInfoInterface.(*auth.UserInfo)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// CORSMiddleware creates a CORS middleware from configuration
|
||||
func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
// If CORS is disabled, return a no-op middleware
|
||||
if !cfg.Enabled {
|
||||
return func(c fiber.Ctx) error {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
origin := c.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
|
||||
// Set CORS headers
|
||||
c.Set("Access-Control-Allow-Origin", origin)
|
||||
|
||||
if cfg.AllowCredentials {
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
// Set allowed methods
|
||||
if len(cfg.AllowedMethods) > 0 {
|
||||
c.Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", "))
|
||||
}
|
||||
|
||||
// Set allowed headers
|
||||
if len(cfg.AllowedHeaders) > 0 {
|
||||
c.Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", "))
|
||||
}
|
||||
|
||||
// Set max age for preflight cache
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedOrigin checks if an origin is in the allowed list
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ====================================
|
||||
// Access Key Models
|
||||
// ====================================
|
||||
|
||||
// GarageKeyInfo represents detailed information about a Garage access key
|
||||
type GarageKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
Expired bool `json:"expired"`
|
||||
SecretAccessKey *string `json:"secretAccessKey,omitempty"`
|
||||
Permissions KeyPermissions `json:"permissions"`
|
||||
Buckets []KeyBucketInfo `json:"buckets"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// KeyPermissions represents permissions for an access key
|
||||
type KeyPermissions struct {
|
||||
CreateBucket bool `json:"createBucket"`
|
||||
}
|
||||
|
||||
// KeyBucketInfo represents bucket information associated with a key
|
||||
type KeyBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
LocalAliases []string `json:"localAliases"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// BucketKeyPermission represents permissions a key has on a specific bucket
|
||||
type BucketKeyPermission struct {
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// CreateKeyRequest represents the request to create a new access key
|
||||
type CreateKeyRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
NeverExpires bool `json:"neverExpires,omitempty"`
|
||||
Allow *KeyPermissions `json:"allow,omitempty"`
|
||||
Deny *KeyPermissions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateKeyRequest represents the request to update an access key
|
||||
type UpdateKeyRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
NeverExpires bool `json:"neverExpires,omitempty"`
|
||||
Allow *KeyPermissions `json:"allow,omitempty"`
|
||||
Deny *KeyPermissions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// ImportKeyRequest represents the request to import an existing key
|
||||
type ImportKeyRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ListKeysResponseItem represents a single key in the list response
|
||||
type ListKeysResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Expired bool `json:"expired"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Models (Admin API)
|
||||
// ====================================
|
||||
|
||||
// GarageBucketInfo represents detailed information about a bucket from Admin API
|
||||
type GarageBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
Keys []BucketKeyInfo `json:"keys"`
|
||||
Objects int64 `json:"objects"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
UnfinishedUploads int64 `json:"unfinishedUploads"`
|
||||
UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"`
|
||||
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
|
||||
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// BucketWebsiteConfig represents website configuration for a bucket
|
||||
type BucketWebsiteConfig struct {
|
||||
IndexDocument string `json:"indexDocument"`
|
||||
ErrorDocument *string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// BucketQuotas represents quota settings for a bucket
|
||||
type BucketQuotas struct {
|
||||
MaxSize *int64 `json:"maxSize,omitempty"`
|
||||
MaxObjects *int64 `json:"maxObjects,omitempty"`
|
||||
}
|
||||
|
||||
// BucketKeyInfo represents key information associated with a bucket
|
||||
type BucketKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
BucketLocalAliases []string `json:"bucketLocalAliases"`
|
||||
}
|
||||
|
||||
// CreateBucketAdminRequest represents the request to create a bucket via Admin API
|
||||
type CreateBucketAdminRequest struct {
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"`
|
||||
}
|
||||
|
||||
// CreateBucketLocalAlias represents local alias configuration when creating a bucket
|
||||
type CreateBucketLocalAlias struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Alias string `json:"alias"`
|
||||
Allow *BucketKeyPermission `json:"allow,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketRequest represents the request to update bucket settings
|
||||
type UpdateBucketRequest struct {
|
||||
WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketWebsiteAccess represents website access settings update
|
||||
type UpdateBucketWebsiteAccess struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IndexDocument *string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument *string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// ListBucketsResponseItem represents a single bucket in the list response
|
||||
type ListBucketsResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
LocalAliases []BucketLocalAlias `json:"localAliases"`
|
||||
}
|
||||
|
||||
// BucketLocalAlias represents a local alias for a bucket
|
||||
type BucketLocalAlias struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Models
|
||||
// ====================================
|
||||
|
||||
// AddBucketAliasRequest represents the request to add a bucket alias
|
||||
type AddBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *string `json:"localAlias,omitempty"`
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// RemoveBucketAliasRequest represents the request to remove a bucket alias
|
||||
type RemoveBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *string `json:"localAlias,omitempty"`
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Models
|
||||
// ====================================
|
||||
|
||||
// BucketKeyPermRequest represents a request to change bucket-key permissions
|
||||
type BucketKeyPermRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Models
|
||||
// ====================================
|
||||
|
||||
// ClusterHealth represents the health status of the cluster
|
||||
type ClusterHealth struct {
|
||||
Status string `json:"status"`
|
||||
KnownNodes int `json:"knownNodes"`
|
||||
ConnectedNodes int `json:"connectedNodes"`
|
||||
StorageNodes int `json:"storageNodes"`
|
||||
StorageNodesUp int `json:"storageNodesUp"`
|
||||
Partitions int `json:"partitions"`
|
||||
PartitionsQuorum int `json:"partitionsQuorum"`
|
||||
PartitionsAllOk int `json:"partitionsAllOk"`
|
||||
}
|
||||
|
||||
// ClusterStatus represents the current status of the cluster
|
||||
type ClusterStatus struct {
|
||||
LayoutVersion int `json:"layoutVersion"`
|
||||
Nodes []NodeInfo `json:"nodes"`
|
||||
}
|
||||
|
||||
// ClusterStatistics represents global cluster statistics
|
||||
type ClusterStatistics struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Models
|
||||
// ====================================
|
||||
|
||||
// NodeInfo represents information about a cluster node
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id"`
|
||||
IsUp bool `json:"isUp"`
|
||||
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Addr *string `json:"addr,omitempty"`
|
||||
GarageVersion *string `json:"garageVersion,omitempty"`
|
||||
Role *NodeRole `json:"role,omitempty"`
|
||||
Draining bool `json:"draining"`
|
||||
DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"`
|
||||
MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRole represents the role assigned to a node
|
||||
type NodeRole struct {
|
||||
Zone string `json:"zone"`
|
||||
Capacity *int64 `json:"capacity,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// FreeSpaceInfo represents disk space information
|
||||
type FreeSpaceInfo struct {
|
||||
Available int64 `json:"available"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// NodeInfoResponse represents the response for GetNodeInfo
|
||||
type NodeInfoResponse struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
GarageVersion string `json:"garageVersion"`
|
||||
RustVersion string `json:"rustVersion"`
|
||||
DBEngine string `json:"dbEngine"`
|
||||
GarageFeatures []string `json:"garageFeatures,omitempty"`
|
||||
}
|
||||
|
||||
// NodeStatisticsResponse represents the response for GetNodeStatistics
|
||||
type NodeStatisticsResponse struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// MultiNodeResponse represents responses from multiple nodes
|
||||
type MultiNodeResponse struct {
|
||||
Success map[string]interface{} `json:"success"`
|
||||
Error map[string]string `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
// CreateBucketRequest represents a request to create a new bucket
|
||||
type CreateBucketRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
|
||||
// GrantBucketPermissionRequest represents a request to grant permissions on a bucket
|
||||
type GrantBucketPermissionRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId" validate:"required"`
|
||||
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
|
||||
}
|
||||
|
||||
// DeleteBucketRequest represents a request to delete a bucket
|
||||
type DeleteBucketRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// ListObjectsRequest represents a request to list objects in a bucket
|
||||
type ListObjectsRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
MaxKeys int `json:"max_keys,omitempty"`
|
||||
Marker string `json:"marker,omitempty"`
|
||||
}
|
||||
|
||||
// UploadObjectRequest represents metadata for an object upload
|
||||
// Note: The actual file data comes from multipart form or request body
|
||||
type UploadObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteObjectRequest represents a request to delete an object
|
||||
type DeleteObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
// GetObjectRequest represents a request to get/download an object
|
||||
type GetObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
// CreateUserRequest represents a request to create a new user/key
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteUserRequest represents a request to delete a user/key
|
||||
type DeleteUserRequest struct {
|
||||
AccessKey string `json:"access_key" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest represents a request to update user permissions
|
||||
type UpdateUserRequest struct {
|
||||
Status *string `json:"status,omitempty"` // "active" or "inactive"
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DashboardMetrics represents aggregated metrics for the dashboard
|
||||
type DashboardMetrics struct {
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
BucketCount int `json:"bucketCount"`
|
||||
UsageByBucket []BucketUsage `json:"usageByBucket"`
|
||||
}
|
||||
|
||||
// BucketUsage represents storage usage for a single bucket
|
||||
type BucketUsage struct {
|
||||
BucketName string `json:"bucketName"`
|
||||
Size int64 `json:"size"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
// APIResponse is the standard response structure for all API endpoints
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error in the API response
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// HealthResponse represents the health check response
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// BucketInfo represents information about a bucket
|
||||
type BucketInfo struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creation_date"`
|
||||
ObjectCount *int64 `json:"object_count,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
type BucketListResponse struct {
|
||||
Buckets []BucketInfo `json:"buckets"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ObjectInfo represents information about an object
|
||||
type ObjectInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
ETag string `json:"etag"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectListResponse represents a list of objects in a bucket
|
||||
type ObjectListResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Objects []ObjectInfo `json:"objects"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
Count int `json:"count"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
NextContinuationToken string `json:"next_continuation_token,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadResponse represents the response after uploading an object
|
||||
type ObjectUploadResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// ObjectUploadMultipleResponse represents the response after uploading multiple objects
|
||||
type ObjectUploadMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessFiles []ObjectUploadResult `json:"success_files"`
|
||||
FailedFiles []ObjectUploadFailedResult `json:"failed_files,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadResult represents a successful upload result
|
||||
type ObjectUploadResult struct {
|
||||
Key string `json:"key"`
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadFailedResult represents a failed upload result
|
||||
type ObjectUploadFailedResult struct {
|
||||
Key string `json:"key"`
|
||||
Error string `json:"error"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectDeleteResponse represents the response after deleting an object
|
||||
type ObjectDeleteResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// UserInfo represents information about a Garage user (key pair)
|
||||
type UserInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
SecretKey *string `json:"secretKey,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Status string `json:"status"` // "active" or "inactive"
|
||||
BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// BucketPermission represents permissions for a specific bucket
|
||||
type BucketPermission struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
BucketName string `json:"bucketName"`
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// Permission represents a permission entry for access control (legacy/deprecated)
|
||||
type Permission struct {
|
||||
Resource string `json:"resource"`
|
||||
Actions []string `json:"actions"`
|
||||
Effect string `json:"effect"` // "Allow" or "Deny"
|
||||
}
|
||||
|
||||
type PresignedURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int64 `json:"expires_in"` // in seconds
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type ObjectDeleteMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Deleted int `json:"deleted"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// UserListResponse represents a list of users/keys
|
||||
type UserListResponse struct {
|
||||
Users []UserInfo `json:"users"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Helper functions to create standard responses
|
||||
|
||||
// SuccessResponse creates a successful API response
|
||||
func SuccessResponse(data interface{}) APIResponse {
|
||||
return APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Error: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResponse creates an error API response
|
||||
func ErrorResponse(code, message string) APIResponse {
|
||||
return APIResponse{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Error: &APIError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Common error codes
|
||||
const (
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||||
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
|
||||
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
|
||||
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
|
||||
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
|
||||
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
|
||||
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||
ErrCodeListFailed = "LIST_FAILED"
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
// Swagger imports
|
||||
_ "Noooste/garage-ui/docs"
|
||||
|
||||
"github.com/Noooste/swagger"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(
|
||||
app *fiber.App,
|
||||
cfg *config.Config,
|
||||
authService *auth.AuthService,
|
||||
healthHandler *handlers.HealthHandler,
|
||||
bucketHandler *handlers.BucketHandler,
|
||||
objectHandler *handlers.ObjectHandler,
|
||||
userHandler *handlers.UserHandler,
|
||||
clusterHandler *handlers.ClusterHandler,
|
||||
monitoringHandler *handlers.MonitoringHandler,
|
||||
) {
|
||||
// Apply CORS middleware globally
|
||||
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
app.Get("/api/v1/health", healthHandler.Check)
|
||||
|
||||
// Swagger documentation endpoint (no auth required)
|
||||
app.Get("/docs/*", swagger.HandlerDefault)
|
||||
|
||||
// API v1 group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Apply authentication middleware to all API routes
|
||||
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
|
||||
|
||||
// Bucket routes
|
||||
buckets := api.Group("/buckets")
|
||||
{
|
||||
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
|
||||
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
|
||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
||||
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
|
||||
}
|
||||
|
||||
// Object routes
|
||||
objects := api.Group("/buckets/:bucket/objects")
|
||||
{
|
||||
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
|
||||
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
||||
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
|
||||
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||
objects.Get("/:key", objectHandler.GetObject) // Download object
|
||||
objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
|
||||
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
|
||||
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
|
||||
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
|
||||
}
|
||||
|
||||
// User/Key management routes
|
||||
users := api.Group("/users")
|
||||
{
|
||||
users.Get("/", userHandler.ListUsers) // List all users/keys
|
||||
users.Post("/", userHandler.CreateUser) // Create new user/key
|
||||
users.Get("/:access_key", userHandler.GetUser) // Get user info
|
||||
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
|
||||
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
|
||||
}
|
||||
|
||||
// Cluster management routes
|
||||
cluster := api.Group("/cluster")
|
||||
{
|
||||
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
|
||||
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
|
||||
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
|
||||
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
|
||||
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
|
||||
}
|
||||
|
||||
// Monitoring routes
|
||||
monitoring := api.Group("/monitoring")
|
||||
{
|
||||
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
|
||||
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
|
||||
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
||||
}
|
||||
|
||||
// OIDC authentication routes (only if OIDC is enabled)
|
||||
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
|
||||
authRoutes := app.Group("/auth")
|
||||
{
|
||||
// Login endpoint - redirects to OIDC provider
|
||||
authRoutes.Get("/login", func(c fiber.Ctx) error {
|
||||
// Generate state token for CSRF protection
|
||||
state := "random-state-token" // In production, use a secure random token
|
||||
authURL, err := authService.GetAuthorizationURL(state)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to generate login URL",
|
||||
})
|
||||
}
|
||||
return c.Redirect().To(authURL)
|
||||
})
|
||||
|
||||
// Callback endpoint - handles OIDC redirect after login
|
||||
authRoutes.Get("/callback", func(c fiber.Ctx) error {
|
||||
// Get authorization code from query
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Authorization code is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
ctx := c.Context()
|
||||
token, err := authService.ExchangeCode(ctx, code)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Failed to exchange authorization code",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract ID token from OAuth2 token
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "No ID token in response",
|
||||
})
|
||||
}
|
||||
|
||||
// Verify ID token and get user info
|
||||
userInfo, err := authService.VerifyIDToken(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Invalid ID token",
|
||||
})
|
||||
}
|
||||
|
||||
// In production, you should:
|
||||
// 1. Create a session and store it in Redis/memory
|
||||
// 2. Set a secure session cookie
|
||||
// 3. Redirect to the frontend with the session
|
||||
|
||||
// For now, just set the ID token as a cookie (not recommended for production)
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: cfg.Auth.OIDC.CookieName,
|
||||
Value: rawIDToken,
|
||||
MaxAge: cfg.Auth.OIDC.SessionMaxAge,
|
||||
Secure: cfg.Auth.OIDC.CookieSecure,
|
||||
HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly,
|
||||
SameSite: cfg.Auth.OIDC.CookieSameSite,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
})
|
||||
|
||||
// Logout endpoint
|
||||
authRoutes.Post("/logout", func(c fiber.Ctx) error {
|
||||
// Clear session cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: cfg.Auth.OIDC.CookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"message": "Logged out successfully",
|
||||
})
|
||||
})
|
||||
|
||||
// User info endpoint
|
||||
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
|
||||
userInfo := c.Locals("userInfo")
|
||||
if userInfo == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
)
|
||||
|
||||
// GarageAdminService handles interactions with the Garage Admin API
|
||||
type GarageAdminService struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *azuretls.Session
|
||||
}
|
||||
|
||||
// NewGarageAdminService creates a new Garage Admin API service
|
||||
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
|
||||
session := azuretls.NewSession()
|
||||
session.Log()
|
||||
|
||||
return &GarageAdminService{
|
||||
baseURL: cfg.AdminEndpoint,
|
||||
token: cfg.AdminToken,
|
||||
httpClient: session,
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request to the Admin API
|
||||
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
|
||||
return s.httpClient.Do(&azuretls.Request{
|
||||
Method: method,
|
||||
Url: s.baseURL + path,
|
||||
Body: body,
|
||||
IgnoreBody: true, // decodeResponse will handle body reading
|
||||
OrderedHeaders: azuretls.OrderedHeaders{
|
||||
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
|
||||
},
|
||||
}, ctx)
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure
|
||||
func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Access Key Operations
|
||||
// ====================================
|
||||
|
||||
// ListKeys returns all access keys in the cluster
|
||||
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result []models.ListKeysResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateKey creates a new API access key
|
||||
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetKeyInfo returns information about a specific access key
|
||||
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
|
||||
if showSecret {
|
||||
path += "&showSecretKey=true"
|
||||
}
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateKey updates information about an access key
|
||||
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteKey deletes an access key from the cluster
|
||||
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
|
||||
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportKey imports an existing API access key
|
||||
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Operations (Admin API)
|
||||
// ====================================
|
||||
|
||||
// ListBuckets returns all buckets in the cluster
|
||||
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result []models.ListBucketsResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetBucketInfo returns detailed information about a bucket by ID
|
||||
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
|
||||
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket via the Admin API
|
||||
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateBucket updates bucket settings
|
||||
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Operations
|
||||
// ====================================
|
||||
|
||||
// AddBucketAlias adds an alias to a bucket
|
||||
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RemoveBucketAlias removes an alias from a bucket
|
||||
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Operations
|
||||
// ====================================
|
||||
|
||||
// AllowBucketKey grants permissions for a key on a bucket
|
||||
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DenyBucketKey revokes permissions for a key on a bucket
|
||||
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Operations
|
||||
// ====================================
|
||||
|
||||
// GetClusterHealth returns the health status of the cluster
|
||||
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.ClusterHealth
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetClusterStatus returns the current status of the cluster
|
||||
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.ClusterStatus
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetClusterStatistics returns global cluster statistics
|
||||
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.ClusterStatistics
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Operations
|
||||
// ====================================
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.MultiNodeResponse
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetNodeStatistics returns statistics for a specific node
|
||||
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.MultiNodeResponse
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Monitoring Operations
|
||||
// ====================================
|
||||
|
||||
// HealthCheck checks if the Admin API is reachable
|
||||
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("health check returned error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetrics returns Prometheus metrics from the Admin API
|
||||
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.RawBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return string(bodyBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
// S3Service handles all S3 operations with Garage
|
||||
type S3Service struct {
|
||||
client *s3.Client
|
||||
config *config.GarageConfig
|
||||
adminService *GarageAdminService
|
||||
}
|
||||
|
||||
// NewS3Service creates a new S3 service instance
|
||||
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
|
||||
// Create AWS credentials from Garage config (default/fallback credentials)
|
||||
creds := credentials.NewStaticCredentialsProvider(
|
||||
cfg.AccessKey,
|
||||
cfg.SecretKey,
|
||||
"", // session token (not used for Garage)
|
||||
)
|
||||
|
||||
// Configure S3 client for Garage
|
||||
s3Config := aws.Config{
|
||||
Region: cfg.Region,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
// Create S3 client with custom endpoint resolver for Garage
|
||||
client := s3.NewFromConfig(s3Config, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||
o.UsePathStyle = cfg.ForcePathStyle
|
||||
})
|
||||
|
||||
return &S3Service{
|
||||
client: client,
|
||||
config: cfg,
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// getBucketCredentials retrieves credentials for a specific bucket
|
||||
// It checks the cache first, then queries the Garage Admin API
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (aws.CredentialsProvider, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s", bucketName)
|
||||
cacheData := utils.GlobalCache.Get(cacheKey)
|
||||
|
||||
if cacheData != nil {
|
||||
return cacheData.(aws.CredentialsProvider), nil
|
||||
}
|
||||
|
||||
// Get bucket info from Garage Admin API
|
||||
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bucket info: %w", err)
|
||||
}
|
||||
|
||||
// Find a key with read and write permissions
|
||||
var accessKeyID, secretAccessKey string
|
||||
for _, keyInfo := range bucketInfo.Keys {
|
||||
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get key details with secret
|
||||
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get key info: %w", err)
|
||||
}
|
||||
|
||||
if keyDetails.SecretAccessKey != nil {
|
||||
accessKeyID = keyDetails.AccessKeyID
|
||||
secretAccessKey = *keyDetails.SecretAccessKey
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if accessKeyID == "" || secretAccessKey == "" {
|
||||
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// Create credentials provider
|
||||
credential := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")
|
||||
|
||||
// Cache credentials for 1 hour
|
||||
utils.GlobalCache.Set(cacheKey, credential, time.Hour)
|
||||
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
// getS3Client creates an S3 client for a specific bucket with dynamic credentials
|
||||
func (s *S3Service) getS3Client(ctx context.Context, bucketName string) (*s3.Client, error) {
|
||||
creds, err := s.getBucketCredentials(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// AWS config
|
||||
awsConfig := aws.Config{
|
||||
Credentials: creds,
|
||||
Region: s.config.Region,
|
||||
}
|
||||
|
||||
// Build S3 client with BaseEndpoint for Garage
|
||||
client := s3.NewFromConfig(awsConfig, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(s.config.Endpoint)
|
||||
o.UsePathStyle = s.config.ForcePathStyle
|
||||
o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{
|
||||
URL: s.config.Endpoint,
|
||||
SigningRegion: s.config.Region,
|
||||
}, nil
|
||||
})
|
||||
})
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ListBuckets retrieves all buckets from Garage
|
||||
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
|
||||
// Call S3 ListBuckets API
|
||||
result, err := s.client.ListBuckets(ctx, &s3.ListBucketsInput{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
||||
}
|
||||
|
||||
// Convert S3 buckets to our model
|
||||
buckets := make([]models.BucketInfo, 0, len(result.Buckets))
|
||||
for _, bucket := range result.Buckets {
|
||||
buckets = append(buckets, models.BucketInfo{
|
||||
Name: aws.ToString(bucket.Name),
|
||||
CreationDate: aws.ToTime(bucket.CreationDate),
|
||||
})
|
||||
}
|
||||
|
||||
return &models.BucketListResponse{
|
||||
Buckets: buckets,
|
||||
Count: len(buckets),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket in Garage
|
||||
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
input := &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
}
|
||||
|
||||
// Call S3 CreateBucket API
|
||||
_, err = client.CreateBucket(ctx, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket from Garage
|
||||
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call S3 DeleteBucket API
|
||||
_, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListObjects lists objects in a bucket with optional prefix filter and pagination
|
||||
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
|
||||
// Get bucket-specific S3 client
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Set default max keys if not specified
|
||||
if maxKeys <= 0 {
|
||||
maxKeys = 100
|
||||
}
|
||||
|
||||
// Create list objects input
|
||||
input := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucketName),
|
||||
Delimiter: aws.String("/"),
|
||||
MaxKeys: aws.Int32(int32(maxKeys)),
|
||||
}
|
||||
|
||||
if prefix != "" {
|
||||
input.Prefix = aws.String(prefix)
|
||||
}
|
||||
|
||||
if continuationToken != "" {
|
||||
input.ContinuationToken = aws.String(continuationToken)
|
||||
}
|
||||
|
||||
// Call S3 ListObjectsV2 API
|
||||
result, err := client.ListObjectsV2(ctx, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Convert S3 objects to our model
|
||||
objects := make([]models.ObjectInfo, 0, len(result.Contents))
|
||||
for _, obj := range result.Contents {
|
||||
objects = append(objects, models.ObjectInfo{
|
||||
Key: aws.ToString(obj.Key),
|
||||
Size: aws.ToInt64(obj.Size),
|
||||
LastModified: aws.ToTime(obj.LastModified),
|
||||
ETag: aws.ToString(obj.ETag),
|
||||
StorageClass: string(obj.StorageClass),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract common prefixes (folders/directories)
|
||||
prefixes := make([]string, 0, len(result.CommonPrefixes))
|
||||
for _, p := range result.CommonPrefixes {
|
||||
prefixes = append(prefixes, aws.ToString(p.Prefix))
|
||||
}
|
||||
|
||||
return &models.ObjectListResponse{
|
||||
Bucket: bucketName,
|
||||
Objects: objects,
|
||||
Prefixes: prefixes,
|
||||
Count: len(objects),
|
||||
IsTruncated: aws.ToBool(result.IsTruncated),
|
||||
NextContinuationToken: aws.ToString(result.NextContinuationToken),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadObject uploads an object to a bucket
|
||||
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||
// Get bucket-specific S3 client
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Create put object input
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
Body: body,
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
input.ContentType = aws.String(contentType)
|
||||
}
|
||||
|
||||
// Call S3 PutObject API
|
||||
result, err := client.PutObject(ctx, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
// Get object metadata to return size
|
||||
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
|
||||
var size int64
|
||||
if err == nil {
|
||||
size = aws.ToInt64(headResult.ContentLength)
|
||||
}
|
||||
|
||||
return &models.ObjectUploadResponse{
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
ETag: aws.ToString(result.ETag),
|
||||
Size: size,
|
||||
ContentType: contentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
// Call S3 GetObject API
|
||||
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
// Create object info
|
||||
objectInfo := &models.ObjectInfo{
|
||||
Key: key,
|
||||
Size: aws.ToInt64(result.ContentLength),
|
||||
LastModified: aws.ToTime(result.LastModified),
|
||||
ETag: aws.ToString(result.ETag),
|
||||
ContentType: aws.ToString(result.ContentType),
|
||||
}
|
||||
|
||||
return result.Body, objectInfo, nil
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
// Call S3 DeleteObject API
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObjectExists checks if an object exists in a bucket
|
||||
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
||||
// Get bucket-specific S3 client
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
_, err = client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetObjectMetadata retrieves metadata for an object without downloading it
|
||||
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
|
||||
// Get bucket-specific S3 client
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
result, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return &models.ObjectInfo{
|
||||
Key: key,
|
||||
Size: aws.ToInt64(result.ContentLength),
|
||||
LastModified: aws.ToTime(result.LastModified),
|
||||
ETag: aws.ToString(result.ETag),
|
||||
ContentType: aws.ToString(result.ContentType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get bucket-specific S3 client
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Create delete objects for batch deletion
|
||||
objects := make([]types.ObjectIdentifier, len(keys))
|
||||
for i, key := range keys {
|
||||
objects[i] = types.ObjectIdentifier{
|
||||
Key: aws.String(key),
|
||||
}
|
||||
}
|
||||
|
||||
// Call S3 DeleteObjects API (batch delete)
|
||||
_, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Delete: &types.Delete{
|
||||
Objects: objects,
|
||||
Quiet: aws.Bool(false),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete multiple objects from bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPresignedURL generates a pre-signed URL for temporary access to an object
|
||||
// This is useful for sharing files without exposing credentials
|
||||
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
|
||||
// Get bucket-specific S3 client
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Create presign client
|
||||
presignClient := s3.NewPresignClient(client)
|
||||
|
||||
// Generate presigned GET request
|
||||
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(key),
|
||||
}, func(opts *s3.PresignOptions) {
|
||||
opts.Expires = expiresIn
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
|
||||
}
|
||||
|
||||
return presignResult.URL, nil
|
||||
}
|
||||
|
||||
// UploadResult represents the result of a single file upload
|
||||
type UploadResult struct {
|
||||
Key string
|
||||
Success bool
|
||||
Error error
|
||||
ETag string
|
||||
Size int64
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// UploadMultipleObjects uploads multiple objects to a bucket
|
||||
// It handles uploads in batches to respect any S3/Garage limits
|
||||
// Returns results for each file, including both successes and failures
|
||||
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []UploadResult {
|
||||
results := make([]UploadResult, len(files))
|
||||
|
||||
// Get bucket-specific S3 client once for all uploads
|
||||
client, err := s.getS3Client(ctx, bucketName)
|
||||
if err != nil {
|
||||
// If we can't get the client, all uploads fail
|
||||
for i := range files {
|
||||
results[i] = UploadResult{
|
||||
Key: files[i].Key,
|
||||
Success: false,
|
||||
Error: fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err),
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Upload each file
|
||||
for i, file := range files {
|
||||
// Create put object input
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(file.Key),
|
||||
Body: file.Body,
|
||||
}
|
||||
|
||||
if file.ContentType != "" {
|
||||
input.ContentType = aws.String(file.ContentType)
|
||||
}
|
||||
|
||||
// Attempt upload
|
||||
result, err := client.PutObject(ctx, input)
|
||||
if err != nil {
|
||||
results[i] = UploadResult{
|
||||
Key: file.Key,
|
||||
Success: false,
|
||||
Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err),
|
||||
ContentType: file.ContentType,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Get object metadata to return size
|
||||
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(file.Key),
|
||||
})
|
||||
|
||||
var size int64
|
||||
if err == nil {
|
||||
size = aws.ToInt64(headResult.ContentLength)
|
||||
}
|
||||
|
||||
results[i] = UploadResult{
|
||||
Key: file.Key,
|
||||
Success: true,
|
||||
Error: nil,
|
||||
ETag: aws.ToString(result.ETag),
|
||||
Size: size,
|
||||
ContentType: file.ContentType,
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// BucketStatistics holds statistical information about a bucket
|
||||
type BucketStatistics struct {
|
||||
ObjectCount int64
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// GetBucketStatistics retrieves bucket statistics from Garage Admin API
|
||||
// This is much more efficient than iterating through all objects
|
||||
func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) {
|
||||
// Get bucket info from Garage Admin API which includes object count and size
|
||||
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Return statistics from Admin API
|
||||
return &BucketStatistics{
|
||||
ObjectCount: bucketInfo.Objects,
|
||||
TotalSize: bucketInfo.Bytes,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/routes"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
)
|
||||
|
||||
// @title Garage UI API
|
||||
// @version 0.1.0
|
||||
// @description REST API for managing Garage distributed object storage system
|
||||
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
// @contact.email support@garage-ui.io
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
// @schemes http https
|
||||
|
||||
// @tag.name Health
|
||||
// @tag.description Health check endpoints
|
||||
|
||||
// @tag.name Buckets
|
||||
// @tag.description Bucket management operations
|
||||
|
||||
// @tag.name Objects
|
||||
// @tag.description Object storage and retrieval operations
|
||||
|
||||
// @tag.name Users
|
||||
// @tag.description User and access key management
|
||||
|
||||
// @tag.name Cluster
|
||||
// @tag.description Cluster status and node management
|
||||
|
||||
// @tag.name Monitoring
|
||||
// @tag.description Monitoring and metrics endpoints
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags
|
||||
configPath := flag.String("config", "config.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
log.Printf("Loading configuration from: %s", *configPath)
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
|
||||
|
||||
// Initialize services
|
||||
log.Println("Initializing Garage Admin service...")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage)
|
||||
|
||||
log.Println("Initializing S3 service...")
|
||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||
|
||||
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
|
||||
authService, err := auth.NewAuthService(&cfg.Auth)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize auth service: %v", err)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
|
||||
objectHandler := handlers.NewObjectHandler(s3Service)
|
||||
userHandler := handlers.NewUserHandler(adminService)
|
||||
clusterHandler := handlers.NewClusterHandler(adminService)
|
||||
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
|
||||
|
||||
// Create Fiber app with configuration
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
//DisableStartupMessage: false,
|
||||
//EnablePrintRoutes: cfg.IsDevelopment(),
|
||||
ErrorHandler: customErrorHandler,
|
||||
})
|
||||
|
||||
// Apply global middleware
|
||||
app.Use(recover.New()) // Panic recovery
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
|
||||
}))
|
||||
|
||||
// Setup routes
|
||||
log.Println("Setting up routes...")
|
||||
routes.SetupRoutes(
|
||||
app,
|
||||
cfg,
|
||||
authService,
|
||||
healthHandler,
|
||||
bucketHandler,
|
||||
objectHandler,
|
||||
userHandler,
|
||||
clusterHandler,
|
||||
monitoringHandler,
|
||||
)
|
||||
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
addr := cfg.GetAddress()
|
||||
log.Printf("Server listening on %s", addr)
|
||||
log.Printf("Health check available at: http://%s/health", addr)
|
||||
log.Printf("API documentation: http://%s/api/v1/", addr)
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
if err := app.Shutdown(); err != nil {
|
||||
log.Fatalf("Server shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped gracefully")
|
||||
}
|
||||
|
||||
// customErrorHandler handles errors globally
|
||||
func customErrorHandler(c fiber.Ctx, err error) error {
|
||||
// Default to 500 Internal Server Error
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
// Log the error
|
||||
log.Printf("Error: %v", err)
|
||||
|
||||
// Return JSON error response
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"error": fiber.Map{
|
||||
"code": fmt.Sprintf("ERROR_%d", code),
|
||||
"message": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheItem represents a cached item with expiration
|
||||
type CacheItem struct {
|
||||
Value interface{}
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
// Cache represents a simple in-memory cache with expiration
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]CacheItem
|
||||
}
|
||||
|
||||
// NewCache creates a new cache instance
|
||||
func NewCache() *Cache {
|
||||
c := &Cache{
|
||||
items: make(map[string]CacheItem),
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go c.cleanupExpired()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *Cache) Get(key string) interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
item, exists := c.items[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if item has expired
|
||||
if time.Now().After(item.Expiration) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return item.Value
|
||||
}
|
||||
|
||||
// Set stores a value in the cache with an expiration duration
|
||||
func (c *Cache) Set(key string, value interface{}, duration time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.items[key] = CacheItem{
|
||||
Value: value,
|
||||
Expiration: time.Now().Add(duration),
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a value from the cache
|
||||
func (c *Cache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
delete(c.items, key)
|
||||
}
|
||||
|
||||
// Clear removes all items from the cache
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.items = make(map[string]CacheItem)
|
||||
}
|
||||
|
||||
// cleanupExpired periodically removes expired items
|
||||
func (c *Cache) cleanupExpired() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, item := range c.items {
|
||||
if now.After(item.Expiration) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Global cache instance
|
||||
var GlobalCache = NewCache()
|
||||
Reference in New Issue
Block a user