add function to s3

This commit is contained in:
2026-01-10 05:36:15 +00:00
parent 7b91e0fd24
commit 8a3ff6a12c
19 changed files with 3715 additions and 134 deletions

View 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

View 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

View 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

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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()

View File

@@ -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';

View File

@@ -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)

View 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"})
}

View 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
}

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"`

View 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`

View 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
}

View File

@@ -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
},
}

View File

@@ -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>
)
}

View 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>
)
}