mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
add initial project setup
This commit is contained in:
+33
-3
@@ -27,6 +27,36 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Editor/IDE
|
# Logs
|
||||||
# .idea/
|
logs
|
||||||
# .vscode/
|
*.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
|
||||||
|
|
||||||
|
frontend/docs
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 S3 service...")
|
||||||
|
s3Service := services.NewS3Service(&cfg.Garage)
|
||||||
|
|
||||||
|
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(s3Service)
|
||||||
|
objectHandler := handlers.NewObjectHandler(s3Service)
|
||||||
|
userHandler := handlers.NewUserHandler()
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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,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/svg+xml" href="/garage.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+5046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"@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",
|
||||||
|
"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,24 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Layout } from '@/components/layout/layout';
|
||||||
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
|
import { Buckets } from '@/pages/Buckets';
|
||||||
|
import { AccessControl } from '@/pages/AccessControl';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="garage-ui-theme">
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="buckets" element={<Buckets />} />
|
||||||
|
<Route path="access" element={<AccessControl />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,65 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { PieChart, Pie, Cell, Legend, Tooltip, ResponsiveContainer } 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, displaySize }) => `${name}: ${displaySize}`}
|
||||||
|
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import type { ClusterHealth } from '@/types';
|
||||||
|
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } 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.healthyStorageNodes,
|
||||||
|
unhealthy: data.declaredStorageNodes - data.healthyStorageNodes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: 'Partitions',
|
||||||
|
healthy: data.healthyPartitions,
|
||||||
|
unhealthy: data.totalPartitions - data.healthyPartitions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 bg-background">
|
||||||
|
<div className="flex h-16 items-center gap-4 px-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative 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,13 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './sidebar';
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Database,
|
||||||
|
Key,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
} 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: 'Access Control',
|
||||||
|
href: '/access',
|
||||||
|
icon: Key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Analytics',
|
||||||
|
href: '/analytics',
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings',
|
||||||
|
href: '/settings',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-64 flex-col border-r bg-card">
|
||||||
|
<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}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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,44 @@
|
|||||||
|
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]',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
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,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 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-full max-w-lg">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{ backgroundColor: 'hsl(var(--background))' }}
|
||||||
|
className={cn(
|
||||||
|
'relative 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 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<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 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-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = 'DialogDescription';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DropdownMenuContextValue {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }, _ref) => {
|
||||||
|
const { open, setOpen, triggerRef } = useDropdownMenu();
|
||||||
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
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 alignmentClasses = {
|
||||||
|
start: 'left-0',
|
||||||
|
end: 'right-0',
|
||||||
|
center: 'left-1/2 -translate-x-1/2',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
style={{ backgroundColor: 'hsl(var(--popover))' }}
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 mt-2 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
|
||||||
|
alignmentClasses[align],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="py-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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,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,103 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 98%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 28 100% 58%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 28 100% 58%;
|
||||||
|
--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 {
|
||||||
|
/* VS Code Dark Blue Theme */
|
||||||
|
--background: 222 14% 11%;
|
||||||
|
--foreground: 220 13% 91%;
|
||||||
|
--card: 222 13% 16%;
|
||||||
|
--card-foreground: 220 13% 91%;
|
||||||
|
--popover: 222 13% 18%;
|
||||||
|
--popover-foreground: 220 13% 91%;
|
||||||
|
--primary: 28 100% 58%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 222 13% 23%;
|
||||||
|
--secondary-foreground: 220 13% 91%;
|
||||||
|
--muted: 222 13% 30%;
|
||||||
|
--muted-foreground: 220 9% 64%;
|
||||||
|
--accent: 28 100% 58%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 15 86% 56%;
|
||||||
|
--destructive-foreground: 220 13% 91%;
|
||||||
|
--border: 222 13% 23%;
|
||||||
|
--input: 222 13% 23%;
|
||||||
|
--ring: 28 100% 58%;
|
||||||
|
|
||||||
|
/* 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: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(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: hsl(var(--muted-foreground) / 0.2);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
Bucket,
|
||||||
|
BucketDetails,
|
||||||
|
S3Object,
|
||||||
|
ObjectMetadata,
|
||||||
|
AccessKey,
|
||||||
|
StorageMetrics,
|
||||||
|
ClusterHealth,
|
||||||
|
ClusterStatistics,
|
||||||
|
NodeInfo,
|
||||||
|
GarageMetrics,
|
||||||
|
} 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bucket API
|
||||||
|
export const bucketsApi = {
|
||||||
|
list: async (): Promise<Bucket[]> => {
|
||||||
|
// const response = await api.get('/buckets');
|
||||||
|
// return response.data;
|
||||||
|
return mockBuckets; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (_name: string): Promise<BucketDetails> => {
|
||||||
|
// const response = await api.get(`/buckets/${_name}`);
|
||||||
|
// return response.data;
|
||||||
|
return mockBucketDetails; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (bucketName: string, bucketRegion?: string): Promise<void> => {
|
||||||
|
// await api.post('/buckets', { name: bucketName, region: bucketRegion });
|
||||||
|
console.log('Create bucket:', bucketName, bucketRegion);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (name: string): Promise<void> => {
|
||||||
|
// await api.delete(`/buckets/${name}`);
|
||||||
|
console.log('Delete bucket:', name);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
|
||||||
|
// await api.put(`/buckets/${name}/settings`, settings);
|
||||||
|
console.log('Update bucket settings:', name, settings);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Objects API
|
||||||
|
export const objectsApi = {
|
||||||
|
list: async (_bucket: string, _prefix?: string): Promise<S3Object[]> => {
|
||||||
|
// const response = await api.get(`/buckets/${_bucket}/objects`, { params: { prefix: _prefix } });
|
||||||
|
// return response.data;
|
||||||
|
|
||||||
|
// Filter mock objects by prefix
|
||||||
|
if (!_prefix) {
|
||||||
|
return mockObjectsFlat.filter(obj => !obj.key.includes('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all objects with this prefix, but only direct children
|
||||||
|
const prefixObjects = mockObjectsFlat.filter(obj => obj.key.startsWith(_prefix));
|
||||||
|
const directChildren = new Set<string>();
|
||||||
|
|
||||||
|
prefixObjects.forEach(obj => {
|
||||||
|
const remaining = obj.key.slice(_prefix.length);
|
||||||
|
const parts = remaining.split('/');
|
||||||
|
|
||||||
|
if (parts.length > 0 && parts[0]) {
|
||||||
|
if (parts.length === 1 && !remaining.endsWith('/')) {
|
||||||
|
// It's a file at this level
|
||||||
|
directChildren.add(obj.key);
|
||||||
|
} else if (parts.length > 1 || remaining.endsWith('/')) {
|
||||||
|
// It's a folder, add the folder path
|
||||||
|
const folderPath = _prefix + parts[0] + '/';
|
||||||
|
directChildren.add(folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return unique objects
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return Array.from(directChildren)
|
||||||
|
.map(key => {
|
||||||
|
if (seen.has(key)) return null;
|
||||||
|
seen.add(key);
|
||||||
|
return mockObjectsFlat.find(obj => obj.key === key) || {
|
||||||
|
key,
|
||||||
|
size: 0,
|
||||||
|
lastModified: new Date().toISOString(),
|
||||||
|
isFolder: key.endsWith('/'),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((obj): obj is S3Object => obj !== null);
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (_bucket: string, _key: string): Promise<Blob> => {
|
||||||
|
// const response = await api.get(`/buckets/${_bucket}/objects/${_key}`, { responseType: 'blob' });
|
||||||
|
// return response.data;
|
||||||
|
return new Blob(); // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
getMetadata: async (_bucket: string, _key: string): Promise<ObjectMetadata> => {
|
||||||
|
// const response = await api.get(`/buckets/${_bucket}/objects/${_key}/metadata`);
|
||||||
|
// return response.data;
|
||||||
|
return mockObjectMetadata; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
upload: async (bucket: string, key: string, file: File): Promise<void> => {
|
||||||
|
// const formData = new FormData();
|
||||||
|
// formData.append('file', file);
|
||||||
|
// await api.post(`/buckets/${bucket}/objects/${key}`, formData, {
|
||||||
|
// headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
// });
|
||||||
|
console.log('Upload object:', bucket, key, file);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (bucket: string, key: string): Promise<void> => {
|
||||||
|
// await api.delete(`/buckets/${bucket}/objects/${key}`);
|
||||||
|
console.log('Delete object:', bucket, key);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMultiple: async (bucket: string, keys: string[]): Promise<void> => {
|
||||||
|
// await api.post(`/buckets/${bucket}/objects/delete`, { keys });
|
||||||
|
console.log('Delete multiple objects:', bucket, keys);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPresignedUrl: async (_bucket: string, _key: string, _expiresIn?: number): Promise<string> => {
|
||||||
|
// const response = await api.post(`/buckets/${_bucket}/objects/${_key}/presign`, { expiresIn: _expiresIn });
|
||||||
|
// return response.data.url;
|
||||||
|
return 'https://example.com/presigned-url'; // Using mock data for now
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Access Control API
|
||||||
|
export const accessApi = {
|
||||||
|
listKeys: async (): Promise<AccessKey[]> => {
|
||||||
|
// const response = await api.get('/access/keys');
|
||||||
|
// return response.data;
|
||||||
|
return mockAccessKeys; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
createKey: async (name: string, permissions: any[]): Promise<AccessKey> => {
|
||||||
|
// const response = await api.post('/access/keys', { name, permissions });
|
||||||
|
// return response.data;
|
||||||
|
console.log('Create access key:', name, permissions);
|
||||||
|
return mockAccessKeys[0]; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
updateKey: async (keyId: string, updates: Partial<AccessKey>): Promise<void> => {
|
||||||
|
// await api.put(`/access/keys/${keyId}`, updates);
|
||||||
|
console.log('Update access key:', keyId, updates);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteKey: async (keyId: string): Promise<void> => {
|
||||||
|
// await api.delete(`/access/keys/${keyId}`);
|
||||||
|
console.log('Delete access key:', keyId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analytics API
|
||||||
|
export const analyticsApi = {
|
||||||
|
getMetrics: async (): Promise<StorageMetrics> => {
|
||||||
|
// const response = await api.get('/analytics/metrics');
|
||||||
|
// return response.data;
|
||||||
|
return mockMetrics; // Using mock data for now
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Garage Admin API
|
||||||
|
export const garageApi = {
|
||||||
|
getClusterHealth: async (): Promise<ClusterHealth> => {
|
||||||
|
// const response = await api.get('/v2/GetClusterHealth');
|
||||||
|
// return response.data;
|
||||||
|
return mockClusterHealth; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
getClusterStatistics: async (): Promise<ClusterStatistics> => {
|
||||||
|
// const response = await api.get('/v2/GetClusterStatistics');
|
||||||
|
// return response.data;
|
||||||
|
return mockClusterStatistics; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
getNodeInfo: async (): Promise<NodeInfo> => {
|
||||||
|
// const response = await api.get('/v2/GetNodeInfo?node=self');
|
||||||
|
// return response.data;
|
||||||
|
return mockNodeInfo; // Using mock data for now
|
||||||
|
},
|
||||||
|
|
||||||
|
getFullMetrics: async (): Promise<GarageMetrics> => {
|
||||||
|
// Fetch all cluster-related metrics
|
||||||
|
const [health, statistics, nodeInfo, storageMetrics] = await Promise.all([
|
||||||
|
garageApi.getClusterHealth(),
|
||||||
|
garageApi.getClusterStatistics(),
|
||||||
|
garageApi.getNodeInfo(),
|
||||||
|
analyticsApi.getMetrics(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...storageMetrics,
|
||||||
|
clusterHealth: health,
|
||||||
|
clusterStatistics: statistics,
|
||||||
|
nodeInfo: nodeInfo,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
const mockBuckets: Bucket[] = [
|
||||||
|
{
|
||||||
|
name: 'production-assets',
|
||||||
|
creationDate: '2025-01-15T10:30:00Z',
|
||||||
|
objectCount: 1247,
|
||||||
|
size: 524288000,
|
||||||
|
region: 'us-east-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user-uploads',
|
||||||
|
creationDate: '2025-01-10T14:20:00Z',
|
||||||
|
objectCount: 3892,
|
||||||
|
size: 1073741824,
|
||||||
|
region: 'us-east-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backups',
|
||||||
|
creationDate: '2025-01-05T08:15:00Z',
|
||||||
|
objectCount: 156,
|
||||||
|
size: 2147483648,
|
||||||
|
region: 'us-west-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'logs',
|
||||||
|
creationDate: '2024-12-20T16:45:00Z',
|
||||||
|
objectCount: 8934,
|
||||||
|
size: 314572800,
|
||||||
|
region: 'eu-west-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'media-cdn',
|
||||||
|
creationDate: '2024-12-15T11:00:00Z',
|
||||||
|
objectCount: 5621,
|
||||||
|
size: 3221225472,
|
||||||
|
region: 'ap-southeast-1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockBucketDetails: BucketDetails = {
|
||||||
|
...mockBuckets[0],
|
||||||
|
versioning: true,
|
||||||
|
encryption: true,
|
||||||
|
publicAccess: false,
|
||||||
|
lifecycleRules: [
|
||||||
|
{
|
||||||
|
id: 'rule-1',
|
||||||
|
enabled: true,
|
||||||
|
prefix: 'temp/',
|
||||||
|
expirationDays: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockObjectsFlat: S3Object[] = [
|
||||||
|
// Root level files
|
||||||
|
{
|
||||||
|
key: 'config.json',
|
||||||
|
size: 2048,
|
||||||
|
lastModified: '2025-01-22T09:15:00Z',
|
||||||
|
etag: 'abc123',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'README.md',
|
||||||
|
size: 4096,
|
||||||
|
lastModified: '2025-01-21T14:20:00Z',
|
||||||
|
etag: 'def456',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'app.log',
|
||||||
|
size: 1048576,
|
||||||
|
lastModified: '2025-01-24T11:45:00Z',
|
||||||
|
etag: 'ghi789',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
// Folder entries
|
||||||
|
{
|
||||||
|
key: 'images/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-20T10:00:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-19T15:30:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-18T14:00:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-17T08:30:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
// Images subfolder
|
||||||
|
{
|
||||||
|
key: 'images/avatar.png',
|
||||||
|
size: 256000,
|
||||||
|
lastModified: '2025-01-20T10:15:00Z',
|
||||||
|
etag: 'img001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'images/logo.svg',
|
||||||
|
size: 12288,
|
||||||
|
lastModified: '2025-01-20T10:20:00Z',
|
||||||
|
etag: 'img002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'images/banner.jpg',
|
||||||
|
size: 512000,
|
||||||
|
lastModified: '2025-01-20T10:25:00Z',
|
||||||
|
etag: 'img003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'images/thumbnails/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-20T11:00:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
// Images/thumbnails subfolder
|
||||||
|
{
|
||||||
|
key: 'images/thumbnails/avatar-small.png',
|
||||||
|
size: 32000,
|
||||||
|
lastModified: '2025-01-20T11:05:00Z',
|
||||||
|
etag: 'thumb001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'images/thumbnails/logo-small.svg',
|
||||||
|
size: 4096,
|
||||||
|
lastModified: '2025-01-20T11:10:00Z',
|
||||||
|
etag: 'thumb002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'images/thumbnails/banner-small.jpg',
|
||||||
|
size: 64000,
|
||||||
|
lastModified: '2025-01-20T11:15:00Z',
|
||||||
|
etag: 'thumb003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
// Documents subfolder
|
||||||
|
{
|
||||||
|
key: 'documents/report-2025-01.pdf',
|
||||||
|
size: 2048000,
|
||||||
|
lastModified: '2025-01-19T16:00:00Z',
|
||||||
|
etag: 'doc001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents/report-2025-02.pdf',
|
||||||
|
size: 2156000,
|
||||||
|
lastModified: '2025-01-19T16:10:00Z',
|
||||||
|
etag: 'doc002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents/contract.docx',
|
||||||
|
size: 128000,
|
||||||
|
lastModified: '2025-01-19T16:20:00Z',
|
||||||
|
etag: 'doc003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents/spreadsheet.xlsx',
|
||||||
|
size: 256000,
|
||||||
|
lastModified: '2025-01-19T16:30:00Z',
|
||||||
|
etag: 'doc004',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents/archives/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-19T17:00:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
// Documents/archives subfolder
|
||||||
|
{
|
||||||
|
key: 'documents/archives/2024-reports.zip',
|
||||||
|
size: 10485760,
|
||||||
|
lastModified: '2025-01-19T17:15:00Z',
|
||||||
|
etag: 'arch001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'documents/archives/2023-reports.zip',
|
||||||
|
size: 9437184,
|
||||||
|
lastModified: '2025-01-19T17:20:00Z',
|
||||||
|
etag: 'arch002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
// Videos subfolder
|
||||||
|
{
|
||||||
|
key: 'videos/tutorial-intro.mp4',
|
||||||
|
size: 104857600,
|
||||||
|
lastModified: '2025-01-18T14:30:00Z',
|
||||||
|
etag: 'vid001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos/tutorial-advanced.mp4',
|
||||||
|
size: 157286400,
|
||||||
|
lastModified: '2025-01-18T14:35:00Z',
|
||||||
|
etag: 'vid002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos/demo.webm',
|
||||||
|
size: 52428800,
|
||||||
|
lastModified: '2025-01-18T14:40:00Z',
|
||||||
|
etag: 'vid003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos/clips/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-18T15:00:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
// Videos/clips subfolder
|
||||||
|
{
|
||||||
|
key: 'videos/clips/intro.mp4',
|
||||||
|
size: 20971520,
|
||||||
|
lastModified: '2025-01-18T15:10:00Z',
|
||||||
|
etag: 'clip001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos/clips/outro.mp4',
|
||||||
|
size: 20971520,
|
||||||
|
lastModified: '2025-01-18T15:15:00Z',
|
||||||
|
etag: 'clip002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos/clips/transition.mp4',
|
||||||
|
size: 10485760,
|
||||||
|
lastModified: '2025-01-18T15:20:00Z',
|
||||||
|
etag: 'clip003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
// Backups subfolder
|
||||||
|
{
|
||||||
|
key: 'backups/db-backup-2025-01-24.sql',
|
||||||
|
size: 536870912,
|
||||||
|
lastModified: '2025-01-24T03:00:00Z',
|
||||||
|
etag: 'backup001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/db-backup-2025-01-23.sql',
|
||||||
|
size: 524288000,
|
||||||
|
lastModified: '2025-01-23T03:00:00Z',
|
||||||
|
etag: 'backup002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/app-backup-2025-01-24.tar.gz',
|
||||||
|
size: 1073741824,
|
||||||
|
lastModified: '2025-01-24T03:15:00Z',
|
||||||
|
etag: 'backup003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/daily/',
|
||||||
|
size: 0,
|
||||||
|
lastModified: '2025-01-24T03:30:00Z',
|
||||||
|
isFolder: true,
|
||||||
|
},
|
||||||
|
// Backups/daily subfolder
|
||||||
|
{
|
||||||
|
key: 'backups/daily/db-backup-2025-01-24-00.sql',
|
||||||
|
size: 268435456,
|
||||||
|
lastModified: '2025-01-24T00:30:00Z',
|
||||||
|
etag: 'daily001',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/daily/db-backup-2025-01-24-06.sql',
|
||||||
|
size: 268435456,
|
||||||
|
lastModified: '2025-01-24T06:30:00Z',
|
||||||
|
etag: 'daily002',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/daily/db-backup-2025-01-24-12.sql',
|
||||||
|
size: 268435456,
|
||||||
|
lastModified: '2025-01-24T12:30:00Z',
|
||||||
|
etag: 'daily003',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/daily/db-backup-2025-01-24-18.sql',
|
||||||
|
size: 268435456,
|
||||||
|
lastModified: '2025-01-24T18:30:00Z',
|
||||||
|
etag: 'daily004',
|
||||||
|
storageClass: 'STANDARD',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockObjectMetadata: ObjectMetadata = {
|
||||||
|
key: 'config.json',
|
||||||
|
size: 2048,
|
||||||
|
lastModified: '2025-01-22T09:15:00Z',
|
||||||
|
contentType: 'application/json',
|
||||||
|
etag: 'abc123',
|
||||||
|
metadata: {
|
||||||
|
'x-amz-meta-author': 'admin',
|
||||||
|
'x-amz-meta-version': '1.0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAccessKeys: AccessKey[] = [
|
||||||
|
{
|
||||||
|
accessKeyId: 'GK1234567890ABCDEF',
|
||||||
|
name: 'Production API Key',
|
||||||
|
createdAt: '2025-01-10T10:00:00Z',
|
||||||
|
lastUsed: '2025-01-24T14:30:00Z',
|
||||||
|
status: 'active',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
resource: 'production-assets/*',
|
||||||
|
actions: ['GetObject', 'PutObject'],
|
||||||
|
effect: 'Allow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessKeyId: 'GK0987654321FEDCBA',
|
||||||
|
name: 'Backup Service',
|
||||||
|
createdAt: '2025-01-05T08:00:00Z',
|
||||||
|
lastUsed: '2025-01-24T02:00:00Z',
|
||||||
|
status: 'active',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
resource: 'backups/*',
|
||||||
|
actions: ['GetObject', 'PutObject', 'DeleteObject'],
|
||||||
|
effect: 'Allow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessKeyId: 'GK5555666677778888',
|
||||||
|
name: 'Legacy Integration',
|
||||||
|
createdAt: '2024-11-15T12:00:00Z',
|
||||||
|
status: 'inactive',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
resource: 'user-uploads/*',
|
||||||
|
actions: ['GetObject'],
|
||||||
|
effect: 'Allow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockMetrics: StorageMetrics = {
|
||||||
|
totalSize: 7282384896,
|
||||||
|
objectCount: 19850,
|
||||||
|
bucketCount: 5,
|
||||||
|
usageByBucket: [
|
||||||
|
{
|
||||||
|
bucketName: 'media-cdn',
|
||||||
|
size: 3221225472,
|
||||||
|
objectCount: 5621,
|
||||||
|
percentage: 44.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bucketName: 'backups',
|
||||||
|
size: 2147483648,
|
||||||
|
objectCount: 156,
|
||||||
|
percentage: 29.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bucketName: 'user-uploads',
|
||||||
|
size: 1073741824,
|
||||||
|
objectCount: 3892,
|
||||||
|
percentage: 14.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bucketName: 'production-assets',
|
||||||
|
size: 524288000,
|
||||||
|
objectCount: 1247,
|
||||||
|
percentage: 7.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bucketName: 'logs',
|
||||||
|
size: 314572800,
|
||||||
|
objectCount: 8934,
|
||||||
|
percentage: 4.3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestMetrics: {
|
||||||
|
getRequests: 145678,
|
||||||
|
putRequests: 12456,
|
||||||
|
deleteRequests: 3421,
|
||||||
|
listRequests: 8934,
|
||||||
|
period: 'last-24h',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClusterHealth = {
|
||||||
|
status: 'Healthy',
|
||||||
|
connectedNodes: 3,
|
||||||
|
knownNodes: 3,
|
||||||
|
healthyStorageNodes: 3,
|
||||||
|
declaredStorageNodes: 3,
|
||||||
|
healthyPartitions: 256,
|
||||||
|
totalPartitions: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClusterStatistics = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
uptime: 864000000,
|
||||||
|
freeform: 'Cluster operating normally',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockNodeInfo = {
|
||||||
|
nodeId: 'node-001',
|
||||||
|
version: '1.0.0',
|
||||||
|
rustVersion: '1.75.0',
|
||||||
|
uptime: 864000,
|
||||||
|
dbSize: 1073741824,
|
||||||
|
blockReferenceTableSize: 536870912,
|
||||||
|
blockMetricsTableSize: 268435456,
|
||||||
|
objectTableSize: 134217728,
|
||||||
|
objectVersionTableSize: 67108864,
|
||||||
|
bucketTableSize: 33554432,
|
||||||
|
bucketAliasTableSize: 16777216,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,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,409 @@
|
|||||||
|
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 { accessApi } from '@/lib/api';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import type { AccessKey, Permission } from '@/types';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
Search,
|
||||||
|
Key,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldX,
|
||||||
|
Copy,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export function AccessControl() {
|
||||||
|
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||||
|
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [selectedKey, setSelectedKey] = useState<AccessKey | null>(null);
|
||||||
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
|
const [newKeyResource, setNewKeyResource] = useState('*');
|
||||||
|
const [newKeyActions, setNewKeyActions] = useState<string[]>(['GetObject']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchKeys = async () => {
|
||||||
|
const data = await accessApi.listKeys();
|
||||||
|
setKeys(data);
|
||||||
|
setFilteredKeys(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
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) return;
|
||||||
|
|
||||||
|
const permissions: Permission[] = [
|
||||||
|
{
|
||||||
|
resource: newKeyResource,
|
||||||
|
actions: newKeyActions,
|
||||||
|
effect: 'Allow',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await accessApi.createKey(newKeyName, permissions);
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewKeyName('');
|
||||||
|
setNewKeyResource('*');
|
||||||
|
setNewKeyActions(['GetObject']);
|
||||||
|
|
||||||
|
// Refresh keys list
|
||||||
|
const data = await accessApi.listKeys();
|
||||||
|
setKeys(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async () => {
|
||||||
|
if (!selectedKey) return;
|
||||||
|
|
||||||
|
await accessApi.deleteKey(selectedKey.accessKeyId);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSelectedKey(null);
|
||||||
|
|
||||||
|
// Refresh keys list
|
||||||
|
const data = await accessApi.listKeys();
|
||||||
|
setKeys(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleKeyStatus = async (key: AccessKey) => {
|
||||||
|
const newStatus = key.status === 'active' ? 'inactive' : 'active';
|
||||||
|
await accessApi.updateKey(key.accessKeyId, { status: newStatus });
|
||||||
|
|
||||||
|
// Refresh keys list
|
||||||
|
const data = await accessApi.listKeys();
|
||||||
|
setKeys(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableActions = [
|
||||||
|
'GetObject',
|
||||||
|
'PutObject',
|
||||||
|
'DeleteObject',
|
||||||
|
'ListBucket',
|
||||||
|
'GetBucketLocation',
|
||||||
|
'CreateBucket',
|
||||||
|
'DeleteBucket',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header
|
||||||
|
title="Access Control"
|
||||||
|
/>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<Tabs defaultValue="keys">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="keys">API Keys</TabsTrigger>
|
||||||
|
<TabsTrigger value="policies">Bucket Policies</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="keys" className="space-y-6 mt-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md: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 items-center justify-between">
|
||||||
|
<div className="relative w-80">
|
||||||
|
<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={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keys Table */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Access Key ID</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Last Used</TableHead>
|
||||||
|
<TableHead>Permissions</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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">{key.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded">
|
||||||
|
{key.accessKeyId}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => navigator.clipboard.writeText(key.accessKeyId)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={key.status === 'active' ? 'default' : 'secondary'}>
|
||||||
|
{key.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(key.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{key.permissions.slice(0, 2).map((perm, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{perm.actions.length} action{perm.actions.length > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{key.permissions.length > 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{key.permissions.length - 2} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleToggleKeyStatus(key)}>
|
||||||
|
{key.status === 'active' ? (
|
||||||
|
<>
|
||||||
|
<ShieldX className="mr-2 h-4 w-4" />
|
||||||
|
Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</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 specific 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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Resource</label>
|
||||||
|
<Input
|
||||||
|
placeholder="bucket-name/* or *"
|
||||||
|
value={newKeyResource}
|
||||||
|
onChange={(e) => setNewKeyResource(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Specify which resources this key can access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Actions</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{availableActions.map((action) => (
|
||||||
|
<label key={action} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newKeyActions.includes(action)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setNewKeyActions([...newKeyActions, action]);
|
||||||
|
} else {
|
||||||
|
setNewKeyActions(newKeyActions.filter((a) => a !== action));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
{action}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateKey} disabled={!newKeyName || newKeyActions.length === 0}>
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
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 { bucketsApi, objectsApi } from '@/lib/api';
|
||||||
|
import { formatBytes, formatDate } from '@/lib/utils';
|
||||||
|
import type { Bucket, S3Object } from '@/types';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Trash2,
|
||||||
|
Settings,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
FolderIcon,
|
||||||
|
FileIcon,
|
||||||
|
Download,
|
||||||
|
ChevronRight,
|
||||||
|
Home,
|
||||||
|
ArrowLeft,
|
||||||
|
Eye,
|
||||||
|
RotateCwIcon,
|
||||||
|
FolderPlus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export function Buckets() {
|
||||||
|
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||||
|
const [filteredBuckets, setFilteredBuckets] = useState<Bucket[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false);
|
||||||
|
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
|
||||||
|
const [newBucketName, setNewBucketName] = useState('');
|
||||||
|
const [showCreatePreview, setShowCreatePreview] = useState(false);
|
||||||
|
|
||||||
|
// Objects state
|
||||||
|
const [viewingBucket, setViewingBucket] = useState<string | null>(null);
|
||||||
|
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||||
|
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>('');
|
||||||
|
const [objectSearchQuery, setObjectSearchQuery] = useState('');
|
||||||
|
const [showUploadZone, setShowUploadZone] = useState(false);
|
||||||
|
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
|
||||||
|
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
|
||||||
|
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
|
||||||
|
const [newDirName, setNewDirName] = useState('');
|
||||||
|
|
||||||
|
// Main area drag & drop
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop: async (acceptedFiles) => {
|
||||||
|
await uploadFiles(acceptedFiles);
|
||||||
|
setShowUploadZone(false);
|
||||||
|
},
|
||||||
|
noClick: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// File upload handler
|
||||||
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
if (!viewingBucket) return;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const key = currentPath ? `${currentPath}${file.name}` : file.name;
|
||||||
|
await objectsApi.upload(viewingBucket, key, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh objects list
|
||||||
|
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||||
|
setObjects(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBuckets = async () => {
|
||||||
|
const data = await bucketsApi.list();
|
||||||
|
setBuckets(data);
|
||||||
|
setFilteredBuckets(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchBuckets();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filtered = buckets.filter((bucket) =>
|
||||||
|
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredBuckets(filtered);
|
||||||
|
}, [searchQuery, buckets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewingBucket) {
|
||||||
|
const fetchObjects = async () => {
|
||||||
|
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||||
|
setObjects(data);
|
||||||
|
setFilteredObjects(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchObjects();
|
||||||
|
}
|
||||||
|
}, [viewingBucket, currentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filtered = objects.filter((obj) =>
|
||||||
|
obj.key.toLowerCase().includes(objectSearchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredObjects(filtered);
|
||||||
|
}, [objectSearchQuery, objects]);
|
||||||
|
|
||||||
|
const handleCreateBucket = async () => {
|
||||||
|
if (!newBucketName) return;
|
||||||
|
|
||||||
|
await bucketsApi.create(newBucketName);
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewBucketName('');
|
||||||
|
setShowCreatePreview(false);
|
||||||
|
|
||||||
|
// Refresh bucket list
|
||||||
|
const data = await bucketsApi.list();
|
||||||
|
setBuckets(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBucket = async () => {
|
||||||
|
if (!selectedBucket) return;
|
||||||
|
|
||||||
|
await bucketsApi.delete(selectedBucket.name);
|
||||||
|
setDeleteBucketDialogOpen(false);
|
||||||
|
setSelectedBucket(null);
|
||||||
|
|
||||||
|
// Refresh bucket list
|
||||||
|
const data = await bucketsApi.list();
|
||||||
|
setBuckets(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewBucket = (bucketName: string) => {
|
||||||
|
setViewingBucket(bucketName);
|
||||||
|
setCurrentPath('');
|
||||||
|
setObjectSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToBuckets = () => {
|
||||||
|
setViewingBucket(null);
|
||||||
|
setCurrentPath('');
|
||||||
|
setObjectSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigateToFolder = (folderKey: string) => {
|
||||||
|
setCurrentPath(folderKey);
|
||||||
|
setObjectSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteObject = async () => {
|
||||||
|
if (!selectedObject || !viewingBucket) return;
|
||||||
|
|
||||||
|
await objectsApi.delete(viewingBucket, selectedObject.key);
|
||||||
|
setDeleteObjectDialogOpen(false);
|
||||||
|
setSelectedObject(null);
|
||||||
|
|
||||||
|
// Refresh objects list
|
||||||
|
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||||
|
setObjects(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshObjects = async () => {
|
||||||
|
if (!viewingBucket) return;
|
||||||
|
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||||
|
setObjects(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateDirectory = async () => {
|
||||||
|
if (!newDirName || !viewingBucket) return;
|
||||||
|
|
||||||
|
// Create a directory by uploading an empty object with a trailing slash
|
||||||
|
const dirKey = currentPath ? `${currentPath}${newDirName}/` : `${newDirName}/`;
|
||||||
|
await objectsApi.upload(viewingBucket, dirKey, new File([], ''));
|
||||||
|
|
||||||
|
setCreateDirDialogOpen(false);
|
||||||
|
setNewDirName('');
|
||||||
|
|
||||||
|
// Refresh objects list
|
||||||
|
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||||
|
setObjects(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBreadcrumbs = () => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If viewing a bucket's objects, show the objects view
|
||||||
|
if (viewingBucket) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header
|
||||||
|
title={`Objects in ${viewingBucket}`}
|
||||||
|
/>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Back Button */}
|
||||||
|
<Button variant="outline" onClick={handleBackToBuckets}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Buckets
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Breadcrumb Navigation */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Home className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{getBreadcrumbs().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={() => setCurrentPath(crumb.path)}
|
||||||
|
className={
|
||||||
|
index === getBreadcrumbs().length - 1
|
||||||
|
? 'font-medium'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="relative w-80">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search objects..."
|
||||||
|
value={objectSearchQuery}
|
||||||
|
onChange={(e) => setObjectSearchQuery(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={() => setShowUploadZone(!showUploadZone)}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreateDirDialogOpen(true)}>
|
||||||
|
<FolderPlus className="mr-2 h-4 w-4" />
|
||||||
|
Create Directory
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" onClick={handleRefreshObjects} title="Refresh">
|
||||||
|
<RotateCwIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Zone */}
|
||||||
|
{showUploadZone && (
|
||||||
|
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Upload SVG Icon */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Upload Content */}
|
||||||
|
<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 or{' '}
|
||||||
|
<label
|
||||||
|
htmlFor="file-folder-input"
|
||||||
|
className="font-medium text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
select from computer
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="file-folder-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
uploadFiles(files);
|
||||||
|
setShowUploadZone(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Objects Table with Drag & Drop */}
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`border rounded-lg transition-colors ${
|
||||||
|
isDragActive
|
||||||
|
? 'border-primary bg-primary/5 border-2'
|
||||||
|
: 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Size</TableHead>
|
||||||
|
<TableHead>Last Modified</TableHead>
|
||||||
|
<TableHead>Storage Class</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredObjects.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||||
|
{objectSearchQuery
|
||||||
|
? '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>
|
||||||
|
<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={() => handleNavigateToFolder(obj.key)}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">
|
||||||
|
{obj.key.replace(currentPath, '')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{obj.isFolder ? '---' : formatBytes(obj.size)}</TableCell>
|
||||||
|
<TableCell>{formatDate(obj.lastModified)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{obj.storageClass && (
|
||||||
|
<Badge variant="secondary">{obj.storageClass}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!obj.isFolder && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
{/*make the button not affecting the row height*/}
|
||||||
|
<Button variant="ghost" size="icon" className={"-m-3 top-1 relative"}>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedObject(obj);
|
||||||
|
setDeleteObjectDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Directory Dialog */}
|
||||||
|
<Dialog open={createDirDialogOpen} onOpenChange={setCreateDirDialogOpen}>
|
||||||
|
<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={newDirName}
|
||||||
|
onChange={(e) => setNewDirName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateDirectory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDirDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateDirectory} disabled={!newDirName}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Object Dialog */}
|
||||||
|
<Dialog open={deleteObjectDialogOpen} onOpenChange={setDeleteObjectDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Object</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete "{selectedObject?.key}"? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteObjectDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteObject}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: show buckets list
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header title="Buckets" />
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative w-80">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search buckets..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buckets Table */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Region</TableHead>
|
||||||
|
<TableHead>Objects</TableHead>
|
||||||
|
<TableHead>Size</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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={() => handleViewBucket(bucket.name)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{bucket.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{bucket.objectCount?.toLocaleString() || 0}</TableCell>
|
||||||
|
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
|
||||||
|
<TableCell>{formatDate(bucket.creationDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewBucket(bucket.name);
|
||||||
|
}}>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
View Objects
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedBucket(bucket);
|
||||||
|
setDeleteBucketDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Bucket Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<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={newBucketName}
|
||||||
|
onChange={(e) => setNewBucketName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Must be unique and follow DNS naming conventions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCreatePreview(true)}
|
||||||
|
disabled={!newBucketName}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateBucket} disabled={!newBucketName}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Create Bucket Preview Dialog */}
|
||||||
|
<Dialog open={showCreatePreview} onOpenChange={setShowCreatePreview}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bucket Preview</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Review the bucket configuration before creating
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Bucket Name</p>
|
||||||
|
<p className="text-sm font-medium">{newBucketName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Status</p>
|
||||||
|
<Badge variant="outline">Ready to Create</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCreatePreview(false)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateBucket}>
|
||||||
|
Confirm & Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Bucket Dialog */}
|
||||||
|
<Dialog open={deleteBucketDialogOpen} onOpenChange={setDeleteBucketDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Bucket</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete "{selectedBucket?.name}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteBucketDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteBucket}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Header } from '@/components/layout/header';
|
||||||
|
import { garageApi, analyticsApi, bucketsApi } from '@/lib/api';
|
||||||
|
import { formatBytes } from '@/lib/utils';
|
||||||
|
import type { GarageMetrics, Bucket, ClusterHealth } from '@/types';
|
||||||
|
import { Database, FolderOpen, HardDrive, Activity, Server, Zap, AlertCircle } from 'lucide-react';
|
||||||
|
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
|
||||||
|
import { RequestMetricsChart } from '@/components/charts/RequestMetricsChart';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [metrics, setMetrics] = useState<GarageMetrics | null>(null);
|
||||||
|
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||||
|
const [clusterHealth, setClusterHealth] = useState<ClusterHealth | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [garageMetrics, bucketsData, health] = await Promise.all([
|
||||||
|
garageApi.getFullMetrics(),
|
||||||
|
bucketsApi.list(),
|
||||||
|
garageApi.getClusterHealth(),
|
||||||
|
]);
|
||||||
|
setMetrics(garageMetrics);
|
||||||
|
setBuckets(bucketsData);
|
||||||
|
setClusterHealth(health);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getHealthStatus = (health: ClusterHealth | null) => {
|
||||||
|
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
|
||||||
|
if (
|
||||||
|
health.healthyStorageNodes === health.declaredStorageNodes &&
|
||||||
|
health.healthyPartitions === health.totalPartitions &&
|
||||||
|
health.connectedNodes === health.knownNodes
|
||||||
|
) {
|
||||||
|
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
health.healthyStorageNodes > 0 &&
|
||||||
|
health.healthyPartitions > 0
|
||||||
|
) {
|
||||||
|
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
|
||||||
|
}
|
||||||
|
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthStatus = getHealthStatus(clusterHealth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header title="Dashboard" />
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Top Stats Grid */}
|
||||||
|
<div className="grid gap-4 md: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">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>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Requests (24h)</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{metrics
|
||||||
|
? (
|
||||||
|
metrics.requestMetrics.getRequests +
|
||||||
|
metrics.requestMetrics.putRequests +
|
||||||
|
metrics.requestMetrics.deleteRequests +
|
||||||
|
metrics.requestMetrics.listRequests
|
||||||
|
).toLocaleString()
|
||||||
|
: '---'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
GET, PUT, DELETE, LIST
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cluster Status */}
|
||||||
|
<div className="grid gap-4 md: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?.healthyStorageNodes || 0}/{clusterHealth?.declaredStorageNodes || 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?.healthyPartitions || 0}/{clusterHealth?.totalPartitions || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Healthy partitions
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row 1 */}
|
||||||
|
<div className="grid gap-4 md: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>
|
||||||
|
|
||||||
|
{/* Request Metrics Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Request Metrics</CardTitle>
|
||||||
|
<CardDescription>API request distribution (24h)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{metrics?.requestMetrics ? (
|
||||||
|
<RequestMetricsChart data={metrics.requestMetrics} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No data available</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<span className="font-medium">{bucket.bucketName}</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Database className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{bucket.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium">{bucket.objectCount?.toLocaleString()} objects</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{bucket.size ? formatBytes(bucket.size) : '---'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// 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 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: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
healthyStorageNodes: number;
|
||||||
|
declaredStorageNodes: number;
|
||||||
|
healthyPartitions: number;
|
||||||
|
totalPartitions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusterStatistics {
|
||||||
|
timestamp: number;
|
||||||
|
uptime: number;
|
||||||
|
freeform: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(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,51 @@
|
|||||||
|
module Noooste/garage-ui
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.40.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.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
|
||||||
|
golang.org/x/oauth2 v0.33.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
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.4.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // 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.9.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
|
||||||
|
github.com/aws/smithy-go v1.23.2 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // 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/uuid v1.6.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/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/tinylib/msgp v1.5.0 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
|
golang.org/x/net v0.47.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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.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.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw=
|
||||||
|
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.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.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.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.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.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.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
|
||||||
|
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/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-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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
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/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/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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
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/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/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.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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
|
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/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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
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,302 @@
|
|||||||
|
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,188 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,175 @@
|
|||||||
|
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 {
|
||||||
|
s3Service *services.S3Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBucketHandler creates a new bucket handler
|
||||||
|
func NewBucketHandler(s3Service *services.S3Service) *BucketHandler {
|
||||||
|
return &BucketHandler{
|
||||||
|
s3Service: s3Service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBuckets returns all buckets
|
||||||
|
// GET /api/v1/buckets
|
||||||
|
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||||
|
ctx := c.Context()
|
||||||
|
|
||||||
|
// List all buckets from Garage
|
||||||
|
buckets, err := h.s3Service.ListBuckets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(models.SuccessResponse(buckets))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBucket creates a new bucket
|
||||||
|
// POST /api/v1/buckets
|
||||||
|
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
|
||||||
|
exists, err := h.s3Service.BucketExists(ctx, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the bucket
|
||||||
|
if err := h.s3Service.CreateBucket(ctx, req.Name); 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
|
||||||
|
// DELETE /api/v1/buckets/:name
|
||||||
|
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 exists
|
||||||
|
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the bucket
|
||||||
|
if err := h.s3Service.DeleteBucket(ctx, bucketName); 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
|
||||||
|
// GET /api/v1/buckets/:name
|
||||||
|
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 exists
|
||||||
|
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all buckets to find this one and get its info
|
||||||
|
buckets, err := h.s3Service.ListBuckets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the specific bucket
|
||||||
|
for _, bucket := range buckets.Buckets {
|
||||||
|
if bucket.Name == bucketName {
|
||||||
|
return c.JSON(models.SuccessResponse(bucket))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
|
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,386 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"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 returns all objects in a bucket
|
||||||
|
// GET /api/v1/buckets/:bucket/objects
|
||||||
|
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
|
||||||
|
prefix := c.Query("prefix", "")
|
||||||
|
maxKeys := c.QueryInt("max_keys", 1000)
|
||||||
|
|
||||||
|
// Check if bucket exists
|
||||||
|
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List objects in the bucket
|
||||||
|
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys)
|
||||||
|
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 a file to a bucket
|
||||||
|
// POST /api/v1/buckets/:bucket/objects
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bucket exists
|
||||||
|
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 downloads an object from a bucket
|
||||||
|
// GET /api/v1/buckets/:bucket/objects/:key
|
||||||
|
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
|
||||||
|
// DELETE /api/v1/buckets/:bucket/objects/:key
|
||||||
|
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
|
||||||
|
// HEAD /api/v1/buckets/:bucket/objects/:key
|
||||||
|
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 temporary pre-signed URL for an object
|
||||||
|
// POST /api/v1/buckets/:bucket/objects/:key/presign
|
||||||
|
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)
|
||||||
|
expiresIn := c.QueryInt("expires_in", 3600)
|
||||||
|
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 := map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
"expires_in": expiresIn,
|
||||||
|
"bucket": bucketName,
|
||||||
|
"key": key,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(models.SuccessResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||||
|
// DELETE /api/v1/buckets/:bucket/objects
|
||||||
|
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
|
||||||
|
var req struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
}
|
||||||
|
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 := map[string]interface{}{
|
||||||
|
"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)
|
||||||
|
// PUT /api/v1/buckets/:bucket/objects/:key
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bucket exists
|
||||||
|
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Noooste/garage-ui/internal/models"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserHandler handles user/key management operations
|
||||||
|
// Note: Garage user management typically requires administrative API access
|
||||||
|
// This is a placeholder implementation that you'll need to extend based on
|
||||||
|
// your Garage setup and how you manage keys/users
|
||||||
|
type UserHandler struct {
|
||||||
|
// In a real implementation, you might have a Garage admin client here
|
||||||
|
// or interact with Garage's administrative API
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserHandler creates a new user handler
|
||||||
|
func NewUserHandler() *UserHandler {
|
||||||
|
return &UserHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all users/keys
|
||||||
|
// GET /api/v1/users
|
||||||
|
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
|
||||||
|
// NOTE: This is a placeholder implementation
|
||||||
|
// Garage manages keys/users through its administrative RPC interface
|
||||||
|
// You'll need to implement this based on your Garage setup
|
||||||
|
|
||||||
|
// For now, return a not implemented response
|
||||||
|
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError,
|
||||||
|
"User management not yet implemented. Requires Garage Admin API integration."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user/key pair
|
||||||
|
// POST /api/v1/users
|
||||||
|
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||||
|
// NOTE: This is a placeholder implementation
|
||||||
|
// To implement this, you need to:
|
||||||
|
// 1. Connect to Garage's admin RPC interface
|
||||||
|
// 2. Call the appropriate key creation endpoint
|
||||||
|
// 3. Return the generated access key and secret key
|
||||||
|
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError,
|
||||||
|
"User creation not yet implemented. Requires Garage Admin API integration."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user/key
|
||||||
|
// DELETE /api/v1/users/:access_key
|
||||||
|
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||||
|
// NOTE: This is a placeholder implementation
|
||||||
|
accessKey := c.Params("access_key")
|
||||||
|
|
||||||
|
if accessKey == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError,
|
||||||
|
"User deletion not yet implemented. Requires Garage Admin API integration."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns information about a specific user/key
|
||||||
|
// GET /api/v1/users/:access_key
|
||||||
|
func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||||
|
accessKey := c.Params("access_key")
|
||||||
|
|
||||||
|
if accessKey == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError,
|
||||||
|
"User info not yet implemented. Requires Garage Admin API integration."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPermissions updates user permissions
|
||||||
|
// PATCH /api/v1/users/:access_key
|
||||||
|
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeInternalError,
|
||||||
|
"User permission update not yet implemented. Requires Garage Admin API integration."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
IMPLEMENTATION NOTES FOR USER MANAGEMENT:
|
||||||
|
|
||||||
|
Garage uses an administrative RPC interface for managing keys and buckets.
|
||||||
|
To implement user management, you need to:
|
||||||
|
|
||||||
|
1. Install garage-admin client or use HTTP RPC calls
|
||||||
|
2. Connect to Garage's admin port (typically 3903)
|
||||||
|
3. Implement the following operations:
|
||||||
|
|
||||||
|
- List keys: GET /v1/key
|
||||||
|
- Create key: POST /v1/key
|
||||||
|
- Get key info: GET /v1/key?id=<access_key>
|
||||||
|
- Delete key: DELETE /v1/key?id=<access_key>
|
||||||
|
- Update key: POST /v1/key?id=<access_key>
|
||||||
|
|
||||||
|
Example using HTTP client:
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GarageAdminClient struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GarageAdminClient) ListKeys() ([]KeyInfo, error) {
|
||||||
|
req, _ := http.NewRequest("GET", g.baseURL+"/v1/key", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+g.token)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var keys []KeyInfo
|
||||||
|
json.NewDecoder(resp.Body).Decode(&keys)
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
For more information, see:
|
||||||
|
https://garagehq.deuxfleurs.fr/documentation/reference-manual/admin-api/
|
||||||
|
*/
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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,64 @@
|
|||||||
|
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,56 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
AccessKey string `json:"access_key" validate:"required"`
|
||||||
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
IsTruncated bool `json:"is_truncated"`
|
||||||
|
NextMarker string `json:"next_marker,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,170 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
) {
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object routes
|
||||||
|
objects := api.Group("/buckets/:bucket/objects")
|
||||||
|
{
|
||||||
|
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
|
||||||
|
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
||||||
|
objects.Delete("/", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,312 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"Noooste/garage-ui/internal/config"
|
||||||
|
"Noooste/garage-ui/internal/models"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewS3Service creates a new S3 service instance
|
||||||
|
func NewS3Service(cfg *config.GarageConfig) *S3Service {
|
||||||
|
// Create AWS credentials from Garage config
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Create bucket input
|
||||||
|
input := &s3.CreateBucketInput{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call S3 CreateBucket API
|
||||||
|
_, err := s.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 {
|
||||||
|
// Call S3 DeleteBucket API
|
||||||
|
_, err := s.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// BucketExists checks if a bucket exists
|
||||||
|
func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) {
|
||||||
|
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a "not found" error
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListObjects lists objects in a bucket with optional prefix filter
|
||||||
|
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) {
|
||||||
|
// Set default max keys if not specified
|
||||||
|
if maxKeys <= 0 {
|
||||||
|
maxKeys = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create list objects input
|
||||||
|
input := &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
MaxKeys: aws.Int32(int32(maxKeys)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefix != "" {
|
||||||
|
input.Prefix = aws.String(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call S3 ListObjectsV2 API
|
||||||
|
result, err := s.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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ObjectListResponse{
|
||||||
|
Bucket: bucketName,
|
||||||
|
Objects: objects,
|
||||||
|
Count: len(objects),
|
||||||
|
IsTruncated: aws.ToBool(result.IsTruncated),
|
||||||
|
NextMarker: 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) {
|
||||||
|
// 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 := s.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 := s.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) {
|
||||||
|
_, err := s.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) {
|
||||||
|
result, err := s.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := s.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) {
|
||||||
|
// Create presign client
|
||||||
|
presignClient := s3.NewPresignClient(s.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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user