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
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
../.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env*
|
||||
!config.yaml.example
|
||||
docker-compose.*.yml
|
||||
|
||||
data/
|
||||
meta/
|
||||
garage.toml
|
||||
|
||||
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