add function to s3
This commit is contained in:
121
DATASET-MOUNTPOINT-VALIDATION.md
Normal file
121
DATASET-MOUNTPOINT-VALIDATION.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Dataset Mountpoint Validation
|
||||
|
||||
## Issue
|
||||
User meminta validasi bahwa mount point untuk dataset dan volume harus berada di dalam directory pool yang terkait.
|
||||
|
||||
## Solution
|
||||
Menambahkan validasi untuk memastikan mount point dataset berada di dalam pool mount point directory (`/opt/calypso/data/pool/<pool-name>/`).
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Updated `backend/internal/storage/zfs.go`
|
||||
**File**: `backend/internal/storage/zfs.go` (line 728-814)
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
1. **Mount Point Validation**
|
||||
- Validasi bahwa mount point yang user berikan harus berada di dalam pool directory
|
||||
- Menggunakan `filepath.Rel()` untuk memastikan mount point tidak keluar dari pool directory
|
||||
|
||||
2. **Default Mount Point**
|
||||
- Jika mount point tidak disediakan, set default ke `/opt/calypso/data/pool/<pool-name>/<dataset-name>/`
|
||||
- Memastikan semua dataset mount point berada di dalam pool directory
|
||||
|
||||
3. **Mount Point Always Set**
|
||||
- Untuk filesystem datasets, mount point selalu di-set (baik user-provided atau default)
|
||||
- Tidak lagi conditional pada `req.MountPoint != ""`
|
||||
|
||||
**Before:**
|
||||
```go
|
||||
if req.Type == "filesystem" && req.MountPoint != "" {
|
||||
mountPath := filepath.Clean(req.MountPoint)
|
||||
// ... create directory ...
|
||||
}
|
||||
|
||||
// Later:
|
||||
if req.Type == "filesystem" && req.MountPoint != "" {
|
||||
args = append(args, "-o", fmt.Sprintf("mountpoint=%s", req.MountPoint))
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```go
|
||||
poolMountPoint := fmt.Sprintf("/opt/calypso/data/pool/%s", poolName)
|
||||
var mountPath string
|
||||
|
||||
if req.Type == "filesystem" {
|
||||
if req.MountPoint != "" {
|
||||
// Validate mount point is within pool directory
|
||||
mountPath = filepath.Clean(req.MountPoint)
|
||||
// ... validation logic ...
|
||||
} else {
|
||||
// Use default mount point
|
||||
mountPath = filepath.Join(poolMountPoint, req.Name)
|
||||
}
|
||||
// ... create directory ...
|
||||
}
|
||||
|
||||
// Later:
|
||||
if req.Type == "filesystem" {
|
||||
args = append(args, "-o", fmt.Sprintf("mountpoint=%s", mountPath))
|
||||
}
|
||||
```
|
||||
|
||||
## Mount Point Structure
|
||||
|
||||
### Pool Mount Point
|
||||
```
|
||||
/opt/calypso/data/pool/<pool-name>/
|
||||
```
|
||||
|
||||
### Dataset Mount Point (Default)
|
||||
```
|
||||
/opt/calypso/data/pool/<pool-name>/<dataset-name>/
|
||||
```
|
||||
|
||||
### Dataset Mount Point (Custom - must be within pool)
|
||||
```
|
||||
/opt/calypso/data/pool/<pool-name>/<custom-path>/
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. **User-provided mount point**:
|
||||
- Must be within `/opt/calypso/data/pool/<pool-name>/`
|
||||
- Cannot use `..` to escape pool directory
|
||||
- Must be a valid directory path
|
||||
|
||||
2. **Default mount point**:
|
||||
- Automatically set to `/opt/calypso/data/pool/<pool-name>/<dataset-name>/`
|
||||
- Always within pool directory
|
||||
|
||||
3. **Volumes**:
|
||||
- Volumes cannot have mount points (already validated in handler)
|
||||
|
||||
## Error Messages
|
||||
|
||||
- `mount point must be within pool directory: <path> (pool mount: <pool-mount>)` - Jika mount point di luar pool directory
|
||||
- `mount point path exists but is not a directory: <path>` - Jika path sudah ada tapi bukan directory
|
||||
- `failed to create mount directory <path>` - Jika gagal membuat directory
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Create dataset without mount point**:
|
||||
- Should use default: `/opt/calypso/data/pool/<pool-name>/<dataset-name>/`
|
||||
|
||||
2. **Create dataset with valid mount point**:
|
||||
- Mount point: `/opt/calypso/data/pool/<pool-name>/custom-path/`
|
||||
- Should succeed
|
||||
|
||||
3. **Create dataset with invalid mount point**:
|
||||
- Mount point: `/opt/calypso/data/other-path/`
|
||||
- Should fail with validation error
|
||||
|
||||
4. **Create volume**:
|
||||
- Should not set mount point (volumes don't have mount points)
|
||||
|
||||
## Status
|
||||
✅ **COMPLETED** - Mount point validation untuk dataset sudah diterapkan
|
||||
|
||||
## Date
|
||||
2026-01-09
|
||||
236
MINIO-INSTALLATION-RECOMMENDATION.md
Normal file
236
MINIO-INSTALLATION-RECOMMENDATION.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# MinIO Installation Recommendation for Calypso Appliance
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Rekomendasi: Native Installation** ✅
|
||||
|
||||
Untuk Calypso appliance, **native installation** MinIO lebih sesuai daripada Docker karena:
|
||||
1. Konsistensi dengan komponen lain (semua native)
|
||||
2. Performa lebih baik (tanpa overhead container)
|
||||
3. Integrasi lebih mudah dengan ZFS dan systemd
|
||||
4. Sesuai dengan filosofi appliance (minimal dependencies)
|
||||
|
||||
---
|
||||
|
||||
## Analisis Arsitektur Calypso
|
||||
|
||||
### Komponen yang Sudah Terinstall (Semuanya Native)
|
||||
|
||||
| Komponen | Installation Method | Service Management |
|
||||
|----------|-------------------|-------------------|
|
||||
| **ZFS** | Native (kernel modules) | systemd (zfs-zed.service) |
|
||||
| **SCST** | Native (kernel modules) | systemd (scst.service) |
|
||||
| **NFS** | Native (nfs-kernel-server) | systemd (nfs-server.service) |
|
||||
| **SMB** | Native (Samba) | systemd (smbd.service, nmbd.service) |
|
||||
| **ClamAV** | Native (clamav-daemon) | systemd (clamav-daemon.service) |
|
||||
| **MHVTL** | Native (kernel modules) | systemd (mhvtl.target) |
|
||||
| **Bacula** | Native (bacula packages) | systemd (bacula-*.service) |
|
||||
| **PostgreSQL** | Native (postgresql-16) | systemd (postgresql.service) |
|
||||
| **Calypso API** | Native (Go binary) | systemd (calypso-api.service) |
|
||||
|
||||
**Kesimpulan:** Semua komponen menggunakan native installation dan dikelola melalui systemd.
|
||||
|
||||
---
|
||||
|
||||
## Perbandingan: Native vs Docker
|
||||
|
||||
### Native Installation ✅ **RECOMMENDED**
|
||||
|
||||
**Pros:**
|
||||
- ✅ **Konsistensi**: Semua komponen lain native, MinIO juga native
|
||||
- ✅ **Performa**: Tidak ada overhead container, akses langsung ke ZFS
|
||||
- ✅ **Integrasi**: Lebih mudah integrasi dengan ZFS datasets sebagai storage backend
|
||||
- ✅ **Monitoring**: Logs langsung ke journald, metrics mudah diakses
|
||||
- ✅ **Resource**: Lebih efisien (tidak perlu Docker daemon)
|
||||
- ✅ **Security**: Sesuai dengan security model appliance (systemd security hardening)
|
||||
- ✅ **Management**: Dikelola melalui systemd seperti komponen lain
|
||||
- ✅ **Dependencies**: MinIO binary standalone, tidak perlu Docker runtime
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Update: Perlu download binary baru dan restart service
|
||||
- ⚠️ Dependencies: Perlu manage MinIO binary sendiri
|
||||
|
||||
**Mitigation:**
|
||||
- Update bisa diotomasi dengan script
|
||||
- MinIO binary bisa disimpan di `/opt/calypso/bin/` seperti komponen lain
|
||||
|
||||
### Docker Installation ❌ **NOT RECOMMENDED**
|
||||
|
||||
**Pros:**
|
||||
- ✅ Isolasi yang lebih baik
|
||||
- ✅ Update lebih mudah (pull image baru)
|
||||
- ✅ Tidak perlu manage dependencies
|
||||
|
||||
**Cons:**
|
||||
- ❌ **Inkonsistensi**: Semua komponen lain native, Docker akan jadi exception
|
||||
- ❌ **Overhead**: Docker daemon memakan resource (~50-100MB RAM)
|
||||
- ❌ **Kompleksitas**: Tambahan layer management (Docker + systemd)
|
||||
- ❌ **Integrasi**: Lebih sulit integrasi dengan ZFS (perlu volume mapping)
|
||||
- ❌ **Performance**: Overhead container, terutama untuk I/O intensive workload
|
||||
- ❌ **Security**: Tambahan attack surface (Docker daemon)
|
||||
- ❌ **Monitoring**: Logs perlu di-forward dari container ke journald
|
||||
- ❌ **Dependencies**: Perlu install Docker (tidak sesuai filosofi minimal dependencies)
|
||||
|
||||
---
|
||||
|
||||
## Rekomendasi Implementasi
|
||||
|
||||
### Native Installation Setup
|
||||
|
||||
#### 1. Binary Location
|
||||
```
|
||||
/opt/calypso/bin/minio
|
||||
```
|
||||
|
||||
#### 2. Configuration Location
|
||||
```
|
||||
/opt/calypso/conf/minio/
|
||||
├── config.json
|
||||
└── minio.env
|
||||
```
|
||||
|
||||
#### 3. Data Location (ZFS Dataset)
|
||||
```
|
||||
/opt/calypso/data/pool/<pool-name>/object/
|
||||
```
|
||||
|
||||
#### 4. Systemd Service
|
||||
```ini
|
||||
[Unit]
|
||||
Description=MinIO Object Storage
|
||||
After=network.target zfs.target
|
||||
Wants=zfs.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=calypso
|
||||
Group=calypso
|
||||
WorkingDirectory=/opt/calypso
|
||||
ExecStart=/opt/calypso/bin/minio server /opt/calypso/data/pool/%i/object --config-dir /opt/calypso/conf/minio
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=minio
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/calypso/data /opt/calypso/conf/minio /var/log/calypso
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### 5. Integration dengan ZFS
|
||||
- MinIO storage backend menggunakan ZFS dataset
|
||||
- Dataset dibuat di pool yang sudah ada
|
||||
- Mount point: `/opt/calypso/data/pool/<pool-name>/object/`
|
||||
- Manfaatkan ZFS features: compression, snapshots, replication
|
||||
|
||||
---
|
||||
|
||||
## Arsitektur yang Disarankan
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Calypso Appliance │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Calypso API (Go) │ │
|
||||
│ │ Port: 8080 │ │
|
||||
│ └───────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────────────┐ │
|
||||
│ │ MinIO (Native Binary) │ │
|
||||
│ │ Port: 9000, 9001 │ │
|
||||
│ │ Storage: ZFS Dataset │ │
|
||||
│ └───────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────────────┐ │
|
||||
│ │ ZFS Pool │ │
|
||||
│ │ Dataset: object/ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation Steps (Native)
|
||||
|
||||
### 1. Download MinIO Binary
|
||||
```bash
|
||||
# Download latest MinIO binary
|
||||
wget https://dl.min.io/server/minio/release/linux-amd64/minio
|
||||
chmod +x minio
|
||||
sudo mv minio /opt/calypso/bin/
|
||||
sudo chown calypso:calypso /opt/calypso/bin/minio
|
||||
```
|
||||
|
||||
### 2. Create ZFS Dataset for Object Storage
|
||||
```bash
|
||||
# Create dataset in existing pool
|
||||
sudo zfs create <pool-name>/object
|
||||
sudo zfs set mountpoint=/opt/calypso/data/pool/<pool-name>/object <pool-name>/object
|
||||
sudo chown -R calypso:calypso /opt/calypso/data/pool/<pool-name>/object
|
||||
```
|
||||
|
||||
### 3. Create Configuration Directory
|
||||
```bash
|
||||
sudo mkdir -p /opt/calypso/conf/minio
|
||||
sudo chown calypso:calypso /opt/calypso/conf/minio
|
||||
```
|
||||
|
||||
### 4. Create Systemd Service
|
||||
```bash
|
||||
sudo cp /src/calypso/deploy/systemd/minio.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable minio.service
|
||||
sudo systemctl start minio.service
|
||||
```
|
||||
|
||||
### 5. Integration dengan Calypso API
|
||||
- Backend API mengelola MinIO melalui MinIO Admin API atau Go SDK
|
||||
- Configuration disimpan di database Calypso
|
||||
- UI untuk manage buckets, policies, users
|
||||
|
||||
---
|
||||
|
||||
## Kesimpulan
|
||||
|
||||
**Native Installation** adalah pilihan terbaik untuk Calypso appliance karena:
|
||||
|
||||
1. ✅ **Konsistensi**: Semua komponen lain native
|
||||
2. ✅ **Performa**: Optimal untuk I/O intensive workload
|
||||
3. ✅ **Integrasi**: Seamless dengan ZFS dan systemd
|
||||
4. ✅ **Filosofi**: Sesuai dengan "appliance-first" dan "minimal dependencies"
|
||||
5. ✅ **Management**: Unified management melalui systemd
|
||||
6. ✅ **Security**: Sesuai dengan security model appliance
|
||||
|
||||
**Docker Installation** tidak direkomendasikan karena:
|
||||
- ❌ Menambah kompleksitas tanpa benefit yang signifikan
|
||||
- ❌ Inkonsisten dengan arsitektur yang ada
|
||||
- ❌ Overhead yang tidak perlu untuk appliance
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Implementasi native MinIO installation
|
||||
2. ✅ Create systemd service file
|
||||
3. ✅ Integrasi dengan ZFS dataset
|
||||
4. ✅ Backend API integration
|
||||
5. ✅ Frontend UI untuk MinIO management
|
||||
|
||||
---
|
||||
|
||||
## Date
|
||||
2026-01-09
|
||||
193
MINIO-INTEGRATION-COMPLETE.md
Normal file
193
MINIO-INTEGRATION-COMPLETE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# MinIO Integration Complete
|
||||
|
||||
**Tanggal:** 2026-01-09
|
||||
**Status:** ✅ **COMPLETE**
|
||||
|
||||
## Summary
|
||||
|
||||
Integrasi MinIO dengan Calypso appliance telah selesai. Frontend Object Storage page sekarang menggunakan data real dari MinIO service, bukan dummy data lagi.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Backend Integration ✅
|
||||
|
||||
#### Created MinIO Service (`backend/internal/object_storage/service.go`)
|
||||
- **Service**: Menggunakan MinIO Go SDK untuk berinteraksi dengan MinIO server
|
||||
- **Features**:
|
||||
- List buckets dengan informasi detail (size, objects, access policy)
|
||||
- Get bucket statistics
|
||||
- Create bucket
|
||||
- Delete bucket
|
||||
- Get bucket access policy
|
||||
|
||||
#### Created MinIO Handler (`backend/internal/object_storage/handler.go`)
|
||||
- **Handler**: HTTP handlers untuk API endpoints
|
||||
- **Endpoints**:
|
||||
- `GET /api/v1/object-storage/buckets` - List all buckets
|
||||
- `GET /api/v1/object-storage/buckets/:name` - Get bucket info
|
||||
- `POST /api/v1/object-storage/buckets` - Create bucket
|
||||
- `DELETE /api/v1/object-storage/buckets/:name` - Delete bucket
|
||||
|
||||
#### Updated Configuration (`backend/internal/common/config/config.go`)
|
||||
- Added `ObjectStorageConfig` struct untuk MinIO configuration
|
||||
- Fields:
|
||||
- `endpoint`: MinIO server endpoint (default: `localhost:9000`)
|
||||
- `access_key`: MinIO access key
|
||||
- `secret_key`: MinIO secret key
|
||||
- `use_ssl`: Whether to use SSL/TLS
|
||||
|
||||
#### Updated Router (`backend/internal/common/router/router.go`)
|
||||
- Added object storage routes group
|
||||
- Routes protected dengan permission `storage:read` dan `storage:write`
|
||||
- Service initialization dengan error handling
|
||||
|
||||
### 2. Configuration ✅
|
||||
|
||||
#### Updated `/opt/calypso/conf/config.yaml`
|
||||
```yaml
|
||||
# Object Storage (MinIO) Configuration
|
||||
object_storage:
|
||||
endpoint: "localhost:9000"
|
||||
access_key: "admin"
|
||||
secret_key: "HqBX1IINqFynkWFa"
|
||||
use_ssl: false
|
||||
```
|
||||
|
||||
### 3. Frontend Integration ✅
|
||||
|
||||
#### Created API Client (`frontend/src/api/objectStorage.ts`)
|
||||
- **API Client**: TypeScript client untuk object storage API
|
||||
- **Interfaces**:
|
||||
- `Bucket`: Bucket data structure
|
||||
- **Methods**:
|
||||
- `listBuckets()`: Fetch all buckets
|
||||
- `getBucket(name)`: Get bucket details
|
||||
- `createBucket(name)`: Create new bucket
|
||||
- `deleteBucket(name)`: Delete bucket
|
||||
|
||||
#### Updated ObjectStorage Page (`frontend/src/pages/ObjectStorage.tsx`)
|
||||
- **Removed**: Mock data (`MOCK_BUCKETS`)
|
||||
- **Added**: Real API integration dengan React Query
|
||||
- **Features**:
|
||||
- Fetch buckets dari API dengan auto-refresh setiap 5 detik
|
||||
- Transform API data ke format UI
|
||||
- Loading state untuk buckets
|
||||
- Empty state ketika tidak ada buckets
|
||||
- Mutations untuk create/delete bucket
|
||||
- Error handling dengan alerts
|
||||
|
||||
### 4. Dependencies ✅
|
||||
|
||||
#### Added Go Packages
|
||||
- `github.com/minio/minio-go/v7` - MinIO Go SDK
|
||||
- `github.com/minio/madmin-go/v3` - MinIO Admin API
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### List Buckets
|
||||
```http
|
||||
GET /api/v1/object-storage/buckets
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"buckets": [
|
||||
{
|
||||
"name": "my-bucket",
|
||||
"creation_date": "2026-01-09T20:13:27Z",
|
||||
"size": 1024000,
|
||||
"objects": 42,
|
||||
"access_policy": "private"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Bucket
|
||||
```http
|
||||
GET /api/v1/object-storage/buckets/:name
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Create Bucket
|
||||
```http
|
||||
POST /api/v1/object-storage/buckets
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "new-bucket"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Bucket
|
||||
```http
|
||||
DELETE /api/v1/object-storage/buckets/:name
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Test
|
||||
```bash
|
||||
# Test API endpoint
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8080/api/v1/object-storage/buckets
|
||||
```
|
||||
|
||||
### Frontend Test
|
||||
1. Login ke Calypso UI
|
||||
2. Navigate ke "Object Storage" page
|
||||
3. Verify buckets dari MinIO muncul di UI
|
||||
4. Test create bucket (jika ada button)
|
||||
5. Test delete bucket (jika ada button)
|
||||
|
||||
---
|
||||
|
||||
## MinIO Service Status
|
||||
|
||||
**Service:** `minio.service`
|
||||
**Status:** ✅ Running
|
||||
**Endpoint:** `http://localhost:9000` (API), `http://localhost:9001` (Console)
|
||||
**Storage:** `/opt/calypso/data/storage/s3`
|
||||
**Credentials:**
|
||||
- Access Key: `admin`
|
||||
- Secret Key: `HqBX1IINqFynkWFa`
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
1. **Add Create/Delete Bucket UI**: Tambahkan modal/form untuk create/delete bucket dari UI
|
||||
2. **Bucket Policies Management**: UI untuk manage bucket access policies
|
||||
3. **Object Management**: UI untuk browse dan manage objects dalam bucket
|
||||
4. **Bucket Quotas**: Implementasi quota management untuk buckets
|
||||
5. **Bucket Lifecycle**: Implementasi lifecycle policies untuk buckets
|
||||
6. **S3 Users & Keys**: Management untuk S3 access keys (MinIO users)
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Backend
|
||||
- `/src/calypso/backend/internal/object_storage/service.go` (NEW)
|
||||
- `/src/calypso/backend/internal/object_storage/handler.go` (NEW)
|
||||
- `/src/calypso/backend/internal/common/config/config.go` (MODIFIED)
|
||||
- `/src/calypso/backend/internal/common/router/router.go` (MODIFIED)
|
||||
- `/opt/calypso/conf/config.yaml` (MODIFIED)
|
||||
|
||||
### Frontend
|
||||
- `/src/calypso/frontend/src/api/objectStorage.ts` (NEW)
|
||||
- `/src/calypso/frontend/src/pages/ObjectStorage.tsx` (MODIFIED)
|
||||
|
||||
---
|
||||
|
||||
## Date
|
||||
2026-01-09
|
||||
@@ -5,15 +5,19 @@ go 1.24.0
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/minio/madmin-go/v3 v3.0.110
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.23.0
|
||||
golang.org/x/sync v0.7.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -21,30 +25,57 @@ require (
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/prometheus/prom2json v1.4.2 // indirect
|
||||
github.com/prometheus/prometheus v0.303.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/safchain/ethtool v0.5.10 // indirect
|
||||
github.com/secure-io/sio-go v0.3.1 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
135
backend/go.sum
135
backend/go.sum
@@ -2,6 +2,8 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
@@ -9,14 +11,22 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -25,12 +35,17 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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=
|
||||
@@ -38,25 +53,75 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
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/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/madmin-go/v3 v3.0.110 h1:FIYekj7YPc430ffpXFWiUtyut3qBt/unIAcDzJn9H5M=
|
||||
github.com/minio/madmin-go/v3 v3.0.110/go.mod h1:WOe2kYmYl1OIlY2DSRHVQ8j1v4OItARQ6jGyQqcCud8=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/prom2json v1.4.2 h1:PxCTM+Whqi/eykO1MKsEL0p/zMpxp9ybpsmdFamw6po=
|
||||
github.com/prometheus/prom2json v1.4.2/go.mod h1:zuvPm7u3epZSbXPWHny6G+o8ETgu6eAK3oPr6yFkRWE=
|
||||
github.com/prometheus/prometheus v0.303.0 h1:wsNNsbd4EycMCphYnTmNY9JASBVbp7NWwJna857cGpA=
|
||||
github.com/prometheus/prometheus v0.303.0/go.mod h1:8PMRi+Fk1WzopMDeb0/6hbNs9nV6zgySkU/zds5Lu3o=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/safchain/ethtool v0.5.10 h1:Im294gZtuf4pSGJRAOGKaASNi3wMeFaGaWuSaomedpc=
|
||||
github.com/safchain/ethtool v0.5.10/go.mod h1:w9jh2Lx7YBR4UwzLkzCmWl85UY0W2uZdd7/DckVE5+c=
|
||||
github.com/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc=
|
||||
github.com/secure-io/sio-go v0.3.1/go.mod h1:+xbkjDzPjwh4Axd07pRKSNriS9SCiYksWnZqdnfpQxs=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -70,39 +135,57 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -10,11 +10,12 @@ import (
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server configuration
|
||||
@@ -96,6 +97,14 @@ type SecurityHeadersConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
// ObjectStorageConfig holds MinIO configuration
|
||||
type ObjectStorageConfig struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
UseSSL bool `yaml:"use_ssl"`
|
||||
}
|
||||
|
||||
// Load reads configuration from file and environment variables
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration: Object Storage Configuration
|
||||
-- Description: Creates table for storing MinIO object storage configuration
|
||||
-- Date: 2026-01-09
|
||||
|
||||
CREATE TABLE IF NOT EXISTS object_storage_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
dataset_path VARCHAR(255) NOT NULL UNIQUE,
|
||||
mount_point VARCHAR(512) NOT NULL,
|
||||
pool_name VARCHAR(255) NOT NULL,
|
||||
dataset_name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_object_storage_config_pool_name ON object_storage_config(pool_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_object_storage_config_updated_at ON object_storage_config(updated_at);
|
||||
|
||||
COMMENT ON TABLE object_storage_config IS 'Stores MinIO object storage configuration, linking to ZFS datasets';
|
||||
COMMENT ON COLUMN object_storage_config.dataset_path IS 'Full ZFS dataset path (e.g., pool/dataset)';
|
||||
COMMENT ON COLUMN object_storage_config.mount_point IS 'Mount point path for the dataset';
|
||||
COMMENT ON COLUMN object_storage_config.pool_name IS 'ZFS pool name';
|
||||
COMMENT ON COLUMN object_storage_config.dataset_name IS 'ZFS dataset name';
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/atlasos/calypso/internal/iam"
|
||||
"github.com/atlasos/calypso/internal/monitoring"
|
||||
"github.com/atlasos/calypso/internal/object_storage"
|
||||
"github.com/atlasos/calypso/internal/scst"
|
||||
"github.com/atlasos/calypso/internal/shares"
|
||||
"github.com/atlasos/calypso/internal/storage"
|
||||
@@ -211,6 +212,45 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
sharesGroup.DELETE("/:id", requirePermission("storage", "write"), sharesHandler.DeleteShare)
|
||||
}
|
||||
|
||||
// Object Storage (MinIO)
|
||||
// Initialize MinIO service if configured
|
||||
if cfg.ObjectStorage.Endpoint != "" {
|
||||
objectStorageService, err := object_storage.NewService(
|
||||
cfg.ObjectStorage.Endpoint,
|
||||
cfg.ObjectStorage.AccessKey,
|
||||
cfg.ObjectStorage.SecretKey,
|
||||
log,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("Failed to initialize MinIO service", "error", err)
|
||||
} else {
|
||||
objectStorageHandler := object_storage.NewHandler(objectStorageService, db, log)
|
||||
objectStorageGroup := protected.Group("/object-storage")
|
||||
objectStorageGroup.Use(requirePermission("storage", "read"))
|
||||
{
|
||||
// Setup endpoints
|
||||
objectStorageGroup.GET("/setup/datasets", objectStorageHandler.GetAvailableDatasets)
|
||||
objectStorageGroup.GET("/setup/current", objectStorageHandler.GetCurrentSetup)
|
||||
objectStorageGroup.POST("/setup", requirePermission("storage", "write"), objectStorageHandler.SetupObjectStorage)
|
||||
objectStorageGroup.PUT("/setup", requirePermission("storage", "write"), objectStorageHandler.UpdateObjectStorage)
|
||||
|
||||
// Bucket endpoints
|
||||
objectStorageGroup.GET("/buckets", objectStorageHandler.ListBuckets)
|
||||
objectStorageGroup.GET("/buckets/:name", objectStorageHandler.GetBucket)
|
||||
objectStorageGroup.POST("/buckets", requirePermission("storage", "write"), objectStorageHandler.CreateBucket)
|
||||
objectStorageGroup.DELETE("/buckets/:name", requirePermission("storage", "write"), objectStorageHandler.DeleteBucket)
|
||||
// User management routes
|
||||
objectStorageGroup.GET("/users", objectStorageHandler.ListUsers)
|
||||
objectStorageGroup.POST("/users", requirePermission("storage", "write"), objectStorageHandler.CreateUser)
|
||||
objectStorageGroup.DELETE("/users/:access_key", requirePermission("storage", "write"), objectStorageHandler.DeleteUser)
|
||||
// Service account (access key) management routes
|
||||
objectStorageGroup.GET("/service-accounts", objectStorageHandler.ListServiceAccounts)
|
||||
objectStorageGroup.POST("/service-accounts", requirePermission("storage", "write"), objectStorageHandler.CreateServiceAccount)
|
||||
objectStorageGroup.DELETE("/service-accounts/:access_key", requirePermission("storage", "write"), objectStorageHandler.DeleteServiceAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SCST
|
||||
scstHandler := scst.NewHandler(db, log)
|
||||
scstGroup := protected.Group("/scst")
|
||||
@@ -307,8 +347,9 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
systemGroup.GET("/logs", systemHandler.GetSystemLogs)
|
||||
systemGroup.GET("/network/throughput", systemHandler.GetNetworkThroughput)
|
||||
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
||||
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
||||
systemGroup.PUT("/interfaces/:name", systemHandler.UpdateNetworkInterface)
|
||||
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
||||
systemGroup.GET("/management-ip", systemHandler.GetManagementIPAddress)
|
||||
systemGroup.PUT("/interfaces/:name", systemHandler.UpdateNetworkInterface)
|
||||
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
|
||||
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
|
||||
systemGroup.POST("/execute", requirePermission("system", "write"), systemHandler.ExecuteCommand)
|
||||
|
||||
285
backend/internal/object_storage/handler.go
Normal file
285
backend/internal/object_storage/handler.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package object_storage
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for object storage
|
||||
type Handler struct {
|
||||
service *Service
|
||||
setupService *SetupService
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new object storage handler
|
||||
func NewHandler(service *Service, db *database.DB, log *logger.Logger) *Handler {
|
||||
return &Handler{
|
||||
service: service,
|
||||
setupService: NewSetupService(db, log),
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// ListBuckets lists all buckets
|
||||
func (h *Handler) ListBuckets(c *gin.Context) {
|
||||
buckets, err := h.service.ListBuckets(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list buckets", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list buckets: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"buckets": buckets})
|
||||
}
|
||||
|
||||
// GetBucket gets bucket information
|
||||
func (h *Handler) GetBucket(c *gin.Context) {
|
||||
bucketName := c.Param("name")
|
||||
|
||||
bucket, err := h.service.GetBucketStats(c.Request.Context(), bucketName)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get bucket", "bucket", bucketName, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, bucket)
|
||||
}
|
||||
|
||||
// CreateBucketRequest represents a request to create a bucket
|
||||
type CreateBucketRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket
|
||||
func (h *Handler) CreateBucket(c *gin.Context) {
|
||||
var req CreateBucketRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid create bucket request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.CreateBucket(c.Request.Context(), req.Name); err != nil {
|
||||
h.logger.Error("Failed to create bucket", "bucket", req.Name, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "bucket created successfully", "name": req.Name})
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
func (h *Handler) DeleteBucket(c *gin.Context) {
|
||||
bucketName := c.Param("name")
|
||||
|
||||
if err := h.service.DeleteBucket(c.Request.Context(), bucketName); err != nil {
|
||||
h.logger.Error("Failed to delete bucket", "bucket", bucketName, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "bucket deleted successfully"})
|
||||
}
|
||||
|
||||
// GetAvailableDatasets gets all available pools and datasets for object storage setup
|
||||
func (h *Handler) GetAvailableDatasets(c *gin.Context) {
|
||||
datasets, err := h.setupService.GetAvailableDatasets(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get available datasets", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get available datasets: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"pools": datasets})
|
||||
}
|
||||
|
||||
// SetupObjectStorageRequest represents a request to setup object storage
|
||||
type SetupObjectStorageRequest struct {
|
||||
PoolName string `json:"pool_name" binding:"required"`
|
||||
DatasetName string `json:"dataset_name" binding:"required"`
|
||||
CreateNew bool `json:"create_new"`
|
||||
}
|
||||
|
||||
// SetupObjectStorage configures object storage with a ZFS dataset
|
||||
func (h *Handler) SetupObjectStorage(c *gin.Context) {
|
||||
var req SetupObjectStorageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid setup request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
setupReq := SetupRequest{
|
||||
PoolName: req.PoolName,
|
||||
DatasetName: req.DatasetName,
|
||||
CreateNew: req.CreateNew,
|
||||
}
|
||||
|
||||
result, err := h.setupService.SetupObjectStorage(c.Request.Context(), setupReq)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to setup object storage", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to setup object storage: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetCurrentSetup gets the current object storage configuration
|
||||
func (h *Handler) GetCurrentSetup(c *gin.Context) {
|
||||
setup, err := h.setupService.GetCurrentSetup(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get current setup", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get current setup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if setup == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"configured": false})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"configured": true, "setup": setup})
|
||||
}
|
||||
|
||||
// UpdateObjectStorage updates the object storage configuration
|
||||
func (h *Handler) UpdateObjectStorage(c *gin.Context) {
|
||||
var req SetupObjectStorageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid update request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
setupReq := SetupRequest{
|
||||
PoolName: req.PoolName,
|
||||
DatasetName: req.DatasetName,
|
||||
CreateNew: req.CreateNew,
|
||||
}
|
||||
|
||||
result, err := h.setupService.UpdateObjectStorage(c.Request.Context(), setupReq)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to update object storage", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object storage: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ListUsers lists all IAM users
|
||||
func (h *Handler) ListUsers(c *gin.Context) {
|
||||
users, err := h.service.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list users", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
|
||||
// CreateUserRequest represents a request to create a user
|
||||
type CreateUserRequest struct {
|
||||
AccessKey string `json:"access_key" binding:"required"`
|
||||
SecretKey string `json:"secret_key" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateUser creates a new IAM user
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid create user request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.CreateUser(c.Request.Context(), req.AccessKey, req.SecretKey); err != nil {
|
||||
h.logger.Error("Failed to create user", "access_key", req.AccessKey, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "user created successfully", "access_key": req.AccessKey})
|
||||
}
|
||||
|
||||
// DeleteUser deletes an IAM user
|
||||
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
accessKey := c.Param("access_key")
|
||||
|
||||
if err := h.service.DeleteUser(c.Request.Context(), accessKey); err != nil {
|
||||
h.logger.Error("Failed to delete user", "access_key", accessKey, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user deleted successfully"})
|
||||
}
|
||||
|
||||
// ListServiceAccounts lists all service accounts (access keys)
|
||||
func (h *Handler) ListServiceAccounts(c *gin.Context) {
|
||||
accounts, err := h.service.ListServiceAccounts(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list service accounts", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list service accounts: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"service_accounts": accounts})
|
||||
}
|
||||
|
||||
// CreateServiceAccountRequest represents a request to create a service account
|
||||
type CreateServiceAccountRequest struct {
|
||||
ParentUser string `json:"parent_user" binding:"required"`
|
||||
Policy string `json:"policy,omitempty"`
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 format
|
||||
}
|
||||
|
||||
// CreateServiceAccount creates a new service account (access key)
|
||||
func (h *Handler) CreateServiceAccount(c *gin.Context) {
|
||||
var req CreateServiceAccountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid create service account request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var expiration *time.Time
|
||||
if req.Expiration != nil {
|
||||
exp, err := time.Parse(time.RFC3339, *req.Expiration)
|
||||
if err != nil {
|
||||
h.logger.Error("Invalid expiration format", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid expiration format, use ISO 8601 (RFC3339)"})
|
||||
return
|
||||
}
|
||||
expiration = &exp
|
||||
}
|
||||
|
||||
account, err := h.service.CreateServiceAccount(c.Request.Context(), req.ParentUser, req.Policy, expiration)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create service account", "parent_user", req.ParentUser, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create service account: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, account)
|
||||
}
|
||||
|
||||
// DeleteServiceAccount deletes a service account
|
||||
func (h *Handler) DeleteServiceAccount(c *gin.Context) {
|
||||
accessKey := c.Param("access_key")
|
||||
|
||||
if err := h.service.DeleteServiceAccount(c.Request.Context(), accessKey); err != nil {
|
||||
h.logger.Error("Failed to delete service account", "access_key", accessKey, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete service account: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "service account deleted successfully"})
|
||||
}
|
||||
297
backend/internal/object_storage/service.go
Normal file
297
backend/internal/object_storage/service.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package object_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
madmin "github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
// Service handles MinIO object storage operations
|
||||
type Service struct {
|
||||
client *minio.Client
|
||||
adminClient *madmin.AdminClient
|
||||
logger *logger.Logger
|
||||
endpoint string
|
||||
accessKey string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
// NewService creates a new MinIO service
|
||||
func NewService(endpoint, accessKey, secretKey string, log *logger.Logger) (*Service, error) {
|
||||
// Create MinIO client
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: false, // Set to true if using HTTPS
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
|
||||
}
|
||||
|
||||
// Create MinIO Admin client
|
||||
adminClient, err := madmin.New(endpoint, accessKey, secretKey, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MinIO admin client: %w", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
client: minioClient,
|
||||
adminClient: adminClient,
|
||||
logger: log,
|
||||
endpoint: endpoint,
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Bucket represents a MinIO bucket
|
||||
type Bucket struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creation_date"`
|
||||
Size int64 `json:"size"` // Total size in bytes
|
||||
Objects int64 `json:"objects"` // Number of objects
|
||||
AccessPolicy string `json:"access_policy"` // private, public-read, public-read-write
|
||||
}
|
||||
|
||||
// ListBuckets lists all buckets in MinIO
|
||||
func (s *Service) ListBuckets(ctx context.Context) ([]*Bucket, error) {
|
||||
buckets, err := s.client.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*Bucket, 0, len(buckets))
|
||||
for _, bucket := range buckets {
|
||||
bucketInfo, err := s.getBucketInfo(ctx, bucket.Name)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get bucket info", "bucket", bucket.Name, "error", err)
|
||||
// Continue with basic info
|
||||
result = append(result, &Bucket{
|
||||
Name: bucket.Name,
|
||||
CreationDate: bucket.CreationDate,
|
||||
Size: 0,
|
||||
Objects: 0,
|
||||
AccessPolicy: "private",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, bucketInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getBucketInfo gets detailed information about a bucket
|
||||
func (s *Service) getBucketInfo(ctx context.Context, bucketName string) (*Bucket, error) {
|
||||
// Get bucket creation date
|
||||
buckets, err := s.client.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var creationDate time.Time
|
||||
for _, b := range buckets {
|
||||
if b.Name == bucketName {
|
||||
creationDate = b.CreationDate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get bucket size and object count by listing objects
|
||||
var size int64
|
||||
var objects int64
|
||||
|
||||
// List objects in bucket to calculate size and count
|
||||
objectCh := s.client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
|
||||
Recursive: true,
|
||||
})
|
||||
|
||||
for object := range objectCh {
|
||||
if object.Err != nil {
|
||||
s.logger.Warn("Error listing object", "bucket", bucketName, "error", object.Err)
|
||||
continue
|
||||
}
|
||||
objects++
|
||||
size += object.Size
|
||||
}
|
||||
|
||||
return &Bucket{
|
||||
Name: bucketName,
|
||||
CreationDate: creationDate,
|
||||
Size: size,
|
||||
Objects: objects,
|
||||
AccessPolicy: s.getBucketPolicy(ctx, bucketName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getBucketPolicy gets the access policy for a bucket
|
||||
func (s *Service) getBucketPolicy(ctx context.Context, bucketName string) string {
|
||||
policy, err := s.client.GetBucketPolicy(ctx, bucketName)
|
||||
if err != nil {
|
||||
return "private"
|
||||
}
|
||||
|
||||
// Parse policy JSON to determine access type
|
||||
// For simplicity, check if policy allows public read
|
||||
if policy != "" {
|
||||
// Check if policy contains public read access
|
||||
if strings.Contains(policy, "s3:GetObject") && strings.Contains(policy, "Principal") && strings.Contains(policy, "*") {
|
||||
if strings.Contains(policy, "s3:PutObject") {
|
||||
return "public-read-write"
|
||||
}
|
||||
return "public-read"
|
||||
}
|
||||
}
|
||||
|
||||
return "private"
|
||||
}
|
||||
|
||||
|
||||
// CreateBucket creates a new bucket
|
||||
func (s *Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
err := s.client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
func (s *Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
err := s.client.RemoveBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBucketStats gets statistics for a bucket
|
||||
func (s *Service) GetBucketStats(ctx context.Context, bucketName string) (*Bucket, error) {
|
||||
return s.getBucketInfo(ctx, bucketName)
|
||||
}
|
||||
|
||||
// User represents a MinIO IAM user
|
||||
type User struct {
|
||||
AccessKey string `json:"access_key"`
|
||||
Status string `json:"status"` // "enabled" or "disabled"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListUsers lists all IAM users in MinIO
|
||||
func (s *Service) ListUsers(ctx context.Context) ([]*User, error) {
|
||||
users, err := s.adminClient.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list users: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*User, 0, len(users))
|
||||
for accessKey, userInfo := range users {
|
||||
status := "enabled"
|
||||
if userInfo.Status == madmin.AccountDisabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
// MinIO doesn't provide creation date, use current time
|
||||
result = append(result, &User{
|
||||
AccessKey: accessKey,
|
||||
Status: status,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new IAM user in MinIO
|
||||
func (s *Service) CreateUser(ctx context.Context, accessKey, secretKey string) error {
|
||||
err := s.adminClient.AddUser(ctx, accessKey, secretKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes an IAM user from MinIO
|
||||
func (s *Service) DeleteUser(ctx context.Context, accessKey string) error {
|
||||
err := s.adminClient.RemoveUser(ctx, accessKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceAccount represents a MinIO service account (access key)
|
||||
type ServiceAccount struct {
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key,omitempty"` // Only returned on creation
|
||||
ParentUser string `json:"parent_user"`
|
||||
Expiration time.Time `json:"expiration,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListServiceAccounts lists all service accounts in MinIO
|
||||
func (s *Service) ListServiceAccounts(ctx context.Context) ([]*ServiceAccount, error) {
|
||||
accounts, err := s.adminClient.ListServiceAccounts(ctx, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list service accounts: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*ServiceAccount, 0, len(accounts.Accounts))
|
||||
for _, account := range accounts.Accounts {
|
||||
var expiration time.Time
|
||||
if account.Expiration != nil {
|
||||
expiration = *account.Expiration
|
||||
}
|
||||
|
||||
result = append(result, &ServiceAccount{
|
||||
AccessKey: account.AccessKey,
|
||||
ParentUser: account.ParentUser,
|
||||
Expiration: expiration,
|
||||
CreatedAt: time.Now(), // MinIO doesn't provide creation date
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateServiceAccount creates a new service account (access key) in MinIO
|
||||
func (s *Service) CreateServiceAccount(ctx context.Context, parentUser string, policy string, expiration *time.Time) (*ServiceAccount, error) {
|
||||
opts := madmin.AddServiceAccountReq{
|
||||
TargetUser: parentUser,
|
||||
}
|
||||
if policy != "" {
|
||||
opts.Policy = json.RawMessage(policy)
|
||||
}
|
||||
if expiration != nil {
|
||||
opts.Expiration = expiration
|
||||
}
|
||||
|
||||
creds, err := s.adminClient.AddServiceAccount(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create service account: %w", err)
|
||||
}
|
||||
|
||||
return &ServiceAccount{
|
||||
AccessKey: creds.AccessKey,
|
||||
SecretKey: creds.SecretKey,
|
||||
ParentUser: parentUser,
|
||||
Expiration: creds.Expiration,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteServiceAccount deletes a service account from MinIO
|
||||
func (s *Service) DeleteServiceAccount(ctx context.Context, accessKey string) error {
|
||||
err := s.adminClient.DeleteServiceAccount(ctx, accessKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete service account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
511
backend/internal/object_storage/setup.go
Normal file
511
backend/internal/object_storage/setup.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package object_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
)
|
||||
|
||||
// SetupService handles object storage setup operations
|
||||
type SetupService struct {
|
||||
db *database.DB
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewSetupService creates a new setup service
|
||||
func NewSetupService(db *database.DB, log *logger.Logger) *SetupService {
|
||||
return &SetupService{
|
||||
db: db,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// PoolDatasetInfo represents a pool with its datasets
|
||||
type PoolDatasetInfo struct {
|
||||
PoolID string `json:"pool_id"`
|
||||
PoolName string `json:"pool_name"`
|
||||
Datasets []DatasetInfo `json:"datasets"`
|
||||
}
|
||||
|
||||
// DatasetInfo represents a dataset that can be used for object storage
|
||||
type DatasetInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"` // pool/dataset
|
||||
MountPoint string `json:"mount_point"`
|
||||
Type string `json:"type"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
AvailableBytes int64 `json:"available_bytes"`
|
||||
}
|
||||
|
||||
// GetAvailableDatasets returns all pools with their datasets that can be used for object storage
|
||||
func (s *SetupService) GetAvailableDatasets(ctx context.Context) ([]PoolDatasetInfo, error) {
|
||||
// Get all pools
|
||||
poolsQuery := `
|
||||
SELECT id, name
|
||||
FROM zfs_pools
|
||||
WHERE is_active = true
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, poolsQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query pools: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pools []PoolDatasetInfo
|
||||
for rows.Next() {
|
||||
var pool PoolDatasetInfo
|
||||
if err := rows.Scan(&pool.PoolID, &pool.PoolName); err != nil {
|
||||
s.logger.Warn("Failed to scan pool", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get datasets for this pool
|
||||
datasetsQuery := `
|
||||
SELECT id, name, type, mount_point, used_bytes, available_bytes
|
||||
FROM zfs_datasets
|
||||
WHERE pool_name = $1 AND type = 'filesystem'
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
datasetRows, err := s.db.QueryContext(ctx, datasetsQuery, pool.PoolName)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to query datasets", "pool", pool.PoolName, "error", err)
|
||||
pool.Datasets = []DatasetInfo{}
|
||||
pools = append(pools, pool)
|
||||
continue
|
||||
}
|
||||
|
||||
var datasets []DatasetInfo
|
||||
for datasetRows.Next() {
|
||||
var ds DatasetInfo
|
||||
var mountPoint sql.NullString
|
||||
|
||||
if err := datasetRows.Scan(&ds.ID, &ds.Name, &ds.Type, &mountPoint, &ds.UsedBytes, &ds.AvailableBytes); err != nil {
|
||||
s.logger.Warn("Failed to scan dataset", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
ds.FullName = fmt.Sprintf("%s/%s", pool.PoolName, ds.Name)
|
||||
if mountPoint.Valid {
|
||||
ds.MountPoint = mountPoint.String
|
||||
} else {
|
||||
ds.MountPoint = ""
|
||||
}
|
||||
|
||||
datasets = append(datasets, ds)
|
||||
}
|
||||
datasetRows.Close()
|
||||
|
||||
pool.Datasets = datasets
|
||||
pools = append(pools, pool)
|
||||
}
|
||||
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
// SetupRequest represents a request to setup object storage
|
||||
type SetupRequest struct {
|
||||
PoolName string `json:"pool_name" binding:"required"`
|
||||
DatasetName string `json:"dataset_name" binding:"required"`
|
||||
CreateNew bool `json:"create_new"` // If true, create new dataset instead of using existing
|
||||
}
|
||||
|
||||
// SetupResponse represents the response after setup
|
||||
type SetupResponse struct {
|
||||
DatasetPath string `json:"dataset_path"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SetupObjectStorage configures MinIO to use a specific ZFS dataset
|
||||
func (s *SetupService) SetupObjectStorage(ctx context.Context, req SetupRequest) (*SetupResponse, error) {
|
||||
var datasetPath, mountPoint string
|
||||
|
||||
// Normalize dataset name - if it already contains pool name, use it as-is
|
||||
var fullDatasetName string
|
||||
if strings.HasPrefix(req.DatasetName, req.PoolName+"/") {
|
||||
// Dataset name already includes pool name (e.g., "pool/dataset")
|
||||
fullDatasetName = req.DatasetName
|
||||
} else {
|
||||
// Dataset name is just the name (e.g., "dataset"), combine with pool
|
||||
fullDatasetName = fmt.Sprintf("%s/%s", req.PoolName, req.DatasetName)
|
||||
}
|
||||
|
||||
if req.CreateNew {
|
||||
// Create new dataset for object storage
|
||||
|
||||
// Check if dataset already exists
|
||||
checkCmd := exec.CommandContext(ctx, "sudo", "zfs", "list", "-H", "-o", "name", fullDatasetName)
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
return nil, fmt.Errorf("dataset %s already exists", fullDatasetName)
|
||||
}
|
||||
|
||||
// Create dataset
|
||||
createCmd := exec.CommandContext(ctx, "sudo", "zfs", "create", fullDatasetName)
|
||||
if output, err := createCmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create dataset: %s - %w", string(output), err)
|
||||
}
|
||||
|
||||
// Get mount point
|
||||
getMountCmd := exec.CommandContext(ctx, "sudo", "zfs", "get", "-H", "-o", "value", "mountpoint", fullDatasetName)
|
||||
mountOutput, err := getMountCmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mount point: %w", err)
|
||||
}
|
||||
mountPoint = strings.TrimSpace(string(mountOutput))
|
||||
|
||||
datasetPath = fullDatasetName
|
||||
s.logger.Info("Created new dataset for object storage", "dataset", fullDatasetName, "mount_point", mountPoint)
|
||||
} else {
|
||||
// Use existing dataset
|
||||
// fullDatasetName already set above
|
||||
|
||||
// Verify dataset exists
|
||||
checkCmd := exec.CommandContext(ctx, "sudo", "zfs", "list", "-H", "-o", "name", fullDatasetName)
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("dataset %s does not exist", fullDatasetName)
|
||||
}
|
||||
|
||||
// Get mount point
|
||||
getMountCmd := exec.CommandContext(ctx, "sudo", "zfs", "get", "-H", "-o", "value", "mountpoint", fullDatasetName)
|
||||
mountOutput, err := getMountCmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mount point: %w", err)
|
||||
}
|
||||
mountPoint = strings.TrimSpace(string(mountOutput))
|
||||
|
||||
datasetPath = fullDatasetName
|
||||
s.logger.Info("Using existing dataset for object storage", "dataset", fullDatasetName, "mount_point", mountPoint)
|
||||
}
|
||||
|
||||
// Ensure mount point directory exists
|
||||
if mountPoint != "none" && mountPoint != "" {
|
||||
if err := os.MkdirAll(mountPoint, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create mount point directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
// If no mount point, use default path
|
||||
mountPoint = filepath.Join("/opt/calypso/data/pool", req.PoolName, req.DatasetName)
|
||||
if err := os.MkdirAll(mountPoint, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create default directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update MinIO configuration to use the selected dataset
|
||||
if err := s.updateMinIOConfig(ctx, mountPoint); err != nil {
|
||||
s.logger.Warn("Failed to update MinIO configuration", "error", err)
|
||||
// Continue anyway, configuration is saved to database
|
||||
}
|
||||
|
||||
// Save configuration to database
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO object_storage_config (dataset_path, mount_point, pool_name, dataset_name, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET dataset_path = $1, mount_point = $2, pool_name = $3, dataset_name = $4, updated_at = NOW()
|
||||
`, datasetPath, mountPoint, req.PoolName, req.DatasetName)
|
||||
|
||||
if err != nil {
|
||||
// If table doesn't exist, just log warning
|
||||
s.logger.Warn("Failed to save configuration to database (table may not exist)", "error", err)
|
||||
}
|
||||
|
||||
return &SetupResponse{
|
||||
DatasetPath: datasetPath,
|
||||
MountPoint: mountPoint,
|
||||
Message: fmt.Sprintf("Object storage configured to use dataset %s at %s. MinIO service needs to be restarted to use the new dataset.", datasetPath, mountPoint),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentSetup returns the current object storage configuration
|
||||
func (s *SetupService) GetCurrentSetup(ctx context.Context) (*SetupResponse, error) {
|
||||
// Check if table exists first
|
||||
var tableExists bool
|
||||
checkQuery := `
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'object_storage_config'
|
||||
)
|
||||
`
|
||||
err := s.db.QueryRowContext(ctx, checkQuery).Scan(&tableExists)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to check if object_storage_config table exists", "error", err)
|
||||
return nil, nil // Return nil if can't check
|
||||
}
|
||||
|
||||
if !tableExists {
|
||||
s.logger.Debug("object_storage_config table does not exist")
|
||||
return nil, nil // No table, no configuration
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT dataset_path, mount_point, pool_name, dataset_name
|
||||
FROM object_storage_config
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var resp SetupResponse
|
||||
var poolName, datasetName string
|
||||
err = s.db.QueryRowContext(ctx, query).Scan(&resp.DatasetPath, &resp.MountPoint, &poolName, &datasetName)
|
||||
if err == sql.ErrNoRows {
|
||||
s.logger.Debug("No configuration found in database")
|
||||
return nil, nil // No configuration found
|
||||
}
|
||||
if err != nil {
|
||||
// Check if error is due to table not existing or permission denied
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "does not exist") || strings.Contains(errStr, "permission denied") {
|
||||
s.logger.Debug("Table does not exist or permission denied, returning nil", "error", errStr)
|
||||
return nil, nil // Return nil instead of error
|
||||
}
|
||||
s.logger.Error("Failed to scan current setup", "error", err)
|
||||
return nil, fmt.Errorf("failed to get current setup: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Found current setup", "dataset_path", resp.DatasetPath, "mount_point", resp.MountPoint, "pool", poolName, "dataset", datasetName)
|
||||
// Use dataset_path directly since it already contains the full path
|
||||
resp.Message = fmt.Sprintf("Using dataset %s at %s", resp.DatasetPath, resp.MountPoint)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateObjectStorage updates the object storage configuration to use a different dataset
|
||||
// This will update the configuration but won't migrate existing data
|
||||
func (s *SetupService) UpdateObjectStorage(ctx context.Context, req SetupRequest) (*SetupResponse, error) {
|
||||
// First check if there's existing configuration
|
||||
currentSetup, err := s.GetCurrentSetup(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check current setup: %w", err)
|
||||
}
|
||||
|
||||
if currentSetup == nil {
|
||||
// No existing setup, just do normal setup
|
||||
return s.SetupObjectStorage(ctx, req)
|
||||
}
|
||||
|
||||
// There's existing setup, proceed with update
|
||||
var datasetPath, mountPoint string
|
||||
|
||||
// Normalize dataset name - if it already contains pool name, use it as-is
|
||||
var fullDatasetName string
|
||||
if strings.HasPrefix(req.DatasetName, req.PoolName+"/") {
|
||||
// Dataset name already includes pool name (e.g., "pool/dataset")
|
||||
fullDatasetName = req.DatasetName
|
||||
} else {
|
||||
// Dataset name is just the name (e.g., "dataset"), combine with pool
|
||||
fullDatasetName = fmt.Sprintf("%s/%s", req.PoolName, req.DatasetName)
|
||||
}
|
||||
|
||||
if req.CreateNew {
|
||||
// Create new dataset for object storage
|
||||
|
||||
// Check if dataset already exists
|
||||
checkCmd := exec.CommandContext(ctx, "sudo", "zfs", "list", "-H", "-o", "name", fullDatasetName)
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
return nil, fmt.Errorf("dataset %s already exists", fullDatasetName)
|
||||
}
|
||||
|
||||
// Create dataset
|
||||
createCmd := exec.CommandContext(ctx, "sudo", "zfs", "create", fullDatasetName)
|
||||
if output, err := createCmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create dataset: %s - %w", string(output), err)
|
||||
}
|
||||
|
||||
// Get mount point
|
||||
getMountCmd := exec.CommandContext(ctx, "sudo", "zfs", "get", "-H", "-o", "value", "mountpoint", fullDatasetName)
|
||||
mountOutput, err := getMountCmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mount point: %w", err)
|
||||
}
|
||||
mountPoint = strings.TrimSpace(string(mountOutput))
|
||||
|
||||
datasetPath = fullDatasetName
|
||||
s.logger.Info("Created new dataset for object storage update", "dataset", fullDatasetName, "mount_point", mountPoint)
|
||||
} else {
|
||||
// Use existing dataset
|
||||
// fullDatasetName already set above
|
||||
|
||||
// Verify dataset exists
|
||||
checkCmd := exec.CommandContext(ctx, "sudo", "zfs", "list", "-H", "-o", "name", fullDatasetName)
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("dataset %s does not exist", fullDatasetName)
|
||||
}
|
||||
|
||||
// Get mount point
|
||||
getMountCmd := exec.CommandContext(ctx, "sudo", "zfs", "get", "-H", "-o", "value", "mountpoint", fullDatasetName)
|
||||
mountOutput, err := getMountCmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get mount point: %w", err)
|
||||
}
|
||||
mountPoint = strings.TrimSpace(string(mountOutput))
|
||||
|
||||
datasetPath = fullDatasetName
|
||||
s.logger.Info("Using existing dataset for object storage update", "dataset", fullDatasetName, "mount_point", mountPoint)
|
||||
}
|
||||
|
||||
// Ensure mount point directory exists
|
||||
if mountPoint != "none" && mountPoint != "" {
|
||||
if err := os.MkdirAll(mountPoint, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create mount point directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
// If no mount point, use default path
|
||||
mountPoint = filepath.Join("/opt/calypso/data/pool", req.PoolName, req.DatasetName)
|
||||
if err := os.MkdirAll(mountPoint, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create default directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration in database
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE object_storage_config
|
||||
SET dataset_path = $1, mount_point = $2, pool_name = $3, dataset_name = $4, updated_at = NOW()
|
||||
WHERE id = (SELECT id FROM object_storage_config ORDER BY updated_at DESC LIMIT 1)
|
||||
`, datasetPath, mountPoint, req.PoolName, req.DatasetName)
|
||||
|
||||
if err != nil {
|
||||
// If update fails, try insert
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO object_storage_config (dataset_path, mount_point, pool_name, dataset_name, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (dataset_path) DO UPDATE
|
||||
SET mount_point = $2, pool_name = $3, dataset_name = $4, updated_at = NOW()
|
||||
`, datasetPath, mountPoint, req.PoolName, req.DatasetName)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to update configuration in database", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update MinIO configuration to use the selected dataset
|
||||
if err := s.updateMinIOConfig(ctx, mountPoint); err != nil {
|
||||
s.logger.Warn("Failed to update MinIO configuration", "error", err)
|
||||
// Continue anyway, configuration is saved to database
|
||||
} else {
|
||||
// Restart MinIO service to apply new configuration
|
||||
if err := s.restartMinIOService(ctx); err != nil {
|
||||
s.logger.Warn("Failed to restart MinIO service", "error", err)
|
||||
// Continue anyway, user can restart manually
|
||||
}
|
||||
}
|
||||
|
||||
return &SetupResponse{
|
||||
DatasetPath: datasetPath,
|
||||
MountPoint: mountPoint,
|
||||
Message: fmt.Sprintf("Object storage updated to use dataset %s at %s. Note: Existing data in previous dataset (%s) is not migrated automatically. MinIO service has been restarted.", datasetPath, mountPoint, currentSetup.DatasetPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// updateMinIOConfig updates MinIO configuration file to use dataset mount point directly
|
||||
// Note: MinIO erasure coding requires direct directory paths, not symlinks
|
||||
func (s *SetupService) updateMinIOConfig(ctx context.Context, datasetMountPoint string) error {
|
||||
configFile := "/opt/calypso/conf/minio/minio.conf"
|
||||
|
||||
// Ensure dataset mount point directory exists and has correct ownership
|
||||
if err := os.MkdirAll(datasetMountPoint, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dataset mount point directory: %w", err)
|
||||
}
|
||||
|
||||
// Set ownership to minio-user so MinIO can write to it
|
||||
if err := exec.CommandContext(ctx, "sudo", "chown", "-R", "minio-user:minio-user", datasetMountPoint).Run(); err != nil {
|
||||
s.logger.Warn("Failed to set ownership on dataset mount point", "path", datasetMountPoint, "error", err)
|
||||
// Continue anyway, might already have correct ownership
|
||||
}
|
||||
|
||||
// Set permissions
|
||||
if err := exec.CommandContext(ctx, "sudo", "chmod", "755", datasetMountPoint).Run(); err != nil {
|
||||
s.logger.Warn("Failed to set permissions on dataset mount point", "path", datasetMountPoint, "error", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Prepared dataset mount point for MinIO", "path", datasetMountPoint)
|
||||
|
||||
// Read current config file
|
||||
configContent, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
// If file doesn't exist, create it
|
||||
if os.IsNotExist(err) {
|
||||
configContent = []byte(fmt.Sprintf("MINIO_ROOT_USER=admin\nMINIO_ROOT_PASSWORD=HqBX1IINqFynkWFa\nMINIO_VOLUMES=%s\n", datasetMountPoint))
|
||||
} else {
|
||||
return fmt.Errorf("failed to read MinIO config file: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Update MINIO_VOLUMES in config
|
||||
lines := strings.Split(string(configContent), "\n")
|
||||
updated := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "MINIO_VOLUMES=") {
|
||||
lines[i] = fmt.Sprintf("MINIO_VOLUMES=%s", datasetMountPoint)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
// Add MINIO_VOLUMES if not found
|
||||
lines = append(lines, fmt.Sprintf("MINIO_VOLUMES=%s", datasetMountPoint))
|
||||
}
|
||||
configContent = []byte(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// Write updated config using sudo
|
||||
// Write temp file to a location we can write to
|
||||
userTempFile := fmt.Sprintf("/tmp/minio.conf.%d.tmp", os.Getpid())
|
||||
if err := os.WriteFile(userTempFile, configContent, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp config file: %w", err)
|
||||
}
|
||||
defer os.Remove(userTempFile) // Cleanup
|
||||
|
||||
// Copy temp file to config location with sudo
|
||||
if err := exec.CommandContext(ctx, "sudo", "cp", userTempFile, configFile).Run(); err != nil {
|
||||
return fmt.Errorf("failed to update config file: %w", err)
|
||||
}
|
||||
|
||||
// Set proper ownership and permissions
|
||||
if err := exec.CommandContext(ctx, "sudo", "chown", "minio-user:minio-user", configFile).Run(); err != nil {
|
||||
s.logger.Warn("Failed to set config file ownership", "error", err)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "sudo", "chmod", "644", configFile).Run(); err != nil {
|
||||
s.logger.Warn("Failed to set config file permissions", "error", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Updated MinIO configuration", "config_file", configFile, "volumes", datasetMountPoint)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartMinIOService restarts the MinIO service to apply new configuration
|
||||
func (s *SetupService) restartMinIOService(ctx context.Context) error {
|
||||
// Restart MinIO service using sudo
|
||||
cmd := exec.CommandContext(ctx, "sudo", "systemctl", "restart", "minio.service")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to restart MinIO service: %w", err)
|
||||
}
|
||||
|
||||
// Wait a moment for service to start
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Verify service is running
|
||||
checkCmd := exec.CommandContext(ctx, "sudo", "systemctl", "is-active", "minio.service")
|
||||
output, err := checkCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check MinIO service status: %w", err)
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(string(output))
|
||||
if status != "active" {
|
||||
return fmt.Errorf("MinIO service is not active after restart, status: %s", status)
|
||||
}
|
||||
|
||||
s.logger.Info("MinIO service restarted successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -730,10 +730,36 @@ func (s *ZFSService) CreateDataset(ctx context.Context, poolName string, req Cre
|
||||
// Construct full dataset name
|
||||
fullName := poolName + "/" + req.Name
|
||||
|
||||
// For filesystem datasets, create mount directory if mount point is provided
|
||||
if req.Type == "filesystem" && req.MountPoint != "" {
|
||||
// Clean and validate mount point path
|
||||
mountPath := filepath.Clean(req.MountPoint)
|
||||
// Get pool mount point to validate dataset mount point is within pool directory
|
||||
poolMountPoint := fmt.Sprintf("/opt/calypso/data/pool/%s", poolName)
|
||||
var mountPath string
|
||||
|
||||
// For filesystem datasets, validate and set mount point
|
||||
if req.Type == "filesystem" {
|
||||
if req.MountPoint != "" {
|
||||
// User provided mount point - validate it's within pool directory
|
||||
mountPath = filepath.Clean(req.MountPoint)
|
||||
|
||||
// Check if mount point is within pool mount point directory
|
||||
poolMountAbs, err := filepath.Abs(poolMountPoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve pool mount point: %w", err)
|
||||
}
|
||||
|
||||
mountPathAbs, err := filepath.Abs(mountPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve mount point: %w", err)
|
||||
}
|
||||
|
||||
// Check if mount path is within pool mount point directory
|
||||
relPath, err := filepath.Rel(poolMountAbs, mountPathAbs)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
return nil, fmt.Errorf("mount point must be within pool directory: %s (pool mount: %s)", mountPath, poolMountPoint)
|
||||
}
|
||||
} else {
|
||||
// No mount point provided - use default: /opt/calypso/data/pool/<pool-name>/<dataset-name>/
|
||||
mountPath = filepath.Join(poolMountPoint, req.Name)
|
||||
}
|
||||
|
||||
// Check if directory already exists
|
||||
if info, err := os.Stat(mountPath); err == nil {
|
||||
@@ -782,9 +808,9 @@ func (s *ZFSService) CreateDataset(ctx context.Context, poolName string, req Cre
|
||||
args = append(args, "-o", fmt.Sprintf("compression=%s", req.Compression))
|
||||
}
|
||||
|
||||
// Set mount point if provided (only for filesystems, not volumes)
|
||||
if req.Type == "filesystem" && req.MountPoint != "" {
|
||||
args = append(args, "-o", fmt.Sprintf("mountpoint=%s", req.MountPoint))
|
||||
// Set mount point for filesystems (always set, either user-provided or default)
|
||||
if req.Type == "filesystem" {
|
||||
args = append(args, "-o", fmt.Sprintf("mountpoint=%s", mountPath))
|
||||
}
|
||||
|
||||
// Execute zfs create
|
||||
|
||||
@@ -133,6 +133,18 @@ func (h *Handler) ListNetworkInterfaces(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"interfaces": interfaces})
|
||||
}
|
||||
|
||||
// GetManagementIPAddress returns the management IP address
|
||||
func (h *Handler) GetManagementIPAddress(c *gin.Context) {
|
||||
ip, err := h.service.GetManagementIPAddress(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get management IP address", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get management IP address"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ip_address": ip})
|
||||
}
|
||||
|
||||
// SaveNTPSettings saves NTP configuration to the OS
|
||||
func (h *Handler) SaveNTPSettings(c *gin.Context) {
|
||||
var settings NTPSettings
|
||||
|
||||
@@ -648,6 +648,40 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
// GetManagementIPAddress returns the IP address of the management interface
|
||||
func (s *Service) GetManagementIPAddress(ctx context.Context) (string, error) {
|
||||
interfaces, err := s.ListNetworkInterfaces(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list network interfaces: %w", err)
|
||||
}
|
||||
|
||||
// First, try to find interface with Role "Management"
|
||||
for _, iface := range interfaces {
|
||||
if iface.Role == "Management" && iface.IPAddress != "" && iface.Status == "Connected" {
|
||||
s.logger.Info("Found management interface", "interface", iface.Name, "ip", iface.IPAddress)
|
||||
return iface.IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use interface with default route (primary interface)
|
||||
for _, iface := range interfaces {
|
||||
if iface.Gateway != "" && iface.IPAddress != "" && iface.Status == "Connected" {
|
||||
s.logger.Info("Using primary interface as management", "interface", iface.Name, "ip", iface.IPAddress)
|
||||
return iface.IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: use first connected interface with IP
|
||||
for _, iface := range interfaces {
|
||||
if iface.IPAddress != "" && iface.Status == "Connected" && iface.Name != "lo" {
|
||||
s.logger.Info("Using first connected interface as management", "interface", iface.Name, "ip", iface.IPAddress)
|
||||
return iface.IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no management interface found")
|
||||
}
|
||||
|
||||
// UpdateNetworkInterfaceRequest represents the request to update a network interface
|
||||
type UpdateNetworkInterfaceRequest struct {
|
||||
IPAddress string `json:"ip_address"`
|
||||
|
||||
96
docs/alpha/components/minio/INSTALLATION.md
Normal file
96
docs/alpha/components/minio/INSTALLATION.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# MinIO Installation and Configuration Guide
|
||||
|
||||
This document outlines the steps to install and configure a standalone MinIO server, running as a `systemd` service.
|
||||
|
||||
## 1. Download MinIO Binary
|
||||
|
||||
Download the latest MinIO server executable and make it accessible system-wide.
|
||||
|
||||
```bash
|
||||
sudo wget https://dl.min.io/server/minio/release/linux-amd64/minio -O /usr/local/bin/minio
|
||||
sudo chmod +x /usr/local/bin/minio
|
||||
```
|
||||
|
||||
## 2. Create a Dedicated User
|
||||
|
||||
For security, create a dedicated system user and group that will own and run the MinIO process. This user does not have login privileges.
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /bin/false minio-user
|
||||
```
|
||||
|
||||
## 3. Create Data and Configuration Directories
|
||||
|
||||
Create the directories specified for MinIO's configuration and its backend storage. Assign ownership to the `minio-user`.
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
sudo mkdir -p /opt/calypso/conf/minio /opt/calypso/data/storage/s3
|
||||
|
||||
# Set ownership
|
||||
sudo chown -R minio-user:minio-user /opt/calypso/conf/minio /opt/calypso/data/storage/s3
|
||||
```
|
||||
|
||||
## 4. Create Environment Configuration File
|
||||
|
||||
Create a configuration file that will be used by the `systemd` service to set necessary environment variables. This includes the access credentials and the path to the storage volume.
|
||||
|
||||
**Note:** The following command includes a pre-generated secure password. These credentials will be required to log in to the MinIO console.
|
||||
|
||||
```bash
|
||||
# Create the environment file
|
||||
sudo bash -c "cat > /opt/calypso/conf/minio/minio.conf" <<'EOF'
|
||||
MINIO_ROOT_USER=admin
|
||||
MINIO_ROOT_PASSWORD=HqBX1IINqFynkWFa
|
||||
MINIO_VOLUMES=/opt/calypso/data/storage/s3
|
||||
EOF
|
||||
|
||||
# Set ownership of the file
|
||||
sudo chown minio-user:minio-user /opt/calypso/conf/minio/minio.conf
|
||||
```
|
||||
|
||||
## 5. Create Systemd Service File
|
||||
|
||||
Create a `systemd` service file to manage the MinIO server process. This defines how the server is started, stopped, and managed.
|
||||
|
||||
```bash
|
||||
sudo bash -c "cat > /etc/systemd/system/minio.service" <<'EOF'
|
||||
[Unit]
|
||||
Description=MinIO
|
||||
Documentation=https://min.io/docs/minio/linux/index.html
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
AssertFileIsExecutable=/usr/local/bin/minio
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=minio-user
|
||||
Group=minio-user
|
||||
EnvironmentFile=/opt/calypso/conf/minio/minio.conf
|
||||
ExecStart=/usr/local/bin/minio server --console-address ":9001" $MINIO_VOLUMES
|
||||
Restart=always
|
||||
LimitNOFILE=65536
|
||||
TimeoutStopSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
## 6. Start and Enable the MinIO Service
|
||||
|
||||
Reload the `systemd` daemon to recognize the new service file, enable it to start automatically on boot, and then start the service.
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable minio.service
|
||||
sudo systemctl start minio.service
|
||||
```
|
||||
|
||||
## 7. Access MinIO
|
||||
|
||||
The MinIO server is now running.
|
||||
- **API Endpoint:** `http://<your-server-ip>:9000`
|
||||
- **Web Console:** `http://<your-server-ip>:9001`
|
||||
- **Root User (Access Key):** `admin`
|
||||
- **Root Password (Secret Key):** `HqBX1IINqFynkWFa`
|
||||
145
frontend/src/api/objectStorage.ts
Normal file
145
frontend/src/api/objectStorage.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Bucket {
|
||||
name: string
|
||||
creation_date: string
|
||||
size: number
|
||||
objects: number
|
||||
access_policy: 'private' | 'public-read' | 'public-read-write'
|
||||
}
|
||||
|
||||
export const objectStorageApi = {
|
||||
listBuckets: async (): Promise<Bucket[]> => {
|
||||
const response = await apiClient.get<{ buckets: Bucket[] }>('/object-storage/buckets')
|
||||
return response.data.buckets || []
|
||||
},
|
||||
|
||||
getBucket: async (name: string): Promise<Bucket> => {
|
||||
const response = await apiClient.get<Bucket>(`/object-storage/buckets/${encodeURIComponent(name)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createBucket: async (name: string): Promise<void> => {
|
||||
await apiClient.post('/object-storage/buckets', { name })
|
||||
},
|
||||
|
||||
deleteBucket: async (name: string): Promise<void> => {
|
||||
await apiClient.delete(`/object-storage/buckets/${encodeURIComponent(name)}`)
|
||||
},
|
||||
|
||||
// Setup endpoints
|
||||
getAvailableDatasets: async (): Promise<PoolDatasetInfo[]> => {
|
||||
const response = await apiClient.get<{ pools: PoolDatasetInfo[] }>('/object-storage/setup/datasets')
|
||||
return response.data.pools || []
|
||||
},
|
||||
|
||||
getCurrentSetup: async (): Promise<CurrentSetup | null> => {
|
||||
const response = await apiClient.get<{ configured: boolean; setup?: SetupResponse }>('/object-storage/setup/current')
|
||||
if (!response.data.configured || !response.data.setup) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
dataset_path: response.data.setup.dataset_path,
|
||||
mount_point: response.data.setup.mount_point,
|
||||
}
|
||||
},
|
||||
|
||||
setupObjectStorage: async (poolName: string, datasetName: string, createNew: boolean): Promise<SetupResponse> => {
|
||||
const response = await apiClient.post<SetupResponse>('/object-storage/setup', {
|
||||
pool_name: poolName,
|
||||
dataset_name: datasetName,
|
||||
create_new: createNew,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateObjectStorage: async (poolName: string, datasetName: string, createNew: boolean): Promise<SetupResponse> => {
|
||||
const response = await apiClient.put<SetupResponse>('/object-storage/setup', {
|
||||
pool_name: poolName,
|
||||
dataset_name: datasetName,
|
||||
create_new: createNew,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// User management
|
||||
listUsers: async (): Promise<User[]> => {
|
||||
const response = await apiClient.get<{ users: User[] }>('/object-storage/users')
|
||||
return response.data.users || []
|
||||
},
|
||||
|
||||
createUser: async (data: CreateUserRequest): Promise<void> => {
|
||||
await apiClient.post('/object-storage/users', data)
|
||||
},
|
||||
|
||||
deleteUser: async (accessKey: string): Promise<void> => {
|
||||
await apiClient.delete(`/object-storage/users/${encodeURIComponent(accessKey)}`)
|
||||
},
|
||||
|
||||
// Service account (access key) management
|
||||
listServiceAccounts: async (): Promise<ServiceAccount[]> => {
|
||||
const response = await apiClient.get<{ service_accounts: ServiceAccount[] }>('/object-storage/service-accounts')
|
||||
return response.data.service_accounts || []
|
||||
},
|
||||
|
||||
createServiceAccount: async (data: CreateServiceAccountRequest): Promise<ServiceAccount> => {
|
||||
const response = await apiClient.post<ServiceAccount>('/object-storage/service-accounts', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteServiceAccount: async (accessKey: string): Promise<void> => {
|
||||
await apiClient.delete(`/object-storage/service-accounts/${encodeURIComponent(accessKey)}`)
|
||||
},
|
||||
}
|
||||
|
||||
export interface PoolDatasetInfo {
|
||||
pool_id: string
|
||||
pool_name: string
|
||||
datasets: DatasetInfo[]
|
||||
}
|
||||
|
||||
export interface DatasetInfo {
|
||||
id: string
|
||||
name: string
|
||||
full_name: string
|
||||
mount_point: string
|
||||
type: string
|
||||
used_bytes: number
|
||||
available_bytes: number
|
||||
}
|
||||
|
||||
export interface SetupResponse {
|
||||
dataset_path: string
|
||||
mount_point: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CurrentSetup {
|
||||
dataset_path: string
|
||||
mount_point: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
access_key: string
|
||||
status: 'enabled' | 'disabled'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ServiceAccount {
|
||||
access_key: string
|
||||
secret_key?: string // Only returned on creation
|
||||
parent_user: string
|
||||
expiration?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
access_key: string
|
||||
secret_key: string
|
||||
}
|
||||
|
||||
export interface CreateServiceAccountRequest {
|
||||
parent_user: string
|
||||
policy?: string
|
||||
expiration?: string // ISO 8601 format
|
||||
}
|
||||
@@ -84,5 +84,9 @@ export const systemAPI = {
|
||||
const response = await apiClient.get<{ data: NetworkDataPoint[] }>(`/system/network/throughput?duration=${duration}`)
|
||||
return response.data.data || []
|
||||
},
|
||||
getManagementIPAddress: async (): Promise<string> => {
|
||||
const response = await apiClient.get<{ ip_address: string }>('/system/management-ip')
|
||||
return response.data.ip_address
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { objectStorageApi, PoolDatasetInfo, CurrentSetup } from '@/api/objectStorage'
|
||||
import UsersAndKeys from './UsersAndKeys'
|
||||
import { systemAPI } from '@/api/system'
|
||||
import {
|
||||
Folder,
|
||||
Share2,
|
||||
Globe,
|
||||
Search,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
CheckCircle2,
|
||||
HardDrive,
|
||||
Database,
|
||||
@@ -18,73 +20,182 @@ import {
|
||||
Settings,
|
||||
Users,
|
||||
Activity,
|
||||
Filter
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const MOCK_BUCKETS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'backup-archive-01',
|
||||
type: 'immutable',
|
||||
usage: 4.2 * 1024 * 1024 * 1024 * 1024, // 4.2 TB in bytes
|
||||
usagePercent: 75,
|
||||
objects: 14200,
|
||||
accessPolicy: 'private',
|
||||
created: '2023-10-24',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'daily-snapshots',
|
||||
type: 'standard',
|
||||
usage: 120 * 1024 * 1024 * 1024, // 120 GB
|
||||
usagePercent: 15,
|
||||
objects: 400,
|
||||
accessPolicy: 'private',
|
||||
created: '2023-11-01',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'public-assets',
|
||||
type: 'standard',
|
||||
usage: 500 * 1024 * 1024 * 1024, // 500 GB
|
||||
usagePercent: 30,
|
||||
objects: 12050,
|
||||
accessPolicy: 'public-read',
|
||||
created: '2023-12-15',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'logs-retention',
|
||||
type: 'archive',
|
||||
usage: 2.1 * 1024 * 1024 * 1024 * 1024, // 2.1 TB
|
||||
usagePercent: 55,
|
||||
objects: 850221,
|
||||
accessPolicy: 'private',
|
||||
created: '2024-01-10',
|
||||
color: 'blue',
|
||||
},
|
||||
]
|
||||
|
||||
const S3_ENDPOINT = 'https://s3.appliance.local:9000'
|
||||
const S3_PORT = 9000
|
||||
|
||||
export default function ObjectStorage() {
|
||||
const [activeTab, setActiveTab] = useState<'buckets' | 'users' | 'monitoring' | 'settings'>('buckets')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [showSetupModal, setShowSetupModal] = useState(false)
|
||||
const [selectedPool, setSelectedPool] = useState<string>('')
|
||||
const [selectedDataset, setSelectedDataset] = useState<string>('')
|
||||
const [createNewDataset, setCreateNewDataset] = useState(false)
|
||||
const [newDatasetName, setNewDatasetName] = useState('')
|
||||
const [isRefreshingBuckets, setIsRefreshingBuckets] = useState(false)
|
||||
const [showCreateBucketModal, setShowCreateBucketModal] = useState(false)
|
||||
const [newBucketName, setNewBucketName] = useState('')
|
||||
const [deleteConfirmBucket, setDeleteConfirmBucket] = useState<string | null>(null)
|
||||
const itemsPerPage = 10
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Mock queries - replace with real API calls
|
||||
const { data: buckets = MOCK_BUCKETS } = useQuery({
|
||||
queryKey: ['object-storage-buckets'],
|
||||
queryFn: async () => MOCK_BUCKETS,
|
||||
// Fetch management IP address
|
||||
const { data: managementIP = 'localhost' } = useQuery<string>({
|
||||
queryKey: ['system-management-ip'],
|
||||
queryFn: systemAPI.getManagementIPAddress,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
retry: 2,
|
||||
})
|
||||
|
||||
// Construct S3 endpoint with management IP
|
||||
const S3_ENDPOINT = `http://${managementIP}:${S3_PORT}`
|
||||
|
||||
// Fetch buckets from API
|
||||
const { data: buckets = [], isLoading: bucketsLoading } = useQuery({
|
||||
queryKey: ['object-storage-buckets'],
|
||||
queryFn: objectStorageApi.listBuckets,
|
||||
refetchInterval: 5000, // Auto-refresh every 5 seconds
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Fetch available datasets for setup
|
||||
const { data: availableDatasets = [] } = useQuery<PoolDatasetInfo[]>({
|
||||
queryKey: ['object-storage-setup-datasets'],
|
||||
queryFn: objectStorageApi.getAvailableDatasets,
|
||||
enabled: showSetupModal, // Only fetch when modal is open
|
||||
})
|
||||
|
||||
// Fetch current setup
|
||||
const { data: currentSetup } = useQuery<CurrentSetup | null>({
|
||||
queryKey: ['object-storage-current-setup'],
|
||||
queryFn: objectStorageApi.getCurrentSetup,
|
||||
})
|
||||
|
||||
// Setup mutation
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: ({ poolName, datasetName, createNew }: { poolName: string; datasetName: string; createNew: boolean }) =>
|
||||
currentSetup
|
||||
? objectStorageApi.updateObjectStorage(poolName, datasetName, createNew)
|
||||
: objectStorageApi.setupObjectStorage(poolName, datasetName, createNew),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-current-setup'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
|
||||
setShowSetupModal(false)
|
||||
if (currentSetup) {
|
||||
alert(`Object storage dataset updated successfully!\n\n${data.message}\n\n⚠️ IMPORTANT: Existing data in the previous dataset is NOT automatically migrated. You may need to manually migrate data or restart MinIO service to use the new dataset.`)
|
||||
} else {
|
||||
alert('Object storage setup completed successfully!')
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.response?.data?.error || `Failed to ${currentSetup ? 'update' : 'setup'} object storage`)
|
||||
},
|
||||
})
|
||||
|
||||
// Create bucket mutation with optimistic update
|
||||
const createBucketMutation = useMutation({
|
||||
mutationFn: (bucketName: string) => objectStorageApi.createBucket(bucketName),
|
||||
onMutate: async (bucketName: string) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['object-storage-buckets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousBuckets = queryClient.getQueryData(['object-storage-buckets'])
|
||||
|
||||
// Optimistically add the new bucket to the list
|
||||
queryClient.setQueryData(['object-storage-buckets'], (old: any[] = []) => {
|
||||
const newBucket = {
|
||||
name: bucketName,
|
||||
creation_date: new Date().toISOString(),
|
||||
size: 0,
|
||||
objects: 0,
|
||||
access_policy: 'private' as const,
|
||||
}
|
||||
return [...old, newBucket]
|
||||
})
|
||||
|
||||
// Close modal immediately
|
||||
setShowCreateBucketModal(false)
|
||||
setNewBucketName('')
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousBuckets }
|
||||
},
|
||||
onError: (error: any, _bucketName: string, context: any) => {
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
if (context?.previousBuckets) {
|
||||
queryClient.setQueryData(['object-storage-buckets'], context.previousBuckets)
|
||||
}
|
||||
// Reopen modal on error
|
||||
setShowCreateBucketModal(true)
|
||||
alert(error.response?.data?.error || 'Failed to create bucket')
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Refetch to ensure we have the latest data from server
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
|
||||
alert('Bucket created successfully!')
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Delete bucket mutation with optimistic update
|
||||
const deleteBucketMutation = useMutation({
|
||||
mutationFn: (bucketName: string) => objectStorageApi.deleteBucket(bucketName),
|
||||
onMutate: async (bucketName: string) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['object-storage-buckets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousBuckets = queryClient.getQueryData(['object-storage-buckets'])
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(['object-storage-buckets'], (old: any[] = []) =>
|
||||
old.filter((bucket: any) => bucket.name !== bucketName)
|
||||
)
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousBuckets }
|
||||
},
|
||||
onError: (error: any, _bucketName: string, context: any) => {
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
if (context?.previousBuckets) {
|
||||
queryClient.setQueryData(['object-storage-buckets'], context.previousBuckets)
|
||||
}
|
||||
alert(error.response?.data?.error || 'Failed to delete bucket')
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Refetch to ensure we have the latest data
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Transform buckets from API to UI format
|
||||
const transformedBuckets = buckets.map((bucket, index) => ({
|
||||
id: bucket.name,
|
||||
name: bucket.name,
|
||||
type: 'standard' as const, // Default type, can be enhanced later
|
||||
usage: bucket.size,
|
||||
usagePercent: 0, // Will be calculated if we have quota info
|
||||
objects: bucket.objects,
|
||||
accessPolicy: bucket.access_policy,
|
||||
created: bucket.creation_date,
|
||||
color: index % 3 === 0 ? 'blue' : index % 3 === 1 ? 'purple' : 'orange',
|
||||
}))
|
||||
|
||||
// Filter buckets by search query
|
||||
const filteredBuckets = buckets.filter(bucket =>
|
||||
const filteredBuckets = transformedBuckets.filter(bucket =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
@@ -96,23 +207,38 @@ export default function ObjectStorage() {
|
||||
)
|
||||
|
||||
// Calculate totals
|
||||
const totalUsage = buckets.reduce((sum, b) => sum + b.usage, 0)
|
||||
const totalObjects = buckets.reduce((sum, b) => sum + b.objects, 0)
|
||||
const totalUsage = transformedBuckets.reduce((sum, b) => sum + b.usage, 0)
|
||||
const totalObjects = transformedBuckets.reduce((sum, b) => sum + b.objects, 0)
|
||||
|
||||
// Copy endpoint to clipboard
|
||||
const copyEndpoint = () => {
|
||||
navigator.clipboard.writeText(S3_ENDPOINT)
|
||||
// You could add a toast notification here
|
||||
const copyEndpoint = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(S3_ENDPOINT)
|
||||
alert('Endpoint copied to clipboard!')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy endpoint:', error)
|
||||
// Fallback: select text
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = S3_ENDPOINT
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
alert('Endpoint copied to clipboard!')
|
||||
} catch (err) {
|
||||
alert(`Failed to copy. Endpoint: ${S3_ENDPOINT}`)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
// Get bucket icon
|
||||
const getBucketIcon = (bucket: typeof MOCK_BUCKETS[0]) => {
|
||||
if (bucket.accessPolicy === 'public-read') {
|
||||
const getBucketIcon = (bucket: typeof transformedBuckets[0]) => {
|
||||
if (bucket.accessPolicy === 'public-read' || bucket.accessPolicy === 'public-read-write') {
|
||||
return <Globe className="text-orange-500" size={20} />
|
||||
}
|
||||
if (bucket.type === 'immutable') {
|
||||
return <Folder className="text-blue-500" size={20} />
|
||||
}
|
||||
return <Share2 className="text-purple-500" size={20} />
|
||||
}
|
||||
|
||||
@@ -138,6 +264,13 @@ export default function ObjectStorage() {
|
||||
|
||||
// Get access policy badge
|
||||
const getAccessPolicyBadge = (policy: string) => {
|
||||
if (policy === 'public-read-write') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-red-500/10 px-2.5 py-0.5 text-xs font-medium text-red-500 border border-red-500/20">
|
||||
Public Read/Write
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (policy === 'public-read') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-2.5 py-0.5 text-xs font-medium text-orange-500 border border-orange-500/20">
|
||||
@@ -158,6 +291,30 @@ export default function ObjectStorage() {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Copy to clipboard helper
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
alert(`${label} copied to clipboard!`)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
// Fallback: select text
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
alert(`${label} copied to clipboard!`)
|
||||
} catch (err) {
|
||||
alert(`Failed to copy. ${label}: ${text}`)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Main Content */}
|
||||
@@ -174,6 +331,46 @@ export default function ObjectStorage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshingBuckets(true)
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['object-storage-buckets'] })
|
||||
// Small delay to show feedback
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert('Buckets refreshed successfully!')
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh buckets:', error)
|
||||
alert('Failed to refresh buckets. Please try again.')
|
||||
} finally {
|
||||
setIsRefreshingBuckets(false)
|
||||
}
|
||||
}}
|
||||
disabled={bucketsLoading || isRefreshingBuckets}
|
||||
className="flex h-10 items-center justify-center rounded-lg border border-border-dark px-4 text-white text-sm font-medium hover:bg-[#233648] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Refresh buckets list"
|
||||
>
|
||||
<RefreshCw className={`mr-2 ${isRefreshingBuckets ? 'animate-spin' : ''}`} size={20} />
|
||||
{isRefreshingBuckets ? 'Refreshing...' : 'Refresh Buckets'}
|
||||
</button>
|
||||
{!currentSetup ? (
|
||||
<button
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
className="flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-white text-sm font-medium hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Plus className="mr-2" size={20} />
|
||||
Setup Object Storage
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
className="flex h-10 items-center justify-center rounded-lg bg-orange-500 px-4 text-white text-sm font-medium hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
<Settings className="mr-2" size={20} />
|
||||
Change Dataset
|
||||
</button>
|
||||
)}
|
||||
<button className="flex h-10 items-center justify-center rounded-lg border border-border-dark px-4 text-white text-sm font-medium hover:bg-[#233648] transition-colors">
|
||||
<FileText className="mr-2" size={20} />
|
||||
Documentation
|
||||
@@ -332,7 +529,10 @@ export default function ObjectStorage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center justify-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition-colors w-full md:w-auto shadow-lg shadow-blue-900/20">
|
||||
<button
|
||||
onClick={() => setShowCreateBucketModal(true)}
|
||||
className="flex items-center justify-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition-colors w-full md:w-auto shadow-lg shadow-blue-900/20"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Create Bucket
|
||||
</button>
|
||||
@@ -368,8 +568,21 @@ export default function ObjectStorage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-dark">
|
||||
{paginatedBuckets.map((bucket) => (
|
||||
<tr key={bucket.id} className="group hover:bg-[#233648] transition-colors">
|
||||
{bucketsLoading ? (
|
||||
<tr className="bg-[#151d26]">
|
||||
<td colSpan={6} className="py-8 px-6 text-center text-text-secondary text-sm">
|
||||
Loading buckets...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedBuckets.length === 0 ? (
|
||||
<tr className="bg-[#151d26]">
|
||||
<td colSpan={6} className="py-8 px-6 text-center text-text-secondary text-sm">
|
||||
No buckets found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedBuckets.map((bucket) => (
|
||||
<tr key={bucket.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -405,13 +618,38 @@ export default function ObjectStorage() {
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-text-secondary text-sm">{formatDate(bucket.created)}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button className="text-text-secondary hover:text-white transition-colors p-2 rounded hover:bg-white/5">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(bucket.name, 'Bucket Name')}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="Copy Bucket Name"
|
||||
>
|
||||
<Copy size={14} />
|
||||
Copy Name
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`${S3_ENDPOINT}/${bucket.name}`, 'Bucket Endpoint')}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="Copy Bucket Endpoint"
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
Copy Endpoint
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmBucket(bucket.name)}
|
||||
disabled={deleteBucketMutation.isPending}
|
||||
className="px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Delete Bucket"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -445,12 +683,7 @@ export default function ObjectStorage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Users className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Users & Keys management coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'users' && <UsersAndKeys S3_ENDPOINT={S3_ENDPOINT} />}
|
||||
|
||||
{activeTab === 'monitoring' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
@@ -469,6 +702,256 @@ export default function ObjectStorage() {
|
||||
</div>
|
||||
<div className="h-12 w-full shrink-0"></div>
|
||||
</main>
|
||||
|
||||
{/* Create Bucket Modal */}
|
||||
{showCreateBucketModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-xl font-bold">Create New Bucket</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateBucketModal(false)
|
||||
setNewBucketName('')
|
||||
}}
|
||||
className="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Bucket Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBucketName}
|
||||
onChange={(e) => setNewBucketName(e.target.value)}
|
||||
placeholder="e.g., my-bucket"
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newBucketName.trim()) {
|
||||
createBucketMutation.mutate(newBucketName.trim())
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
Bucket names must be unique and follow S3 naming conventions (lowercase, numbers, hyphens only)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateBucketModal(false)
|
||||
setNewBucketName('')
|
||||
}}
|
||||
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newBucketName.trim()) {
|
||||
createBucketMutation.mutate(newBucketName.trim())
|
||||
} else {
|
||||
alert('Please enter a bucket name')
|
||||
}
|
||||
}}
|
||||
disabled={createBucketMutation.isPending || !newBucketName.trim()}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBucketMutation.isPending ? 'Creating...' : 'Create Bucket'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup Modal */}
|
||||
{showSetupModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-xl font-bold">
|
||||
{currentSetup ? 'Change Object Storage Dataset' : 'Setup Object Storage'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSetupModal(false)}
|
||||
className="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Select Pool</label>
|
||||
<select
|
||||
value={selectedPool}
|
||||
onChange={(e) => {
|
||||
setSelectedPool(e.target.value)
|
||||
setSelectedDataset('')
|
||||
}}
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
>
|
||||
<option value="">-- Select Pool --</option>
|
||||
{availableDatasets.map((pool) => (
|
||||
<option key={pool.pool_id} value={pool.pool_name}>
|
||||
{pool.pool_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center text-white text-sm font-medium mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createNewDataset}
|
||||
onChange={(e) => setCreateNewDataset(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
Create New Dataset
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{createNewDataset ? (
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Dataset Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDatasetName}
|
||||
onChange={(e) => setNewDatasetName(e.target.value)}
|
||||
placeholder="e.g., object-storage"
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Select Dataset</label>
|
||||
<select
|
||||
value={selectedDataset}
|
||||
onChange={(e) => setSelectedDataset(e.target.value)}
|
||||
disabled={!selectedPool}
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="">-- Select Dataset --</option>
|
||||
{selectedPool &&
|
||||
availableDatasets
|
||||
.find((p) => p.pool_name === selectedPool)
|
||||
?.datasets.map((ds) => (
|
||||
<option key={ds.id} value={ds.name}>
|
||||
{ds.name} ({formatBytes(ds.available_bytes, 1)} available)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSetup && (
|
||||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 space-y-2">
|
||||
<p className="text-orange-400 text-sm font-medium">Current Configuration:</p>
|
||||
<p className="text-orange-300 text-sm">
|
||||
Dataset: <span className="font-mono">{currentSetup.dataset_path}</span>
|
||||
</p>
|
||||
<p className="text-orange-300 text-sm">
|
||||
Mount Point: <span className="font-mono">{currentSetup.mount_point}</span>
|
||||
</p>
|
||||
<p className="text-orange-400 text-xs mt-2">
|
||||
⚠️ Warning: Changing the dataset will update MinIO configuration. Existing data in the current dataset will NOT be automatically migrated. Make sure to backup or migrate data before changing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
onClick={() => setShowSetupModal(false)}
|
||||
className="px-4 py-2 bg-[#233648] hover:bg-[#2b4055] text-white text-sm font-medium rounded-lg border border-border-dark transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedPool) {
|
||||
alert('Please select a pool')
|
||||
return
|
||||
}
|
||||
if (createNewDataset && !newDatasetName) {
|
||||
alert('Please enter a dataset name')
|
||||
return
|
||||
}
|
||||
if (!createNewDataset && !selectedDataset) {
|
||||
alert('Please select a dataset')
|
||||
return
|
||||
}
|
||||
setupMutation.mutate({
|
||||
poolName: selectedPool,
|
||||
datasetName: createNewDataset ? newDatasetName : selectedDataset,
|
||||
createNew: createNewDataset,
|
||||
})
|
||||
}}
|
||||
disabled={setupMutation.isPending}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{setupMutation.isPending
|
||||
? currentSetup
|
||||
? 'Updating...'
|
||||
: 'Setting up...'
|
||||
: currentSetup
|
||||
? 'Update Dataset'
|
||||
: 'Setup'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete Bucket Confirmation Dialog */}
|
||||
{deleteConfirmBucket && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg">
|
||||
<AlertCircle className="text-red-400" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-bold">Delete Bucket</h2>
|
||||
<p className="text-text-secondary text-sm">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-white text-sm mb-2">
|
||||
Are you sure you want to delete bucket <span className="font-mono font-semibold text-primary">{deleteConfirmBucket}</span>?
|
||||
</p>
|
||||
<p className="text-text-secondary text-xs">
|
||||
All objects in this bucket will be permanently deleted. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmBucket(null)}
|
||||
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteBucketMutation.mutate(deleteConfirmBucket)
|
||||
setDeleteConfirmBucket(null)
|
||||
}}
|
||||
disabled={deleteBucketMutation.isPending}
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{deleteBucketMutation.isPending ? 'Deleting...' : 'Delete Bucket'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
952
frontend/src/pages/UsersAndKeys.tsx
Normal file
952
frontend/src/pages/UsersAndKeys.tsx
Normal file
@@ -0,0 +1,952 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { objectStorageApi, User, ServiceAccount, CreateUserRequest, CreateServiceAccountRequest } from '@/api/objectStorage'
|
||||
import {
|
||||
Users,
|
||||
Key,
|
||||
UserPlus,
|
||||
KeyRound,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
CheckCircle2
|
||||
} from 'lucide-react'
|
||||
|
||||
interface UsersAndKeysProps {
|
||||
S3_ENDPOINT: string
|
||||
}
|
||||
|
||||
export default function UsersAndKeys({ S3_ENDPOINT }: UsersAndKeysProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'users' | 'keys'>('users')
|
||||
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
|
||||
const [newUserAccessKey, setNewUserAccessKey] = useState('')
|
||||
const [newUserSecretKey, setNewUserSecretKey] = useState('')
|
||||
const [showCreateKeyModal, setShowCreateKeyModal] = useState(false)
|
||||
const [newKeyParentUser, setNewKeyParentUser] = useState('')
|
||||
const [newKeyPolicy, setNewKeyPolicy] = useState('')
|
||||
const [newKeyExpiration, setNewKeyExpiration] = useState('')
|
||||
const [createdKey, setCreatedKey] = useState<ServiceAccount | null>(null)
|
||||
const [showSecretKey, setShowSecretKey] = useState(false)
|
||||
const [deleteConfirmUser, setDeleteConfirmUser] = useState<string | null>(null)
|
||||
const [deleteConfirmKey, setDeleteConfirmKey] = useState<string | null>(null)
|
||||
const [isRefreshingUsers, setIsRefreshingUsers] = useState(false)
|
||||
const [isRefreshingKeys, setIsRefreshingKeys] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch users
|
||||
const { data: users = [], isLoading: usersLoading } = useQuery<User[]>({
|
||||
queryKey: ['object-storage-users'],
|
||||
queryFn: objectStorageApi.listUsers,
|
||||
refetchInterval: 10000, // Auto-refresh every 10 seconds
|
||||
})
|
||||
|
||||
// Fetch service accounts (keys)
|
||||
const { data: serviceAccounts = [], isLoading: keysLoading } = useQuery<ServiceAccount[]>({
|
||||
queryKey: ['object-storage-service-accounts'],
|
||||
queryFn: objectStorageApi.listServiceAccounts,
|
||||
refetchInterval: 10000, // Auto-refresh every 10 seconds
|
||||
})
|
||||
|
||||
// Create user mutation with optimistic update
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: (data: CreateUserRequest) => objectStorageApi.createUser(data),
|
||||
onMutate: async (data: CreateUserRequest) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['object-storage-users'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousUsers = queryClient.getQueryData(['object-storage-users'])
|
||||
|
||||
// Optimistically add the new user to the list
|
||||
queryClient.setQueryData(['object-storage-users'], (old: User[] = []) => {
|
||||
const newUser: User = {
|
||||
access_key: data.access_key,
|
||||
status: 'enabled',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
return [...old, newUser]
|
||||
})
|
||||
|
||||
// Close modal immediately
|
||||
setShowCreateUserModal(false)
|
||||
setNewUserAccessKey('')
|
||||
setNewUserSecretKey('')
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousUsers }
|
||||
},
|
||||
onError: (error: any, _data: CreateUserRequest, context: any) => {
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
if (context?.previousUsers) {
|
||||
queryClient.setQueryData(['object-storage-users'], context.previousUsers)
|
||||
}
|
||||
// Reopen modal on error
|
||||
setShowCreateUserModal(true)
|
||||
alert(error.response?.data?.error || 'Failed to create user')
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Refetch to ensure we have the latest data from server
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
|
||||
alert('User created successfully!')
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Delete user mutation
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: (accessKey: string) => objectStorageApi.deleteUser(accessKey),
|
||||
onMutate: async (accessKey: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['object-storage-users'] })
|
||||
const previousUsers = queryClient.getQueryData(['object-storage-users'])
|
||||
queryClient.setQueryData(['object-storage-users'], (old: User[] = []) =>
|
||||
old.filter((user) => user.access_key !== accessKey)
|
||||
)
|
||||
return { previousUsers }
|
||||
},
|
||||
onError: (error: any, _accessKey: string, context: any) => {
|
||||
if (context?.previousUsers) {
|
||||
queryClient.setQueryData(['object-storage-users'], context.previousUsers)
|
||||
}
|
||||
alert(error.response?.data?.error || 'Failed to delete user')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Create service account mutation with optimistic update
|
||||
const createServiceAccountMutation = useMutation({
|
||||
mutationFn: (data: CreateServiceAccountRequest) => objectStorageApi.createServiceAccount(data),
|
||||
onMutate: async (_data: CreateServiceAccountRequest) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousAccounts = queryClient.getQueryData(['object-storage-service-accounts'])
|
||||
|
||||
// Close modal immediately (we'll show created key modal after success)
|
||||
setShowCreateKeyModal(false)
|
||||
setNewKeyParentUser('')
|
||||
setNewKeyPolicy('')
|
||||
setNewKeyExpiration('')
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousAccounts }
|
||||
},
|
||||
onError: (error: any, _data: CreateServiceAccountRequest, context: any) => {
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
if (context?.previousAccounts) {
|
||||
queryClient.setQueryData(['object-storage-service-accounts'], context.previousAccounts)
|
||||
}
|
||||
// Reopen modal on error
|
||||
setShowCreateKeyModal(true)
|
||||
alert(error.response?.data?.error || 'Failed to create access key')
|
||||
},
|
||||
onSuccess: (data: ServiceAccount) => {
|
||||
// Optimistically add the new service account to the list
|
||||
queryClient.setQueryData(['object-storage-service-accounts'], (old: ServiceAccount[] = []) => {
|
||||
return [...old, data]
|
||||
})
|
||||
|
||||
// Show created key modal with secret key
|
||||
setCreatedKey(data)
|
||||
|
||||
// Refetch to ensure we have the latest data from server
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Delete service account mutation
|
||||
const deleteServiceAccountMutation = useMutation({
|
||||
mutationFn: (accessKey: string) => objectStorageApi.deleteServiceAccount(accessKey),
|
||||
onMutate: async (accessKey: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
const previousAccounts = queryClient.getQueryData(['object-storage-service-accounts'])
|
||||
queryClient.setQueryData(['object-storage-service-accounts'], (old: ServiceAccount[] = []) =>
|
||||
old.filter((account) => account.access_key !== accessKey)
|
||||
)
|
||||
return { previousAccounts }
|
||||
},
|
||||
onError: (error: any, _accessKey: string, context: any) => {
|
||||
if (context?.previousAccounts) {
|
||||
queryClient.setQueryData(['object-storage-service-accounts'], context.previousAccounts)
|
||||
}
|
||||
alert(error.response?.data?.error || 'Failed to delete access key')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Refresh users
|
||||
const handleRefreshUsers = async () => {
|
||||
setIsRefreshingUsers(true)
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['object-storage-users'] })
|
||||
setTimeout(() => {
|
||||
alert('Users refreshed successfully!')
|
||||
}, 300)
|
||||
} catch (error) {
|
||||
alert('Failed to refresh users')
|
||||
} finally {
|
||||
setIsRefreshingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh keys
|
||||
const handleRefreshKeys = async () => {
|
||||
setIsRefreshingKeys(true)
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['object-storage-service-accounts'] })
|
||||
setTimeout(() => {
|
||||
alert('Access keys refreshed successfully!')
|
||||
}, 300)
|
||||
} catch (error) {
|
||||
alert('Failed to refresh access keys')
|
||||
} finally {
|
||||
setIsRefreshingKeys(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
alert(`${label} copied to clipboard!`)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
// Fallback
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
alert(`${label} copied to clipboard!`)
|
||||
} catch (err) {
|
||||
alert(`Failed to copy. ${label}: ${text}`)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sub-tabs for Users and Keys */}
|
||||
<div className="flex items-center gap-2 border-b border-border-dark">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('users')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeSubTab === 'users'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={18} />
|
||||
<span>Users ({users.length})</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('keys')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeSubTab === 'keys'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={18} />
|
||||
<span>Access Keys ({serviceAccounts.length})</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeSubTab === 'users' && (
|
||||
<div className="space-y-4">
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-white text-xl font-bold">IAM Users</h2>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Manage MinIO IAM users for accessing object storage
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRefreshUsers}
|
||||
disabled={isRefreshingUsers}
|
||||
className="px-4 py-2 bg-[#233648] hover:bg-[#2b4055] text-white text-sm font-medium rounded-lg border border-border-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={isRefreshingUsers ? 'animate-spin' : ''} />
|
||||
{isRefreshingUsers ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateUserModal(true)}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
{usersLoading ? (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<p className="text-text-secondary text-sm">Loading users...</p>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Users className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm mb-4">No users found</p>
|
||||
<button
|
||||
onClick={() => setShowCreateUserModal(true)}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Create First User
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[#16202a] border-b border-border-dark">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Access Key
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-dark">
|
||||
{users.map((user) => (
|
||||
<tr key={user.access_key} className="hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={16} className="text-primary" />
|
||||
<span className="text-white font-mono text-sm">{user.access_key}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
user.status === 'enabled'
|
||||
? 'bg-green-500/10 text-green-500 border border-green-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{user.status === 'enabled' ? (
|
||||
<>
|
||||
<CheckCircle2 size={12} className="mr-1" />
|
||||
Enabled
|
||||
</>
|
||||
) : (
|
||||
'Disabled'
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await copyToClipboard(user.access_key, 'Access Key')
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="Copy Access Key"
|
||||
>
|
||||
<Copy size={14} />
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmUser(user.access_key)}
|
||||
disabled={deleteUserMutation.isPending}
|
||||
className="px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Delete User"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys Tab */}
|
||||
{activeSubTab === 'keys' && (
|
||||
<div className="space-y-4">
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-white text-xl font-bold">Access Keys</h2>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Manage service account access keys for programmatic access
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRefreshKeys}
|
||||
disabled={isRefreshingKeys}
|
||||
className="px-4 py-2 bg-[#233648] hover:bg-[#2b4055] text-white text-sm font-medium rounded-lg border border-border-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={isRefreshingKeys ? 'animate-spin' : ''} />
|
||||
{isRefreshingKeys ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (users.length === 0) {
|
||||
alert('Please create at least one user before creating access keys')
|
||||
setActiveSubTab('users')
|
||||
return
|
||||
}
|
||||
setShowCreateKeyModal(true)
|
||||
}}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
Create Access Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keys Table */}
|
||||
{keysLoading ? (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<p className="text-text-secondary text-sm">Loading access keys...</p>
|
||||
</div>
|
||||
) : serviceAccounts.length === 0 ? (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Key className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm mb-4">No access keys found</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (users.length === 0) {
|
||||
alert('Please create at least one user before creating access keys')
|
||||
setActiveSubTab('users')
|
||||
return
|
||||
}
|
||||
setShowCreateKeyModal(true)
|
||||
}}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Create First Access Key
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[#16202a] border-b border-border-dark">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Access Key
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Parent User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Expiration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-dark">
|
||||
{serviceAccounts.map((account) => (
|
||||
<tr key={account.access_key} className="hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={16} className="text-primary" />
|
||||
<span className="text-white font-mono text-sm">{account.access_key}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
|
||||
{account.parent_user}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
|
||||
{account.expiration ? formatDate(account.expiration) : 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
|
||||
{formatDate(account.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await copyToClipboard(account.access_key, 'Access Key')
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="Copy Access Key"
|
||||
>
|
||||
<Copy size={14} />
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmKey(account.access_key)}
|
||||
disabled={deleteServiceAccountMutation.isPending}
|
||||
className="px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Delete Access Key"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateUserModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-xl font-bold">Create IAM User</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateUserModal(false)
|
||||
setNewUserAccessKey('')
|
||||
setNewUserSecretKey('')
|
||||
}}
|
||||
className="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Access Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUserAccessKey}
|
||||
onChange={(e) => setNewUserAccessKey(e.target.value)}
|
||||
placeholder="e.g., myuser"
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
Access key must be unique and follow MinIO naming conventions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Secret Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecretKey ? 'text' : 'password'}
|
||||
value={newUserSecretKey}
|
||||
onChange={(e) => setNewUserSecretKey(e.target.value)}
|
||||
placeholder="Enter secret key"
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 pr-10 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecretKey(!showSecretKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary hover:text-white"
|
||||
>
|
||||
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
Secret key must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateUserModal(false)
|
||||
setNewUserAccessKey('')
|
||||
setNewUserSecretKey('')
|
||||
}}
|
||||
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!newUserAccessKey.trim()) {
|
||||
alert('Please enter an access key')
|
||||
return
|
||||
}
|
||||
if (!newUserSecretKey.trim() || newUserSecretKey.length < 8) {
|
||||
alert('Please enter a secret key (minimum 8 characters)')
|
||||
return
|
||||
}
|
||||
createUserMutation.mutate({
|
||||
access_key: newUserAccessKey.trim(),
|
||||
secret_key: newUserSecretKey,
|
||||
})
|
||||
}}
|
||||
disabled={createUserMutation.isPending || !newUserAccessKey.trim() || !newUserSecretKey.trim()}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Access Key Modal */}
|
||||
{showCreateKeyModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-xl font-bold">Create Access Key</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateKeyModal(false)
|
||||
setNewKeyParentUser('')
|
||||
setNewKeyPolicy('')
|
||||
setNewKeyExpiration('')
|
||||
}}
|
||||
className="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Parent User</label>
|
||||
<select
|
||||
value={newKeyParentUser}
|
||||
onChange={(e) => setNewKeyParentUser(e.target.value)}
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
autoFocus
|
||||
>
|
||||
<option value="">-- Select User --</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.access_key} value={user.access_key}>
|
||||
{user.access_key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
Select the IAM user this access key will belong to
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">
|
||||
Policy (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={newKeyPolicy}
|
||||
onChange={(e) => setNewKeyPolicy(e.target.value)}
|
||||
placeholder='{"Version":"2012-10-17","Statement":[...]}'
|
||||
rows={4}
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none font-mono"
|
||||
/>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
JSON policy document (leave empty for default permissions)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">
|
||||
Expiration (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={newKeyExpiration}
|
||||
onChange={(e) => setNewKeyExpiration(e.target.value)}
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
/>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
Leave empty for no expiration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateKeyModal(false)
|
||||
setNewKeyParentUser('')
|
||||
setNewKeyPolicy('')
|
||||
setNewKeyExpiration('')
|
||||
}}
|
||||
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!newKeyParentUser.trim()) {
|
||||
alert('Please select a parent user')
|
||||
return
|
||||
}
|
||||
createServiceAccountMutation.mutate({
|
||||
parent_user: newKeyParentUser.trim(),
|
||||
policy: newKeyPolicy.trim() || undefined,
|
||||
expiration: newKeyExpiration ? new Date(newKeyExpiration).toISOString() : undefined,
|
||||
})
|
||||
}}
|
||||
disabled={createServiceAccountMutation.isPending || !newKeyParentUser.trim()}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createServiceAccountMutation.isPending ? 'Creating...' : 'Create Access Key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete User Confirmation Dialog */}
|
||||
{deleteConfirmUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg">
|
||||
<AlertCircle className="text-red-400" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-bold">Delete User</h2>
|
||||
<p className="text-text-secondary text-sm">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-white text-sm mb-2">
|
||||
Are you sure you want to delete user <span className="font-mono font-semibold text-primary">{deleteConfirmUser}</span>?
|
||||
</p>
|
||||
<p className="text-text-secondary text-xs">
|
||||
All access keys associated with this user will also be deleted. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmUser(null)}
|
||||
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteUserMutation.mutate(deleteConfirmUser)
|
||||
setDeleteConfirmUser(null)
|
||||
}}
|
||||
disabled={deleteUserMutation.isPending}
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{deleteUserMutation.isPending ? 'Deleting...' : 'Delete User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Key Confirmation Dialog */}
|
||||
{deleteConfirmKey && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg">
|
||||
<AlertCircle className="text-red-400" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-bold">Delete Access Key</h2>
|
||||
<p className="text-text-secondary text-sm">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-white text-sm mb-2">
|
||||
Are you sure you want to delete access key <span className="font-mono font-semibold text-primary">{deleteConfirmKey}</span>?
|
||||
</p>
|
||||
<p className="text-text-secondary text-xs">
|
||||
Applications using this access key will lose access immediately. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmKey(null)}
|
||||
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteServiceAccountMutation.mutate(deleteConfirmKey)
|
||||
setDeleteConfirmKey(null)
|
||||
}}
|
||||
disabled={deleteServiceAccountMutation.isPending}
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{deleteServiceAccountMutation.isPending ? 'Deleting...' : 'Delete Key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Created Key Modal (shows secret key once) */}
|
||||
{createdKey && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-lg w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-xl font-bold">Access Key Created</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreatedKey(null)
|
||||
setShowSecretKey(false)
|
||||
}}
|
||||
className="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="text-orange-400 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="text-orange-400 text-sm font-medium mb-1">Important</p>
|
||||
<p className="text-orange-300 text-xs">
|
||||
Save these credentials now. The secret key will not be shown again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={createdKey.access_key}
|
||||
readOnly
|
||||
className="flex-1 bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(createdKey.access_key, 'Access Key')}
|
||||
className="px-3 py-2 bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Secret Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showSecretKey ? 'text' : 'password'}
|
||||
value={createdKey.secret_key || ''}
|
||||
readOnly
|
||||
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 pr-10 text-white text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecretKey(!showSecretKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary hover:text-white"
|
||||
>
|
||||
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(createdKey.secret_key || '', 'Secret Key')}
|
||||
className="px-3 py-2 bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">Endpoint</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={S3_ENDPOINT}
|
||||
readOnly
|
||||
className="flex-1 bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(S3_ENDPOINT, 'Endpoint')}
|
||||
className="px-3 py-2 bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreatedKey(null)
|
||||
setShowSecretKey(false)
|
||||
}}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
I've Saved These Credentials
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user