working on storage dashboard
This commit is contained in:
61
CHECK-BACKEND-LOGS.md
Normal file
61
CHECK-BACKEND-LOGS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Cara Cek Backend Logs
|
||||
|
||||
## Lokasi Log File
|
||||
Backend logs ditulis ke: `/tmp/backend-api.log`
|
||||
|
||||
## Cara Melihat Logs
|
||||
|
||||
### 1. Lihat Logs Real-time (Live)
|
||||
```bash
|
||||
tail -f /tmp/backend-api.log
|
||||
```
|
||||
|
||||
### 2. Lihat Logs Terakhir (50 baris)
|
||||
```bash
|
||||
tail -50 /tmp/backend-api.log
|
||||
```
|
||||
|
||||
### 3. Filter Logs untuk Error ZFS Pool
|
||||
```bash
|
||||
tail -100 /tmp/backend-api.log | grep -i "zfs\|pool\|create\|error\|failed"
|
||||
```
|
||||
|
||||
### 4. Lihat Logs dengan Format JSON yang Lebih Readable
|
||||
```bash
|
||||
tail -50 /tmp/backend-api.log | jq '.'
|
||||
```
|
||||
|
||||
### 5. Monitor Logs Real-time untuk ZFS Pool Creation
|
||||
```bash
|
||||
tail -f /tmp/backend-api.log | grep -i "zfs\|pool\|create"
|
||||
```
|
||||
|
||||
## Restart Backend
|
||||
|
||||
Backend perlu di-restart setelah perubahan code untuk load route baru:
|
||||
|
||||
```bash
|
||||
# 1. Cari process ID backend
|
||||
ps aux | grep calypso-api | grep -v grep
|
||||
|
||||
# 2. Kill process (ganti PID dengan process ID yang ditemukan)
|
||||
kill <PID>
|
||||
|
||||
# 3. Restart backend
|
||||
cd /development/calypso/backend
|
||||
export CALYPSO_DB_PASSWORD="calypso123"
|
||||
export CALYPSO_JWT_SECRET="test-jwt-secret-key-minimum-32-characters-long"
|
||||
go run ./cmd/calypso-api -config config.yaml.example > /tmp/backend-api.log 2>&1 &
|
||||
|
||||
# 4. Cek apakah backend sudah running
|
||||
sleep 3
|
||||
tail -20 /tmp/backend-api.log
|
||||
```
|
||||
|
||||
## Masalah yang Ditemukan
|
||||
|
||||
Dari logs, terlihat:
|
||||
- **Status 404** untuk `POST /api/v1/storage/zfs/pools`
|
||||
- Route sudah ada di code, tapi backend belum di-restart
|
||||
- **Solusi**: Restart backend untuk load route baru
|
||||
|
||||
37
PERMISSIONS-FIX.md
Normal file
37
PERMISSIONS-FIX.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Permissions Fix - Admin User
|
||||
|
||||
## Issue
|
||||
The admin user was getting 403 Forbidden errors when accessing API endpoints because the admin role didn't have any permissions assigned.
|
||||
|
||||
## Solution
|
||||
All 18 permissions have been assigned to the admin role:
|
||||
|
||||
- `audit:read`
|
||||
- `iam:manage`, `iam:read`, `iam:write`
|
||||
- `iscsi:manage`, `iscsi:read`, `iscsi:write`
|
||||
- `monitoring:read`, `monitoring:write`
|
||||
- `storage:manage`, `storage:read`, `storage:write`
|
||||
- `system:manage`, `system:read`, `system:write`
|
||||
- `tape:manage`, `tape:read`, `tape:write`
|
||||
|
||||
## Action Required
|
||||
|
||||
**You need to log out and log back in** to refresh your authentication token with the updated permissions.
|
||||
|
||||
1. Click "Logout" in the sidebar
|
||||
2. Log back in with:
|
||||
- Username: `admin`
|
||||
- Password: `admin123`
|
||||
3. The dashboard should now load all data correctly
|
||||
|
||||
## Verification
|
||||
|
||||
After logging back in, you should see:
|
||||
- ✅ Metrics loading (CPU, RAM, Storage, etc.)
|
||||
- ✅ Alerts loading
|
||||
- ✅ Storage repositories loading
|
||||
- ✅ No more 403 errors in the console
|
||||
|
||||
## Status
|
||||
✅ **FIXED** - All permissions assigned to admin role
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,26 @@
|
||||
-- Storage and Tape Component Schema
|
||||
-- Version: 2.0
|
||||
|
||||
-- ZFS pools table
|
||||
CREATE TABLE IF NOT EXISTS zfs_pools (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
raid_level VARCHAR(50) NOT NULL, -- stripe, mirror, raidz, raidz2, raidz3
|
||||
disks TEXT[] NOT NULL, -- array of device paths
|
||||
size_bytes BIGINT NOT NULL,
|
||||
used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
compression VARCHAR(50) NOT NULL DEFAULT 'lz4', -- off, lz4, zstd, gzip
|
||||
deduplication BOOLEAN NOT NULL DEFAULT false,
|
||||
auto_expand BOOLEAN NOT NULL DEFAULT false,
|
||||
scrub_interval INTEGER NOT NULL DEFAULT 30, -- days
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
health_status VARCHAR(50) NOT NULL DEFAULT 'online', -- online, degraded, faulted, offline
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Disk repositories table
|
||||
CREATE TABLE IF NOT EXISTS disk_repositories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- AtlasOS - Calypso
|
||||
-- Add ZFS Pools Table
|
||||
-- Version: 4.0
|
||||
-- Note: This migration adds the zfs_pools table that was added to migration 002
|
||||
-- but may not have been applied if migration 002 was run before the table was added
|
||||
|
||||
-- ZFS pools table
|
||||
CREATE TABLE IF NOT EXISTS zfs_pools (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
raid_level VARCHAR(50) NOT NULL, -- stripe, mirror, raidz, raidz2, raidz3
|
||||
disks TEXT[] NOT NULL, -- array of device paths
|
||||
size_bytes BIGINT NOT NULL,
|
||||
used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
compression VARCHAR(50) NOT NULL DEFAULT 'lz4', -- off, lz4, zstd, gzip
|
||||
deduplication BOOLEAN NOT NULL DEFAULT false,
|
||||
auto_expand BOOLEAN NOT NULL DEFAULT false,
|
||||
scrub_interval INTEGER NOT NULL DEFAULT 30, -- days
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
health_status VARCHAR(50) NOT NULL DEFAULT 'online', -- online, degraded, faulted, offline
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Create index on name for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_zfs_pools_name ON zfs_pools(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_zfs_pools_created_by ON zfs_pools(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_zfs_pools_health_status ON zfs_pools(health_status);
|
||||
|
||||
@@ -168,8 +168,18 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
storageGroup.GET("/volume-groups", storageHandler.ListVolumeGroups)
|
||||
storageGroup.GET("/repositories", storageHandler.ListRepositories)
|
||||
storageGroup.GET("/repositories/:id", storageHandler.GetRepository)
|
||||
storageGroup.POST("/repositories", storageHandler.CreateRepository)
|
||||
storageGroup.DELETE("/repositories/:id", storageHandler.DeleteRepository)
|
||||
storageGroup.POST("/repositories", requirePermission("storage", "write"), storageHandler.CreateRepository)
|
||||
storageGroup.DELETE("/repositories/:id", requirePermission("storage", "write"), storageHandler.DeleteRepository)
|
||||
// ZFS Pools
|
||||
storageGroup.GET("/zfs/pools", storageHandler.ListZFSPools)
|
||||
storageGroup.GET("/zfs/pools/:id", storageHandler.GetZFSPool)
|
||||
storageGroup.POST("/zfs/pools", requirePermission("storage", "write"), storageHandler.CreateZPool)
|
||||
storageGroup.DELETE("/zfs/pools/:id", requirePermission("storage", "write"), storageHandler.DeleteZFSPool)
|
||||
storageGroup.POST("/zfs/pools/:id/spare", requirePermission("storage", "write"), storageHandler.AddSpareDisk)
|
||||
// ZFS Datasets
|
||||
storageGroup.GET("/zfs/pools/:id/datasets", storageHandler.ListZFSDatasets)
|
||||
storageGroup.POST("/zfs/pools/:id/datasets", requirePermission("storage", "write"), storageHandler.CreateZFSDataset)
|
||||
storageGroup.DELETE("/zfs/pools/:id/datasets/:dataset", requirePermission("storage", "write"), storageHandler.DeleteZFSDataset)
|
||||
}
|
||||
|
||||
// SCST
|
||||
|
||||
@@ -41,6 +41,7 @@ type PhysicalDisk struct {
|
||||
HealthStatus string `json:"health_status"`
|
||||
HealthDetails map[string]interface{} `json:"health_details"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
AttachedToPool string `json:"attached_to_pool"` // Pool name if disk is used in a ZFS pool
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -74,6 +75,13 @@ func (s *DiskService) DiscoverDisks(ctx context.Context) ([]PhysicalDisk, error)
|
||||
}
|
||||
|
||||
devicePath := "/dev/" + device.Name
|
||||
|
||||
// Skip OS disk (disk that has root or boot partition)
|
||||
if s.isOSDisk(ctx, devicePath) {
|
||||
s.logger.Debug("Skipping OS disk", "device", devicePath)
|
||||
continue
|
||||
}
|
||||
|
||||
disk, err := s.getDiskInfo(ctx, devicePath)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get disk info", "device", devicePath, "error", err)
|
||||
@@ -131,9 +139,16 @@ func (s *DiskService) getDiskInfo(ctx context.Context, devicePath string) (*Phys
|
||||
disk.SectorSize = sectorSize
|
||||
}
|
||||
|
||||
// Check if disk is in use (part of a volume group)
|
||||
// Check if disk is in use (part of a volume group or ZFS pool)
|
||||
disk.IsUsed = s.isDiskInUse(ctx, devicePath)
|
||||
|
||||
// Check if disk is used in a ZFS pool
|
||||
poolName := s.getZFSPoolForDisk(ctx, devicePath)
|
||||
if poolName != "" {
|
||||
disk.IsUsed = true
|
||||
disk.AttachedToPool = poolName
|
||||
}
|
||||
|
||||
// Get health status (simplified - would use smartctl in production)
|
||||
disk.HealthStatus = "healthy" // Placeholder
|
||||
|
||||
@@ -160,6 +175,87 @@ func (s *DiskService) isDiskInUse(ctx context.Context, devicePath string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// getZFSPoolForDisk checks if a disk is used in a ZFS pool and returns the pool name
|
||||
func (s *DiskService) getZFSPoolForDisk(ctx context.Context, devicePath string) string {
|
||||
// Extract device name (e.g., /dev/sde -> sde)
|
||||
deviceName := strings.TrimPrefix(devicePath, "/dev/")
|
||||
|
||||
// Get all ZFS pools
|
||||
cmd := exec.CommandContext(ctx, "zpool", "list", "-H", "-o", "name")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
pools := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, poolName := range pools {
|
||||
if poolName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check pool status for this device
|
||||
statusCmd := exec.CommandContext(ctx, "zpool", "status", poolName)
|
||||
statusOutput, err := statusCmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusStr := string(statusOutput)
|
||||
// Check if device is in the pool (as data disk or spare)
|
||||
if strings.Contains(statusStr, deviceName) {
|
||||
return poolName
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isOSDisk checks if a disk is used as OS disk (has root or boot partition)
|
||||
func (s *DiskService) isOSDisk(ctx context.Context, devicePath string) bool {
|
||||
// Extract device name (e.g., /dev/sda -> sda)
|
||||
deviceName := strings.TrimPrefix(devicePath, "/dev/")
|
||||
|
||||
// Check if any partition of this disk is mounted as root or boot
|
||||
// Use lsblk to get mount points for this device and its children
|
||||
cmd := exec.CommandContext(ctx, "lsblk", "-n", "-o", "NAME,MOUNTPOINT", devicePath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
mountPoint := fields[1]
|
||||
// Check if mounted as root or boot
|
||||
if mountPoint == "/" || mountPoint == "/boot" || mountPoint == "/boot/efi" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check all partitions of this disk using lsblk with recursive listing
|
||||
partCmd := exec.CommandContext(ctx, "lsblk", "-n", "-o", "NAME,MOUNTPOINT", "-l")
|
||||
partOutput, err := partCmd.Output()
|
||||
if err == nil {
|
||||
partLines := strings.Split(string(partOutput), "\n")
|
||||
for _, line := range partLines {
|
||||
if strings.HasPrefix(line, deviceName) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
mountPoint := fields[1]
|
||||
if mountPoint == "/" || mountPoint == "/boot" || mountPoint == "/boot/efi" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SyncDisksToDatabase syncs discovered disks to the database
|
||||
func (s *DiskService) SyncDisksToDatabase(ctx context.Context) error {
|
||||
disks, err := s.DiscoverDisks(ctx)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
type Handler struct {
|
||||
diskService *DiskService
|
||||
lvmService *LVMService
|
||||
zfsService *ZFSService
|
||||
taskEngine *tasks.Engine
|
||||
db *database.DB
|
||||
logger *logger.Logger
|
||||
@@ -23,6 +26,7 @@ func NewHandler(db *database.DB, log *logger.Logger) *Handler {
|
||||
return &Handler{
|
||||
diskService: NewDiskService(db, log),
|
||||
lvmService: NewLVMService(db, log),
|
||||
zfsService: NewZFSService(db, log),
|
||||
taskEngine: tasks.NewEngine(db, log),
|
||||
db: db,
|
||||
logger: log,
|
||||
@@ -167,3 +171,288 @@ func (h *Handler) DeleteRepository(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "repository deleted successfully"})
|
||||
}
|
||||
|
||||
// CreateZPoolRequest represents a ZFS pool creation request
|
||||
type CreateZPoolRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
RaidLevel string `json:"raid_level" binding:"required"` // stripe, mirror, raidz, raidz2, raidz3
|
||||
Disks []string `json:"disks" binding:"required"` // device paths
|
||||
Compression string `json:"compression"` // off, lz4, zstd, gzip
|
||||
Deduplication bool `json:"deduplication"`
|
||||
AutoExpand bool `json:"auto_expand"`
|
||||
}
|
||||
|
||||
// CreateZPool creates a new ZFS pool
|
||||
func (h *Handler) CreateZPool(c *gin.Context) {
|
||||
var req CreateZPoolRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid request body", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool name is required"})
|
||||
return
|
||||
}
|
||||
if req.RaidLevel == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "raid_level is required"})
|
||||
return
|
||||
}
|
||||
if len(req.Disks) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "at least one disk is required"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
h.logger.Error("User ID not found in context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
userIDStr, ok := userID.(string)
|
||||
if !ok {
|
||||
h.logger.Error("Invalid user ID type", "type", fmt.Sprintf("%T", userID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default compression if not provided
|
||||
if req.Compression == "" {
|
||||
req.Compression = "lz4"
|
||||
}
|
||||
|
||||
h.logger.Info("Creating ZFS pool request", "name", req.Name, "raid_level", req.RaidLevel, "disks", req.Disks, "compression", req.Compression)
|
||||
|
||||
pool, err := h.zfsService.CreatePool(
|
||||
c.Request.Context(),
|
||||
req.Name,
|
||||
req.RaidLevel,
|
||||
req.Disks,
|
||||
req.Compression,
|
||||
req.Deduplication,
|
||||
req.AutoExpand,
|
||||
userIDStr,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create ZFS pool", "error", err, "name", req.Name, "raid_level", req.RaidLevel, "disks", req.Disks)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("ZFS pool created successfully", "pool_id", pool.ID, "name", pool.Name)
|
||||
c.JSON(http.StatusCreated, pool)
|
||||
}
|
||||
|
||||
// ListZFSPools lists all ZFS pools
|
||||
func (h *Handler) ListZFSPools(c *gin.Context) {
|
||||
pools, err := h.zfsService.ListPools(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list ZFS pools", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list ZFS pools"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"pools": pools})
|
||||
}
|
||||
|
||||
// GetZFSPool retrieves a ZFS pool by ID
|
||||
func (h *Handler) GetZFSPool(c *gin.Context) {
|
||||
poolID := c.Param("id")
|
||||
|
||||
pool, err := h.zfsService.GetPool(c.Request.Context(), poolID)
|
||||
if err != nil {
|
||||
if err.Error() == "pool not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pool not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get ZFS pool", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get ZFS pool"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pool)
|
||||
}
|
||||
|
||||
// DeleteZFSPool deletes a ZFS pool
|
||||
func (h *Handler) DeleteZFSPool(c *gin.Context) {
|
||||
poolID := c.Param("id")
|
||||
|
||||
if err := h.zfsService.DeletePool(c.Request.Context(), poolID); err != nil {
|
||||
if err.Error() == "pool not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pool not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete ZFS pool", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "ZFS pool deleted successfully"})
|
||||
}
|
||||
|
||||
// AddSpareDiskRequest represents a request to add spare disks to a pool
|
||||
type AddSpareDiskRequest struct {
|
||||
Disks []string `json:"disks" binding:"required"`
|
||||
}
|
||||
|
||||
// AddSpareDisk adds spare disks to a ZFS pool
|
||||
func (h *Handler) AddSpareDisk(c *gin.Context) {
|
||||
poolID := c.Param("id")
|
||||
|
||||
var req AddSpareDiskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid add spare disk request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Disks) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "at least one disk must be specified"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.zfsService.AddSpareDisk(c.Request.Context(), poolID, req.Disks); err != nil {
|
||||
if err.Error() == "pool not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pool not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to add spare disks", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Spare disks added successfully"})
|
||||
}
|
||||
|
||||
// ListZFSDatasets lists all datasets in a ZFS pool
|
||||
func (h *Handler) ListZFSDatasets(c *gin.Context) {
|
||||
poolID := c.Param("id")
|
||||
|
||||
// Get pool to get pool name
|
||||
pool, err := h.zfsService.GetPool(c.Request.Context(), poolID)
|
||||
if err != nil {
|
||||
if err.Error() == "pool not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pool not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get pool", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pool"})
|
||||
return
|
||||
}
|
||||
|
||||
datasets, err := h.zfsService.ListDatasets(c.Request.Context(), pool.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list datasets", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"datasets": datasets})
|
||||
}
|
||||
|
||||
// CreateZFSDatasetRequest represents a request to create a ZFS dataset
|
||||
type CreateZFSDatasetRequest struct {
|
||||
Name string `json:"name" binding:"required"` // Dataset name (without pool prefix)
|
||||
Type string `json:"type" binding:"required"` // "filesystem" or "volume"
|
||||
Compression string `json:"compression"` // off, lz4, zstd, gzip, etc.
|
||||
Quota int64 `json:"quota"` // -1 for unlimited, >0 for size
|
||||
Reservation int64 `json:"reservation"` // 0 for none
|
||||
MountPoint string `json:"mount_point"` // Optional mount point
|
||||
}
|
||||
|
||||
// CreateZFSDataset creates a new ZFS dataset in a pool
|
||||
func (h *Handler) CreateZFSDataset(c *gin.Context) {
|
||||
poolID := c.Param("id")
|
||||
|
||||
// Get pool to get pool name
|
||||
pool, err := h.zfsService.GetPool(c.Request.Context(), poolID)
|
||||
if err != nil {
|
||||
if err.Error() == "pool not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pool not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get pool", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pool"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateZFSDatasetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid create dataset request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if req.Type != "filesystem" && req.Type != "volume" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "type must be 'filesystem' or 'volume'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate dataset name (should not contain pool name)
|
||||
if strings.Contains(req.Name, "/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dataset name should not contain '/' (pool name is automatically prepended)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create dataset request - CreateDatasetRequest is in the same package (zfs.go)
|
||||
createReq := CreateDatasetRequest{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Compression: req.Compression,
|
||||
Quota: req.Quota,
|
||||
Reservation: req.Reservation,
|
||||
MountPoint: req.MountPoint,
|
||||
}
|
||||
|
||||
dataset, err := h.zfsService.CreateDataset(c.Request.Context(), pool.Name, createReq)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create dataset", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, dataset)
|
||||
}
|
||||
|
||||
// DeleteZFSDataset deletes a ZFS dataset
|
||||
func (h *Handler) DeleteZFSDataset(c *gin.Context) {
|
||||
poolID := c.Param("id")
|
||||
datasetName := c.Param("dataset")
|
||||
|
||||
// Get pool to get pool name
|
||||
pool, err := h.zfsService.GetPool(c.Request.Context(), poolID)
|
||||
if err != nil {
|
||||
if err.Error() == "pool not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pool not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get pool", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pool"})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full dataset name
|
||||
fullDatasetName := pool.Name + "/" + datasetName
|
||||
|
||||
// Verify dataset belongs to this pool
|
||||
if !strings.HasPrefix(fullDatasetName, pool.Name+"/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dataset does not belong to this pool"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.zfsService.DeleteDataset(c.Request.Context(), fullDatasetName); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "dataset not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete dataset", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Dataset deleted successfully"})
|
||||
}
|
||||
|
||||
792
backend/internal/storage/zfs.go
Normal file
792
backend/internal/storage/zfs.go
Normal file
@@ -0,0 +1,792 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
)
|
||||
|
||||
// ZFSService handles ZFS pool management
|
||||
type ZFSService struct {
|
||||
db *database.DB
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewZFSService creates a new ZFS service
|
||||
func NewZFSService(db *database.DB, log *logger.Logger) *ZFSService {
|
||||
return &ZFSService{
|
||||
db: db,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// ZFSPool represents a ZFS pool
|
||||
type ZFSPool struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RaidLevel string `json:"raid_level"` // stripe, mirror, raidz, raidz2, raidz3
|
||||
Disks []string `json:"disks"` // device paths
|
||||
SpareDisks []string `json:"spare_disks"` // spare disk paths
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
Compression string `json:"compression"` // off, lz4, zstd, gzip
|
||||
Deduplication bool `json:"deduplication"`
|
||||
AutoExpand bool `json:"auto_expand"`
|
||||
ScrubInterval int `json:"scrub_interval"` // days
|
||||
IsActive bool `json:"is_active"`
|
||||
HealthStatus string `json:"health_status"` // online, degraded, faulted, offline
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// CreatePool creates a new ZFS pool
|
||||
func (s *ZFSService) CreatePool(ctx context.Context, name string, raidLevel string, disks []string, compression string, deduplication bool, autoExpand bool, createdBy string) (*ZFSPool, error) {
|
||||
// Validate inputs
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("pool name is required")
|
||||
}
|
||||
if len(disks) == 0 {
|
||||
return nil, fmt.Errorf("at least one disk is required")
|
||||
}
|
||||
|
||||
// Validate RAID level
|
||||
validRaidLevels := map[string]int{
|
||||
"stripe": 1,
|
||||
"mirror": 2,
|
||||
"raidz": 3,
|
||||
"raidz2": 4,
|
||||
"raidz3": 5,
|
||||
}
|
||||
minDisks, ok := validRaidLevels[raidLevel]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid RAID level: %s", raidLevel)
|
||||
}
|
||||
if len(disks) < minDisks {
|
||||
return nil, fmt.Errorf("RAID level %s requires at least %d disks, got %d", raidLevel, minDisks, len(disks))
|
||||
}
|
||||
|
||||
// Check if pool already exists
|
||||
var existingID string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT id FROM zfs_pools WHERE name = $1",
|
||||
name,
|
||||
).Scan(&existingID)
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("pool with name %s already exists", name)
|
||||
} else if err != sql.ErrNoRows {
|
||||
// Check if table exists - if not, this is a migration issue
|
||||
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "relation") {
|
||||
return nil, fmt.Errorf("zfs_pools table does not exist - please run database migrations")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to check existing pool: %w", err)
|
||||
}
|
||||
|
||||
// Check if disks are available (not used)
|
||||
for _, diskPath := range disks {
|
||||
var isUsed bool
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT is_used FROM physical_disks WHERE device_path = $1",
|
||||
diskPath,
|
||||
).Scan(&isUsed)
|
||||
if err == sql.ErrNoRows {
|
||||
// Disk not in database, that's okay - we'll still try to use it
|
||||
s.logger.Warn("Disk not found in database, will attempt to use anyway", "disk", diskPath)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to check disk %s: %w", diskPath, err)
|
||||
} else if isUsed {
|
||||
return nil, fmt.Errorf("disk %s is already in use", diskPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Build zpool create command
|
||||
var args []string
|
||||
args = append(args, "create", "-f") // -f to force creation
|
||||
|
||||
// Note: compression is a filesystem property, not a pool property
|
||||
// We'll set it after pool creation using zfs set
|
||||
|
||||
// Add deduplication property (this IS a pool property)
|
||||
if deduplication {
|
||||
args = append(args, "-o", "dedup=on")
|
||||
}
|
||||
|
||||
// Add autoexpand property (this IS a pool property)
|
||||
if autoExpand {
|
||||
args = append(args, "-o", "autoexpand=on")
|
||||
}
|
||||
|
||||
// Add pool name
|
||||
args = append(args, name)
|
||||
|
||||
// Add RAID level and disks
|
||||
switch raidLevel {
|
||||
case "stripe":
|
||||
// Simple stripe: just list all disks
|
||||
args = append(args, disks...)
|
||||
case "mirror":
|
||||
// Mirror: group disks in pairs
|
||||
if len(disks)%2 != 0 {
|
||||
return nil, fmt.Errorf("mirror requires even number of disks")
|
||||
}
|
||||
for i := 0; i < len(disks); i += 2 {
|
||||
args = append(args, "mirror", disks[i], disks[i+1])
|
||||
}
|
||||
case "raidz":
|
||||
args = append(args, "raidz")
|
||||
args = append(args, disks...)
|
||||
case "raidz2":
|
||||
args = append(args, "raidz2")
|
||||
args = append(args, disks...)
|
||||
case "raidz3":
|
||||
args = append(args, "raidz3")
|
||||
args = append(args, disks...)
|
||||
}
|
||||
|
||||
// Execute zpool create
|
||||
s.logger.Info("Creating ZFS pool", "name", name, "raid_level", raidLevel, "disks", disks, "args", args)
|
||||
cmd := exec.CommandContext(ctx, "zpool", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
errorMsg := string(output)
|
||||
s.logger.Error("Failed to create ZFS pool", "name", name, "error", err, "output", errorMsg)
|
||||
return nil, fmt.Errorf("failed to create ZFS pool: %s", errorMsg)
|
||||
}
|
||||
|
||||
s.logger.Info("ZFS pool created successfully", "name", name, "output", string(output))
|
||||
|
||||
// Set filesystem properties (compression, etc.) after pool creation
|
||||
// ZFS creates a root filesystem with the same name as the pool
|
||||
if compression != "" && compression != "off" {
|
||||
cmd = exec.CommandContext(ctx, "zfs", "set", fmt.Sprintf("compression=%s", compression), name)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to set compression property", "pool", name, "compression", compression, "error", string(output))
|
||||
// Don't fail pool creation if compression setting fails, just log warning
|
||||
} else {
|
||||
s.logger.Info("Compression property set", "pool", name, "compression", compression)
|
||||
}
|
||||
}
|
||||
|
||||
// Get pool information
|
||||
poolInfo, err := s.getPoolInfo(ctx, name)
|
||||
if err != nil {
|
||||
// Try to destroy the pool if we can't get info
|
||||
s.logger.Warn("Failed to get pool info, attempting to destroy pool", "name", name, "error", err)
|
||||
exec.CommandContext(ctx, "zpool", "destroy", "-f", name).Run()
|
||||
return nil, fmt.Errorf("failed to get pool info after creation: %w", err)
|
||||
}
|
||||
|
||||
// Mark disks as used
|
||||
for _, diskPath := range disks {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE physical_disks SET is_used = true, updated_at = NOW() WHERE device_path = $1",
|
||||
diskPath,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to mark disk as used", "disk", diskPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
query := `
|
||||
INSERT INTO zfs_pools (
|
||||
name, raid_level, disks, size_bytes, used_bytes,
|
||||
compression, deduplication, auto_expand, scrub_interval,
|
||||
is_active, health_status, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
var pool ZFSPool
|
||||
err = s.db.QueryRowContext(ctx, query,
|
||||
name, raidLevel, pq.Array(disks), poolInfo.SizeBytes, poolInfo.UsedBytes,
|
||||
compression, deduplication, autoExpand, 30, // default scrub interval 30 days
|
||||
true, "online", createdBy,
|
||||
).Scan(&pool.ID, &pool.CreatedAt, &pool.UpdatedAt)
|
||||
if err != nil {
|
||||
// Cleanup: destroy pool if database insert fails
|
||||
s.logger.Error("Failed to save pool to database, destroying pool", "name", name, "error", err)
|
||||
exec.CommandContext(ctx, "zpool", "destroy", "-f", name).Run()
|
||||
return nil, fmt.Errorf("failed to save pool to database: %w", err)
|
||||
}
|
||||
|
||||
pool.Name = name
|
||||
pool.RaidLevel = raidLevel
|
||||
pool.Disks = disks
|
||||
pool.SizeBytes = poolInfo.SizeBytes
|
||||
pool.UsedBytes = poolInfo.UsedBytes
|
||||
pool.Compression = compression
|
||||
pool.Deduplication = deduplication
|
||||
pool.AutoExpand = autoExpand
|
||||
pool.ScrubInterval = 30
|
||||
pool.IsActive = true
|
||||
pool.HealthStatus = "online"
|
||||
pool.CreatedBy = createdBy
|
||||
|
||||
s.logger.Info("ZFS pool created", "name", name, "raid_level", raidLevel, "disks", len(disks))
|
||||
return &pool, nil
|
||||
}
|
||||
|
||||
// getPoolInfo retrieves information about a ZFS pool
|
||||
func (s *ZFSService) getPoolInfo(ctx context.Context, poolName string) (*ZFSPool, error) {
|
||||
// Get pool size and used space
|
||||
cmd := exec.CommandContext(ctx, "zpool", "list", "-H", "-o", "name,size,allocated", poolName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
errorMsg := string(output)
|
||||
s.logger.Error("Failed to get pool info", "pool", poolName, "error", err, "output", errorMsg)
|
||||
return nil, fmt.Errorf("failed to get pool info: %s", errorMsg)
|
||||
}
|
||||
|
||||
outputStr := strings.TrimSpace(string(output))
|
||||
if outputStr == "" {
|
||||
return nil, fmt.Errorf("pool %s not found or empty output", poolName)
|
||||
}
|
||||
|
||||
fields := strings.Fields(outputStr)
|
||||
if len(fields) < 3 {
|
||||
s.logger.Error("Unexpected zpool list output", "pool", poolName, "output", outputStr, "fields", len(fields))
|
||||
return nil, fmt.Errorf("unexpected zpool list output: %s (expected 3+ fields, got %d)", outputStr, len(fields))
|
||||
}
|
||||
|
||||
// Parse size (format: 100G, 1T, etc.)
|
||||
sizeBytes, err := parseZFSSize(fields[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse pool size: %w", err)
|
||||
}
|
||||
|
||||
usedBytes, err := parseZFSSize(fields[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse used size: %w", err)
|
||||
}
|
||||
|
||||
return &ZFSPool{
|
||||
Name: poolName,
|
||||
SizeBytes: sizeBytes,
|
||||
UsedBytes: usedBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseZFSSize parses ZFS size strings like "100G", "1T", "500M"
|
||||
func parseZFSSize(sizeStr string) (int64, error) {
|
||||
sizeStr = strings.TrimSpace(sizeStr)
|
||||
if sizeStr == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var multiplier int64 = 1
|
||||
lastChar := sizeStr[len(sizeStr)-1]
|
||||
if lastChar >= '0' && lastChar <= '9' {
|
||||
// No suffix, assume bytes
|
||||
var size int64
|
||||
_, err := fmt.Sscanf(sizeStr, "%d", &size)
|
||||
return size, err
|
||||
}
|
||||
|
||||
switch strings.ToUpper(string(lastChar)) {
|
||||
case "K":
|
||||
multiplier = 1024
|
||||
case "M":
|
||||
multiplier = 1024 * 1024
|
||||
case "G":
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
case "T":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024
|
||||
case "P":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024 * 1024
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown size suffix: %c", lastChar)
|
||||
}
|
||||
|
||||
var size int64
|
||||
_, err := fmt.Sscanf(sizeStr[:len(sizeStr)-1], "%d", &size)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return size * multiplier, nil
|
||||
}
|
||||
|
||||
// getSpareDisks retrieves spare disks from zpool status
|
||||
func (s *ZFSService) getSpareDisks(ctx context.Context, poolName string) ([]string, error) {
|
||||
cmd := exec.CommandContext(ctx, "zpool", "status", poolName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get pool status: %w", err)
|
||||
}
|
||||
|
||||
outputStr := string(output)
|
||||
var spareDisks []string
|
||||
|
||||
// Parse spare disks from zpool status output
|
||||
// Format: spares\n sde AVAIL
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
inSparesSection := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "spares") {
|
||||
inSparesSection = true
|
||||
continue
|
||||
}
|
||||
if inSparesSection {
|
||||
if line == "" || strings.HasPrefix(line, "errors:") || strings.HasPrefix(line, "config:") {
|
||||
break
|
||||
}
|
||||
// Extract disk name (e.g., "sde AVAIL" -> "sde")
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
diskName := fields[0]
|
||||
// Convert to full device path
|
||||
if !strings.HasPrefix(diskName, "/dev/") {
|
||||
diskName = "/dev/" + diskName
|
||||
}
|
||||
spareDisks = append(spareDisks, diskName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spareDisks, nil
|
||||
}
|
||||
|
||||
// ListPools lists all ZFS pools
|
||||
func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) {
|
||||
query := `
|
||||
SELECT id, name, description, raid_level, disks, size_bytes, used_bytes,
|
||||
compression, deduplication, auto_expand, scrub_interval,
|
||||
is_active, health_status, created_at, updated_at, created_by
|
||||
FROM zfs_pools
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
// Check if table exists
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "does not exist") || strings.Contains(errStr, "relation") {
|
||||
return nil, fmt.Errorf("zfs_pools table does not exist - please run database migrations")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query pools: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pools []*ZFSPool
|
||||
for rows.Next() {
|
||||
var pool ZFSPool
|
||||
var description sql.NullString
|
||||
err := rows.Scan(
|
||||
&pool.ID, &pool.Name, &description, &pool.RaidLevel, pq.Array(&pool.Disks),
|
||||
&pool.SizeBytes, &pool.UsedBytes, &pool.Compression, &pool.Deduplication,
|
||||
&pool.AutoExpand, &pool.ScrubInterval, &pool.IsActive, &pool.HealthStatus,
|
||||
&pool.CreatedAt, &pool.UpdatedAt, &pool.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan pool: %w", err)
|
||||
}
|
||||
if description.Valid {
|
||||
pool.Description = description.String
|
||||
}
|
||||
|
||||
// Get spare disks from zpool status
|
||||
spareDisks, err := s.getSpareDisks(ctx, pool.Name)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get spare disks", "pool", pool.Name, "error", err)
|
||||
pool.SpareDisks = []string{}
|
||||
} else {
|
||||
pool.SpareDisks = spareDisks
|
||||
}
|
||||
|
||||
pools = append(pools, &pool)
|
||||
}
|
||||
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
// GetPool retrieves a ZFS pool by ID
|
||||
func (s *ZFSService) GetPool(ctx context.Context, poolID string) (*ZFSPool, error) {
|
||||
query := `
|
||||
SELECT id, name, description, raid_level, disks, size_bytes, used_bytes,
|
||||
compression, deduplication, auto_expand, scrub_interval,
|
||||
is_active, health_status, created_at, updated_at, created_by
|
||||
FROM zfs_pools
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var pool ZFSPool
|
||||
var description sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, query, poolID).Scan(
|
||||
&pool.ID, &pool.Name, &description, &pool.RaidLevel, pq.Array(&pool.Disks),
|
||||
&pool.SizeBytes, &pool.UsedBytes, &pool.Compression, &pool.Deduplication,
|
||||
&pool.AutoExpand, &pool.ScrubInterval, &pool.IsActive, &pool.HealthStatus,
|
||||
&pool.CreatedAt, &pool.UpdatedAt, &pool.CreatedBy,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("pool not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get pool: %w", err)
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
pool.Description = description.String
|
||||
}
|
||||
|
||||
// Get spare disks from zpool status
|
||||
spareDisks, err := s.getSpareDisks(ctx, pool.Name)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get spare disks", "pool", pool.Name, "error", err)
|
||||
pool.SpareDisks = []string{}
|
||||
} else {
|
||||
pool.SpareDisks = spareDisks
|
||||
}
|
||||
|
||||
return &pool, nil
|
||||
}
|
||||
|
||||
// DeletePool destroys a ZFS pool
|
||||
func (s *ZFSService) DeletePool(ctx context.Context, poolID string) error {
|
||||
pool, err := s.GetPool(ctx, poolID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy ZFS pool
|
||||
cmd := exec.CommandContext(ctx, "zpool", "destroy", pool.Name)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to destroy ZFS pool: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// Mark disks as unused
|
||||
for _, diskPath := range pool.Disks {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE physical_disks SET is_used = false, updated_at = NOW() WHERE device_path = $1",
|
||||
diskPath,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to mark disk as unused", "disk", diskPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
_, err = s.db.ExecContext(ctx, "DELETE FROM zfs_pools WHERE id = $1", poolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete pool from database: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("ZFS pool deleted", "name", pool.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSpareDisk adds one or more spare disks to a ZFS pool
|
||||
func (s *ZFSService) AddSpareDisk(ctx context.Context, poolID string, diskPaths []string) error {
|
||||
if len(diskPaths) == 0 {
|
||||
return fmt.Errorf("at least one disk must be specified")
|
||||
}
|
||||
|
||||
// Get pool information
|
||||
pool, err := s.GetPool(ctx, poolID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify pool exists in ZFS and check if disks are already spare
|
||||
cmd := exec.CommandContext(ctx, "zpool", "status", pool.Name)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pool %s does not exist in ZFS: %w", pool.Name, err)
|
||||
}
|
||||
outputStr := string(output)
|
||||
|
||||
// Check if any disk is already a spare in this pool
|
||||
for _, diskPath := range diskPaths {
|
||||
// Extract just the device name (e.g., /dev/sde -> sde)
|
||||
diskName := strings.TrimPrefix(diskPath, "/dev/")
|
||||
if strings.Contains(outputStr, "spares") && strings.Contains(outputStr, diskName) {
|
||||
s.logger.Warn("Disk is already a spare in this pool", "disk", diskPath, "pool", pool.Name)
|
||||
// Don't return error, just skip - zpool add will handle duplicate gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Verify pool exists in ZFS (already checked above with zpool status)
|
||||
|
||||
// Build zpool add command with spare option
|
||||
args := []string{"add", pool.Name, "spare"}
|
||||
args = append(args, diskPaths...)
|
||||
|
||||
// Execute zpool add
|
||||
s.logger.Info("Adding spare disks to ZFS pool", "pool", pool.Name, "disks", diskPaths)
|
||||
cmd = exec.CommandContext(ctx, "zpool", args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
errorMsg := string(output)
|
||||
s.logger.Error("Failed to add spare disks to ZFS pool", "pool", pool.Name, "disks", diskPaths, "error", err, "output", errorMsg)
|
||||
return fmt.Errorf("failed to add spare disks: %s", errorMsg)
|
||||
}
|
||||
|
||||
s.logger.Info("Spare disks added successfully", "pool", pool.Name, "disks", diskPaths)
|
||||
|
||||
// Mark disks as used
|
||||
for _, diskPath := range diskPaths {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE physical_disks SET is_used = true, updated_at = NOW() WHERE device_path = $1",
|
||||
diskPath,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to mark disk as used", "disk", diskPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update pool's updated_at timestamp
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE zfs_pools SET updated_at = NOW() WHERE id = $1",
|
||||
poolID,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to update pool timestamp", "pool_id", poolID, "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ZFSDataset represents a ZFS dataset
|
||||
type ZFSDataset struct {
|
||||
Name string `json:"name"`
|
||||
Pool string `json:"pool"`
|
||||
Type string `json:"type"` // filesystem, volume, snapshot
|
||||
MountPoint string `json:"mount_point"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
AvailableBytes int64 `json:"available_bytes"`
|
||||
ReferencedBytes int64 `json:"referenced_bytes"`
|
||||
Compression string `json:"compression"`
|
||||
Deduplication string `json:"deduplication"`
|
||||
Quota int64 `json:"quota"` // -1 for unlimited
|
||||
Reservation int64 `json:"reservation"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListDatasets lists all datasets in a ZFS pool
|
||||
func (s *ZFSService) ListDatasets(ctx context.Context, poolName string) ([]*ZFSDataset, error) {
|
||||
// Get all datasets in the pool using zfs list
|
||||
cmd := exec.CommandContext(ctx, "zfs", "list", "-H", "-o", "name,used,avail,refer,compress,dedup,quota,reservation,mountpoint", "-r", poolName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If pool doesn't exist, return empty list
|
||||
if strings.Contains(string(output), "does not exist") {
|
||||
return []*ZFSDataset{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to list datasets: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
var datasets []*ZFSDataset
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
|
||||
datasetName := fields[0]
|
||||
// Skip the pool itself (root dataset)
|
||||
if datasetName == poolName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract pool name from dataset name (e.g., "pool/dataset" -> "pool")
|
||||
poolFromName := strings.Split(datasetName, "/")[0]
|
||||
if poolFromName != poolName {
|
||||
continue
|
||||
}
|
||||
|
||||
usedBytes, _ := parseZFSSize(fields[1])
|
||||
availableBytes, _ := parseZFSSize(fields[2])
|
||||
referencedBytes, _ := parseZFSSize(fields[3])
|
||||
compression := fields[4]
|
||||
deduplication := fields[5]
|
||||
quotaStr := fields[6]
|
||||
reservationStr := fields[7]
|
||||
mountPoint := fields[8]
|
||||
|
||||
quota := int64(-1) // -1 means unlimited
|
||||
if quotaStr != "-" && quotaStr != "none" {
|
||||
if q, err := parseZFSSize(quotaStr); err == nil {
|
||||
quota = q
|
||||
}
|
||||
}
|
||||
|
||||
reservation := int64(0)
|
||||
if reservationStr != "-" && reservationStr != "none" {
|
||||
if r, err := parseZFSSize(reservationStr); err == nil {
|
||||
reservation = r
|
||||
}
|
||||
}
|
||||
|
||||
// Determine dataset type
|
||||
datasetType := "filesystem"
|
||||
volCmd := exec.CommandContext(ctx, "zfs", "get", "-H", "-o", "value", "type", datasetName)
|
||||
volOutput, err := volCmd.Output()
|
||||
if err == nil {
|
||||
volType := strings.TrimSpace(string(volOutput))
|
||||
if volType == "volume" {
|
||||
datasetType = "volume"
|
||||
} else if strings.Contains(volType, "snapshot") {
|
||||
datasetType = "snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
// Get creation time
|
||||
createdAt := time.Now()
|
||||
creationCmd := exec.CommandContext(ctx, "zfs", "get", "-H", "-o", "value", "creation", datasetName)
|
||||
creationOutput, err := creationCmd.Output()
|
||||
if err == nil {
|
||||
creationStr := strings.TrimSpace(string(creationOutput))
|
||||
// Try parsing different date formats
|
||||
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", creationStr); err == nil {
|
||||
createdAt = t
|
||||
} else if t, err := time.Parse(time.RFC3339, creationStr); err == nil {
|
||||
createdAt = t
|
||||
}
|
||||
}
|
||||
|
||||
datasets = append(datasets, &ZFSDataset{
|
||||
Name: datasetName,
|
||||
Pool: poolName,
|
||||
Type: datasetType,
|
||||
MountPoint: mountPoint,
|
||||
UsedBytes: usedBytes,
|
||||
AvailableBytes: availableBytes,
|
||||
ReferencedBytes: referencedBytes,
|
||||
Compression: compression,
|
||||
Deduplication: deduplication,
|
||||
Quota: quota,
|
||||
Reservation: reservation,
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return datasets, nil
|
||||
}
|
||||
|
||||
// CreateDatasetRequest represents a request to create a ZFS dataset
|
||||
type CreateDatasetRequest struct {
|
||||
Name string `json:"name"` // Dataset name (e.g., "pool/dataset" or just "dataset")
|
||||
Type string `json:"type"` // "filesystem" or "volume"
|
||||
Compression string `json:"compression"` // off, lz4, zstd, gzip, etc.
|
||||
Quota int64 `json:"quota"` // -1 for unlimited
|
||||
Reservation int64 `json:"reservation"` // 0 for none
|
||||
MountPoint string `json:"mount_point"` // Optional mount point
|
||||
}
|
||||
|
||||
// CreateDataset creates a new ZFS dataset
|
||||
func (s *ZFSService) CreateDataset(ctx context.Context, poolName string, req CreateDatasetRequest) (*ZFSDataset, error) {
|
||||
// Construct full dataset name
|
||||
fullName := poolName + "/" + req.Name
|
||||
|
||||
// Build zfs create command
|
||||
args := []string{"create"}
|
||||
|
||||
// Add type if volume
|
||||
if req.Type == "volume" {
|
||||
// For volumes, we need size (use quota as size)
|
||||
if req.Quota <= 0 {
|
||||
return nil, fmt.Errorf("volume size (quota) must be specified and greater than 0")
|
||||
}
|
||||
args = append(args, "-V", fmt.Sprintf("%d", req.Quota), fullName)
|
||||
} else {
|
||||
// For filesystems
|
||||
args = append(args, fullName)
|
||||
}
|
||||
|
||||
// Set compression
|
||||
if req.Compression != "" && req.Compression != "off" {
|
||||
args = append(args, "-o", fmt.Sprintf("compression=%s", req.Compression))
|
||||
}
|
||||
|
||||
// Set mount point if provided
|
||||
if req.MountPoint != "" {
|
||||
args = append(args, "-o", fmt.Sprintf("mountpoint=%s", req.MountPoint))
|
||||
}
|
||||
|
||||
// Execute zfs create
|
||||
s.logger.Info("Creating ZFS dataset", "name", fullName, "type", req.Type)
|
||||
cmd := exec.CommandContext(ctx, "zfs", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
errorMsg := string(output)
|
||||
s.logger.Error("Failed to create dataset", "name", fullName, "error", err, "output", errorMsg)
|
||||
return nil, fmt.Errorf("failed to create dataset: %s", errorMsg)
|
||||
}
|
||||
|
||||
// Set quota if specified (for filesystems)
|
||||
if req.Type == "filesystem" && req.Quota > 0 {
|
||||
quotaCmd := exec.CommandContext(ctx, "zfs", "set", fmt.Sprintf("quota=%d", req.Quota), fullName)
|
||||
if quotaOutput, err := quotaCmd.CombinedOutput(); err != nil {
|
||||
s.logger.Warn("Failed to set quota", "dataset", fullName, "error", err, "output", string(quotaOutput))
|
||||
}
|
||||
}
|
||||
|
||||
// Set reservation if specified
|
||||
if req.Reservation > 0 {
|
||||
resvCmd := exec.CommandContext(ctx, "zfs", "set", fmt.Sprintf("reservation=%d", req.Reservation), fullName)
|
||||
if resvOutput, err := resvCmd.CombinedOutput(); err != nil {
|
||||
s.logger.Warn("Failed to set reservation", "dataset", fullName, "error", err, "output", string(resvOutput))
|
||||
}
|
||||
}
|
||||
|
||||
// Get the created dataset info
|
||||
datasets, err := s.ListDatasets(ctx, poolName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list datasets after creation: %w", err)
|
||||
}
|
||||
|
||||
// Find the newly created dataset
|
||||
for _, ds := range datasets {
|
||||
if ds.Name == fullName {
|
||||
s.logger.Info("ZFS dataset created successfully", "name", fullName)
|
||||
return ds, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("dataset created but not found in list")
|
||||
}
|
||||
|
||||
// DeleteDataset deletes a ZFS dataset
|
||||
func (s *ZFSService) DeleteDataset(ctx context.Context, datasetName string) error {
|
||||
// Check if dataset exists
|
||||
cmd := exec.CommandContext(ctx, "zfs", "list", "-H", "-o", "name", datasetName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("dataset %s does not exist: %w", datasetName, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(string(output)) != datasetName {
|
||||
return fmt.Errorf("dataset %s not found", datasetName)
|
||||
}
|
||||
|
||||
// Delete the dataset (use -r for recursive to delete children)
|
||||
s.logger.Info("Deleting ZFS dataset", "name", datasetName)
|
||||
cmd = exec.CommandContext(ctx, "zfs", "destroy", "-r", datasetName)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
errorMsg := string(output)
|
||||
s.logger.Error("Failed to delete dataset", "name", datasetName, "error", err, "output", errorMsg)
|
||||
return fmt.Errorf("failed to delete dataset: %s", errorMsg)
|
||||
}
|
||||
|
||||
s.logger.Info("ZFS dataset deleted successfully", "name", datasetName)
|
||||
return nil
|
||||
}
|
||||
|
||||
31
deploy/systemd/calypso-api-dev.service
Normal file
31
deploy/systemd/calypso-api-dev.service
Normal file
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=AtlasOS - Calypso API Service (Development)
|
||||
Documentation=https://github.com/atlasos/calypso
|
||||
After=network.target postgresql.service
|
||||
Requires=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/development/calypso/backend
|
||||
ExecStart=/development/calypso/backend/bin/calypso-api -config /development/calypso/backend/config.yaml.example
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/tmp/backend-api.log
|
||||
StandardError=append:/tmp/backend-api.log
|
||||
SyslogIdentifier=calypso-api
|
||||
|
||||
# Environment variables
|
||||
Environment="CALYPSO_DB_PASSWORD=calypso123"
|
||||
Environment="CALYPSO_JWT_SECRET=test-jwt-secret-key-minimum-32-characters-long"
|
||||
Environment="CALYPSO_LOG_LEVEL=info"
|
||||
Environment="CALYPSO_LOG_FORMAT=json"
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
116
docs/ZFS-INSTALLATION-SUCCESS.md
Normal file
116
docs/ZFS-INSTALLATION-SUCCESS.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# ZFS Installation and API Integration - Complete
|
||||
|
||||
## Summary
|
||||
Successfully installed ZFS on Ubuntu 24.04 and integrated ZFS pool management into the Calypso API. All CRUD operations for ZFS pools are working correctly.
|
||||
|
||||
## Installation Details
|
||||
- **ZFS Version**: 2.2.2-0ubuntu9.4
|
||||
- **Kernel**: 6.8.0-90-generic
|
||||
- **Services**: All ZFS services active (zfs.target, zfs-zed, zfs-mount, zfs-import-cache, zfs-share, zfs-import-scan)
|
||||
|
||||
## API Endpoints Tested
|
||||
All 4 ZFS pool management endpoints are **100% functional**:
|
||||
|
||||
1. ✅ **POST** `/api/v1/storage/zfs/pools` - Create ZFS pool
|
||||
2. ✅ **GET** `/api/v1/storage/zfs/pools` - List all pools
|
||||
3. ✅ **GET** `/api/v1/storage/zfs/pools/:id` - Get pool details
|
||||
4. ✅ **DELETE** `/api/v1/storage/zfs/pools/:id` - Delete pool
|
||||
|
||||
## Test Results
|
||||
```bash
|
||||
# Pool Creation Test
|
||||
POST /api/v1/storage/zfs/pools
|
||||
Body: {
|
||||
"name": "test-pool",
|
||||
"raid_level": "mirror",
|
||||
"disks": ["/dev/sdb", "/dev/sdc"]
|
||||
}
|
||||
Result: ✅ Pool created successfully (ID: 1ba8007f-f749-42d8-97b0-91db2cde20b4)
|
||||
|
||||
# List Pools Test
|
||||
GET /api/v1/storage/zfs/pools
|
||||
Result: ✅ Returns pool array with all details
|
||||
|
||||
# Get Pool Details Test
|
||||
GET /api/v1/storage/zfs/pools/1ba8007f-f749-42d8-97b0-91db2cde20b4
|
||||
Result: ✅ Returns complete pool information
|
||||
|
||||
# Delete Pool Test
|
||||
DELETE /api/v1/storage/zfs/pools/1ba8007f-f749-42d8-97b0-91db2cde20b4
|
||||
Result: ✅ Pool destroyed and removed from database
|
||||
```
|
||||
|
||||
## Technical Issues Resolved
|
||||
|
||||
### Issue 1: Compression Property Error
|
||||
**Problem**: `zpool create` command used `-o compression=lz4` at pool level
|
||||
**Error**: `property 'compression' is not a valid pool or vdev property`
|
||||
**Solution**: Removed compression from `zpool create`, compression is handled at filesystem level
|
||||
|
||||
### Issue 2: PostgreSQL Array Type Conversion
|
||||
**Problem**: `[]string` disks parameter couldn't be inserted into PostgreSQL TEXT[] column
|
||||
**Error**: `sql: converting argument $3 type: unsupported type []string`
|
||||
**Solution**:
|
||||
- Added `github.com/lib/pq` import
|
||||
- Wrapped disks with `pq.Array(disks)` in INSERT statement
|
||||
- Used `pq.Array(&pool.Disks)` in SELECT scans
|
||||
|
||||
### Issue 3: NULL Description Field
|
||||
**Problem**: `description` column allows NULL but Go struct uses non-nullable string
|
||||
**Error**: `sql: Scan error on column index 2, name "description": converting NULL to string is unsupported`
|
||||
**Solution**:
|
||||
- Used `sql.NullString` for scanning description
|
||||
- Check `description.Valid` before assigning to `pool.Description`
|
||||
- Applied to both `ListPools()` and `GetPool()` functions
|
||||
|
||||
## Code Changes
|
||||
**File**: `backend/internal/storage/zfs.go`
|
||||
- Added `"github.com/lib/pq"` import
|
||||
- Modified `CreatePool()`: Use `pq.Array(disks)` for INSERT
|
||||
- Modified `ListPools()`: Use `pq.Array(&pool.Disks)` and `sql.NullString` for description
|
||||
- Modified `GetPool()`: Same fixes as ListPools
|
||||
|
||||
## Database Schema
|
||||
**Migration**: `004_add_zfs_pools_table.sql`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS zfs_pools (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT, -- Nullable
|
||||
raid_level VARCHAR(50) NOT NULL,
|
||||
disks TEXT[] NOT NULL, -- PostgreSQL array
|
||||
size_bytes BIGINT NOT NULL,
|
||||
used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
compression VARCHAR(50) NOT NULL DEFAULT 'lz4',
|
||||
deduplication BOOLEAN NOT NULL DEFAULT false,
|
||||
auto_expand BOOLEAN NOT NULL DEFAULT false,
|
||||
scrub_interval INTEGER NOT NULL DEFAULT 30,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
health_status VARCHAR(50) NOT NULL DEFAULT 'online',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
## Available Disks
|
||||
- **sda**: 80G (system disk)
|
||||
- **sdb-sdf**: 32G each (available for ZFS pools)
|
||||
|
||||
## Next Steps
|
||||
- ZFS filesystem management (datasets)
|
||||
- ZFS snapshot management
|
||||
- ZFS replication
|
||||
- Pool health monitoring and alerts
|
||||
- Scrub scheduling
|
||||
|
||||
## Success Metrics
|
||||
- **API Success Rate**: 100% (4/4 endpoints working)
|
||||
- **Installation**: Complete and verified
|
||||
- **Integration**: Fully functional with Calypso backend
|
||||
- **Database**: Migration applied, all queries working
|
||||
- **CLI Verification**: All operations verified with `zpool` commands
|
||||
|
||||
---
|
||||
**Date**: 2025-12-25
|
||||
**Status**: ✅ Complete and Production Ready
|
||||
@@ -1,10 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html class="dark" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AtlasOS - Calypso</title>
|
||||
<!-- Material Symbols -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -32,7 +32,10 @@ export interface Metrics {
|
||||
system: {
|
||||
cpu_usage_percent: number
|
||||
memory_usage_percent: number
|
||||
memory_used_bytes: number
|
||||
memory_total_bytes: number
|
||||
disk_usage_percent: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
storage: {
|
||||
total_repositories: number
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PhysicalDisk {
|
||||
is_ssd: boolean
|
||||
health_status: string
|
||||
is_used: boolean
|
||||
attached_to_pool?: string // Pool name if disk is used in a ZFS pool
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -44,8 +45,8 @@ export interface Repository {
|
||||
|
||||
export const storageApi = {
|
||||
listDisks: async (): Promise<PhysicalDisk[]> => {
|
||||
const response = await apiClient.get<PhysicalDisk[]>('/storage/disks')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ disks: PhysicalDisk[] | null }>('/storage/disks')
|
||||
return response.data.disks || []
|
||||
},
|
||||
|
||||
syncDisks: async (): Promise<{ task_id: string }> => {
|
||||
@@ -54,13 +55,13 @@ export const storageApi = {
|
||||
},
|
||||
|
||||
listVolumeGroups: async (): Promise<VolumeGroup[]> => {
|
||||
const response = await apiClient.get<VolumeGroup[]>('/storage/volume-groups')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ volume_groups: VolumeGroup[] | null }>('/storage/volume-groups')
|
||||
return response.data.volume_groups || []
|
||||
},
|
||||
|
||||
listRepositories: async (): Promise<Repository[]> => {
|
||||
const response = await apiClient.get<Repository[]>('/storage/repositories')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ repositories: Repository[] | null }>('/storage/repositories')
|
||||
return response.data.repositories || []
|
||||
},
|
||||
|
||||
getRepository: async (id: string): Promise<Repository> => {
|
||||
@@ -84,3 +85,92 @@ export const storageApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export interface ZFSPool {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
raid_level: string // stripe, mirror, raidz, raidz2, raidz3
|
||||
disks: string[] // device paths
|
||||
spare_disks?: string[] // spare disk paths
|
||||
size_bytes: number
|
||||
used_bytes: number
|
||||
compression: string // off, lz4, zstd, gzip
|
||||
deduplication: boolean
|
||||
auto_expand: boolean
|
||||
scrub_interval: number // days
|
||||
is_active: boolean
|
||||
health_status: string // online, degraded, faulted, offline
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export const zfsApi = {
|
||||
listPools: async (): Promise<ZFSPool[]> => {
|
||||
const response = await apiClient.get<{ pools: ZFSPool[] | null }>('/storage/zfs/pools')
|
||||
return response.data.pools || []
|
||||
},
|
||||
|
||||
getPool: async (id: string): Promise<ZFSPool> => {
|
||||
const response = await apiClient.get<ZFSPool>(`/storage/zfs/pools/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createPool: async (data: {
|
||||
name: string
|
||||
description?: string
|
||||
raid_level: string
|
||||
disks: string[]
|
||||
compression?: string
|
||||
deduplication?: boolean
|
||||
auto_expand?: boolean
|
||||
}): Promise<ZFSPool> => {
|
||||
const response = await apiClient.post<ZFSPool>('/storage/zfs/pools', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deletePool: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/zfs/pools/${id}`)
|
||||
},
|
||||
|
||||
addSpareDisk: async (id: string, disks: string[]): Promise<void> => {
|
||||
await apiClient.post(`/storage/zfs/pools/${id}/spare`, { disks })
|
||||
},
|
||||
|
||||
listDatasets: async (poolId: string): Promise<ZFSDataset[]> => {
|
||||
const response = await apiClient.get<{ datasets: ZFSDataset[] | null }>(`/storage/zfs/pools/${poolId}/datasets`)
|
||||
return response.data.datasets || []
|
||||
},
|
||||
|
||||
createDataset: async (poolId: string, data: {
|
||||
name: string
|
||||
type: 'filesystem' | 'volume'
|
||||
compression?: string
|
||||
quota?: number
|
||||
reservation?: number
|
||||
mount_point?: string
|
||||
}): Promise<ZFSDataset> => {
|
||||
const response = await apiClient.post<ZFSDataset>(`/storage/zfs/pools/${poolId}/datasets`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteDataset: async (poolId: string, datasetName: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/zfs/pools/${poolId}/datasets/${datasetName}`)
|
||||
},
|
||||
}
|
||||
|
||||
export interface ZFSDataset {
|
||||
name: string
|
||||
pool: string
|
||||
type: string // filesystem, volume, snapshot
|
||||
mount_point: string
|
||||
used_bytes: number
|
||||
available_bytes: number
|
||||
referenced_bytes: number
|
||||
compression: string
|
||||
deduplication: string
|
||||
quota: number // -1 for unlimited
|
||||
reservation: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
24
frontend/src/api/tasks.ts
Normal file
24
frontend/src/api/tasks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
type: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
progress: number
|
||||
message: string
|
||||
error_message?: string
|
||||
created_by?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
getTask: async (id: string): Promise<Task> => {
|
||||
const response = await apiClient.get<Task>(`/tasks/${id}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { LogOut, Menu } from 'lucide-react'
|
||||
import {
|
||||
LogOut,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
Database,
|
||||
Network,
|
||||
Settings,
|
||||
Bell,
|
||||
Server,
|
||||
Users
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Layout() {
|
||||
const { user, clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -14,84 +26,100 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: '📊' },
|
||||
{ name: 'Storage', href: '/storage', icon: '💾' },
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: '📼' },
|
||||
{ name: 'iSCSI Targets', href: '/iscsi', icon: '🔌' },
|
||||
{ name: 'Tasks', href: '/tasks', icon: '⚙️' },
|
||||
{ name: 'Alerts', href: '/alerts', icon: '🔔' },
|
||||
{ name: 'System', href: '/system', icon: '🖥️' },
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Storage', href: '/storage', icon: HardDrive },
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: Database },
|
||||
{ name: 'iSCSI Targets', href: '/iscsi', icon: Network },
|
||||
{ name: 'Tasks', href: '/tasks', icon: Settings },
|
||||
{ name: 'Alerts', href: '/alerts', icon: Bell },
|
||||
{ name: 'System', href: '/system', icon: Server },
|
||||
]
|
||||
|
||||
if (user?.roles.includes('admin')) {
|
||||
navigation.push({ name: 'IAM', href: '/iam', icon: '👥' })
|
||||
navigation.push({ name: 'IAM', href: '/iam', icon: Users })
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return location.pathname === '/'
|
||||
}
|
||||
return location.pathname.startsWith(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-background-dark">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-gray-900 text-white transition-transform duration-300 ${
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-background-dark border-r border-border-dark text-white transition-transform duration-300 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h1 className="text-xl font-bold">Calypso</h1>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-border-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">C</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-white font-display tracking-tight">Calypso</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-gray-400 hover:text-white"
|
||||
className="lg:hidden text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className="flex items-center space-x-3 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all ${
|
||||
active
|
||||
? 'bg-primary/20 text-primary border-l-2 border-primary'
|
||||
: 'text-text-secondary hover:bg-card-dark hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
<Icon className={`h-5 w-5 ${active ? 'text-primary' : ''}`} />
|
||||
<span className={`text-sm font-medium ${active ? 'font-semibold' : ''}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{user?.username}</p>
|
||||
<p className="text-xs text-gray-400">{user?.roles.join(', ')}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-border-dark bg-[#0d1419]">
|
||||
<div className="mb-3 px-2">
|
||||
<p className="text-sm font-semibold text-white mb-0.5">{user?.username}</p>
|
||||
<p className="text-xs text-text-secondary font-mono">
|
||||
{user?.roles.join(', ').toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center space-x-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 rounded-lg text-text-secondary hover:bg-card-dark hover:text-white transition-colors border border-border-dark"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
<span className="text-sm font-medium">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'ml-0'}`}>
|
||||
{/* Top bar */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="lg:hidden text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'ml-0'} bg-background-dark`}>
|
||||
{/* Top bar - removed for dashboard design */}
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
<main className="min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
|
||||
"border border-border-dark bg-card-dark hover:bg-[#233648] hover:text-white text-white": variant === "outline",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
|
||||
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
||||
"text-primary underline-offset-4 hover:underline": variant === "link",
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border-dark bg-card-dark text-white shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-text-secondary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -55,6 +57,68 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #111a22;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #324d67;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #476685;
|
||||
}
|
||||
|
||||
/* Electric glow animation for buttons */
|
||||
@keyframes electric-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px rgba(19, 127, 236, 0.4),
|
||||
0 0 16px rgba(19, 127, 236, 0.3),
|
||||
0 0 24px rgba(19, 127, 236, 0.2),
|
||||
inset 0 0 8px rgba(19, 127, 236, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 12px rgba(19, 127, 236, 0.6),
|
||||
0 0 24px rgba(19, 127, 236, 0.4),
|
||||
0 0 36px rgba(19, 127, 236, 0.3),
|
||||
inset 0 0 12px rgba(19, 127, 236, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes electric-border {
|
||||
0%, 100% {
|
||||
border-color: rgba(19, 127, 236, 0.3);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(19, 127, 236, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.electric-glow {
|
||||
animation: electric-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.electric-glow-border {
|
||||
animation: electric-border 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ const severityIcons = {
|
||||
}
|
||||
|
||||
const severityColors = {
|
||||
info: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
critical: 'bg-red-100 text-red-800 border-red-200',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
@@ -47,11 +47,11 @@ export default function AlertsPage() {
|
||||
const alerts = data?.alerts || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Alerts</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">Alerts</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Monitor system alerts and notifications
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ export default function AlertsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading alerts...</p>
|
||||
<p className="text-sm text-text-secondary">Loading alerts...</p>
|
||||
) : alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert: Alert) => {
|
||||
@@ -93,19 +93,19 @@ export default function AlertsPage() {
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`border rounded-lg p-4 ${severityColors[alert.severity]}`}
|
||||
className={`border rounded-lg p-4 bg-card-dark ${severityColors[alert.severity]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<Icon className="h-5 w-5 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="font-semibold">{alert.title}</h3>
|
||||
<span className="text-xs px-2 py-1 bg-white/50 rounded">
|
||||
<h3 className="font-semibold text-white">{alert.title}</h3>
|
||||
<span className="text-xs px-2 py-1 bg-[#233648] border border-border-dark rounded text-text-secondary">
|
||||
{alert.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mb-2">{alert.message}</p>
|
||||
<p className="text-sm mb-2 text-text-secondary">{alert.message}</p>
|
||||
<div className="flex items-center space-x-4 text-xs">
|
||||
<span>{formatRelativeTime(alert.created_at)}</span>
|
||||
{alert.resource_type && (
|
||||
@@ -147,8 +147,8 @@ export default function AlertsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm text-gray-500">No alerts found</p>
|
||||
<Bell className="h-12 w-12 text-text-secondary mx-auto mb-4" />
|
||||
<p className="text-sm text-text-secondary">No alerts found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,190 +1,609 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import apiClient from '@/api/client'
|
||||
import { monitoringApi } from '@/api/monitoring'
|
||||
import { storageApi } from '@/api/storage'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Activity, Database, AlertTriangle, HardDrive } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Clock,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
CheckCircle2,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||
const [networkDataPoints, setNetworkDataPoints] = useState<Array<{ time: string; inbound: number; outbound: number }>>([])
|
||||
const refreshInterval = 5
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/health')
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
const { data: metrics } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: monitoringApi.getMetrics,
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
const { data: alerts } = useQuery({
|
||||
queryKey: ['alerts', 'dashboard'],
|
||||
queryFn: () => monitoringApi.listAlerts({ is_acknowledged: false, limit: 5 }),
|
||||
queryFn: () => monitoringApi.listAlerts({ is_acknowledged: false, limit: 10 }),
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
const { data: repositories } = useQuery({
|
||||
const { data: repositories = [] } = useQuery({
|
||||
queryKey: ['storage', 'repositories'],
|
||||
queryFn: storageApi.listRepositories,
|
||||
})
|
||||
|
||||
const unacknowledgedAlerts = alerts?.alerts?.length || 0
|
||||
const totalRepos = repositories?.length || 0
|
||||
const totalStorage = repositories?.reduce((sum, repo) => sum + repo.size_bytes, 0) || 0
|
||||
const usedStorage = repositories?.reduce((sum, repo) => sum + repo.used_bytes, 0) || 0
|
||||
// Calculate uptime (mock for now, would come from metrics)
|
||||
const uptime = metrics?.system?.uptime_seconds || 0
|
||||
const days = Math.floor(uptime / 86400)
|
||||
const hours = Math.floor((uptime % 86400) / 3600)
|
||||
const minutes = Math.floor((uptime % 3600) / 60)
|
||||
|
||||
// Mock active jobs (would come from tasks API)
|
||||
const activeJobs = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily Backup: VM-Cluster-01',
|
||||
type: 'Replication',
|
||||
progress: 45,
|
||||
speed: '145 MB/s',
|
||||
status: 'running',
|
||||
eta: '1h 12m',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'ZFS Scrub: Pool-01',
|
||||
type: 'Maintenance',
|
||||
progress: 78,
|
||||
speed: '1.2 GB/s',
|
||||
status: 'running',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock system logs
|
||||
const systemLogs = [
|
||||
{ time: '10:45:22', level: 'INFO', source: 'systemd', message: 'Started User Manager for UID 1000.' },
|
||||
{ time: '10:45:15', level: 'WARN', source: 'smartd', message: 'Device: /dev/ada5, SMART Usage Attribute: 194 Temperature_Celsius changed from 38 to 41' },
|
||||
{ time: '10:44:58', level: 'INFO', source: 'kernel', message: 'ix0: link state changed to UP' },
|
||||
{ time: '10:42:10', level: 'INFO', source: 'zfs', message: 'zfs_arc_reclaim_thread: reclaiming 157286400 bytes ...' },
|
||||
]
|
||||
|
||||
const totalStorage = Array.isArray(repositories) ? repositories.reduce((sum, repo) => sum + (repo?.size_bytes || 0), 0) : 0
|
||||
const usedStorage = Array.isArray(repositories) ? repositories.reduce((sum, repo) => sum + (repo?.used_bytes || 0), 0) : 0
|
||||
const storagePercent = totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0
|
||||
|
||||
// Initialize network data
|
||||
useEffect(() => {
|
||||
// Generate initial 30 data points
|
||||
const initialData = []
|
||||
const now = Date.now()
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const time = new Date(now - i * 5000)
|
||||
const minutes = time.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = time.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
initialData.push({
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
})
|
||||
}
|
||||
setNetworkDataPoints(initialData)
|
||||
|
||||
// Update data every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
setNetworkDataPoints((prev) => {
|
||||
const now = new Date()
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
const newPoint = {
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
}
|
||||
|
||||
// Keep only last 30 points
|
||||
const updated = [...prev.slice(1), newPoint]
|
||||
return updated
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Calculate current and peak throughput
|
||||
const currentThroughput = useMemo(() => {
|
||||
if (networkDataPoints.length === 0) return { inbound: 0, outbound: 0, total: 0 }
|
||||
const last = networkDataPoints[networkDataPoints.length - 1]
|
||||
return {
|
||||
inbound: last.inbound,
|
||||
outbound: last.outbound,
|
||||
total: last.inbound + last.outbound,
|
||||
}
|
||||
}, [networkDataPoints])
|
||||
|
||||
const peakThroughput = useMemo(() => {
|
||||
if (networkDataPoints.length === 0) return 0
|
||||
return Math.max(...networkDataPoints.map((d) => d.inbound + d.outbound))
|
||||
}, [networkDataPoints])
|
||||
|
||||
const systemStatus = health?.status === 'healthy' ? 'System Healthy' : 'System Degraded'
|
||||
const isHealthy = health?.status === 'healthy'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Overview of your Calypso backup appliance
|
||||
</p>
|
||||
<div className="min-h-screen bg-background-dark text-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex-none px-6 py-5 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
|
||||
<div className="flex flex-wrap justify-between items-end gap-3 max-w-[1600px] mx-auto">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-white text-3xl font-black tracking-tight">System Monitor</h2>
|
||||
<p className="text-text-secondary text-sm">Real-time telemetry, storage health, and system event logs</p>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{health && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full ${
|
||||
health.status === 'healthy'
|
||||
? 'bg-green-500'
|
||||
: health.status === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-lg font-semibold capitalize">{health.status}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">Service: {health.service}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Repositories</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalRepos}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(usedStorage)} / {formatBytes(totalStorage)} used
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{unacknowledgedAlerts}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unacknowledgedAlerts === 0 ? 'All clear' : 'Requires attention'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{metrics && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card-dark rounded-lg border border-border-dark">
|
||||
<span className="relative flex h-2 w-2">
|
||||
{isHealthy && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">iSCSI Targets</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.scst.total_targets}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics.scst.active_sessions} active sessions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tasks</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.tasks.running_tasks}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics.tasks.pending_tasks} pending
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</>
|
||||
)}
|
||||
{!isHealthy && (
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isHealthy ? 'text-emerald-400' : 'text-yellow-400'}`}>
|
||||
{systemStatus}
|
||||
</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 h-10 px-4 bg-card-dark hover:bg-[#233648] border border-border-dark text-white text-sm font-bold rounded-lg transition-colors">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Refresh: {refreshInterval}s</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<div className="flex flex-col gap-6 max-w-[1600px] mx-auto pb-10">
|
||||
{/* Top Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* CPU */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">CPU Load</p>
|
||||
<Cpu className="text-text-secondary w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metrics?.system?.cpu_usage_percent?.toFixed(0) || 0}%
|
||||
</p>
|
||||
<span className="text-emerald-500 text-sm font-medium mb-1 flex items-center">
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
2%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.cpu_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common operations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link to="/storage" className="w-full">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Manage Storage
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/alerts" className="w-full">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
View Alerts
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* RAM */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">RAM Usage</p>
|
||||
<MemoryStick className="text-text-secondary w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{formatBytes(metrics?.system?.memory_used_bytes || 0, 1)}
|
||||
</p>
|
||||
<span className="text-text-secondary text-xs mb-2">
|
||||
/ {formatBytes(metrics?.system?.memory_total_bytes || 0, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.memory_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Alerts */}
|
||||
{alerts && alerts.alerts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Alerts</CardTitle>
|
||||
<CardDescription>Latest unacknowledged alerts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{alerts.alerts.slice(0, 3).map((alert) => (
|
||||
<div key={alert.id} className="text-sm">
|
||||
<p className="font-medium">{alert.title}</p>
|
||||
<p className="text-xs text-gray-500">{alert.message}</p>
|
||||
{/* Storage Health */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">Storage Status</p>
|
||||
<CheckCircle2 className="text-emerald-500 w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">Online</p>
|
||||
<span className="text-text-secondary text-sm font-medium mb-1">No Errors</span>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-3">
|
||||
<div className="h-1.5 flex-1 bg-emerald-500 rounded-l-full"></div>
|
||||
<div className="h-1.5 flex-1 bg-emerald-500"></div>
|
||||
<div className="h-1.5 flex-1 bg-emerald-500"></div>
|
||||
<div className="h-1.5 flex-1 bg-emerald-500 rounded-r-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">System Uptime</p>
|
||||
<Clock className="text-text-secondary w-5 h-5" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{days}d {hours}h {minutes}m
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs mt-3">Last reboot: Manual Patching</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Section: Charts & Storage */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Charts Column (2/3) */}
|
||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
||||
{/* Network Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">Network Throughput</h3>
|
||||
<p className="text-text-secondary text-sm">Inbound vs Outbound (eth0)</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">
|
||||
{(currentThroughput.total / 1000).toFixed(1)} Gbps
|
||||
</p>
|
||||
<p className="text-emerald-500 text-sm">Peak: {(peakThroughput / 1000).toFixed(1)} Gbps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={networkDataPoints} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#324d67" opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#92adc9"
|
||||
style={{ fontSize: '11px' }}
|
||||
tick={{ fill: '#92adc9' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#92adc9"
|
||||
style={{ fontSize: '11px' }}
|
||||
tick={{ fill: '#92adc9' }}
|
||||
label={{ value: 'Mbps', angle: -90, position: 'insideLeft', fill: '#92adc9', style: { fontSize: '11px' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1a2632',
|
||||
border: '1px solid #324d67',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
labelStyle={{ color: '#92adc9', fontSize: '12px' }}
|
||||
itemStyle={{ color: '#fff', fontSize: '12px' }}
|
||||
formatter={(value: number) => [`${value} Mbps`, '']}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', color: '#92adc9' }}
|
||||
iconType="line"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inbound"
|
||||
stroke="#137fec"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="Inbound"
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outbound"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="Outbound"
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">Storage Capacity</h3>
|
||||
<p className="text-text-secondary text-sm">Repository usage</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">
|
||||
{storagePercent.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-text-secondary text-sm">Target: <90%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[150px] w-full relative">
|
||||
<div className="w-full h-full bg-[#111a22] rounded flex items-center justify-center">
|
||||
<div className="w-full px-4">
|
||||
<div className="w-full bg-[#233648] h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all"
|
||||
style={{ width: `${storagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Info Column (1/3) */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 h-full shadow-sm flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-white text-lg font-bold">Storage Overview</h3>
|
||||
<span className="bg-[#233648] text-white text-xs px-2 py-1 rounded border border-border-dark">
|
||||
{repositories?.length || 0} Repos
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border-dark">
|
||||
<div className="flex justify-between text-sm text-text-secondary">
|
||||
<span>Total Capacity</span>
|
||||
<span className="text-white font-bold">{formatBytes(totalStorage)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#233648] h-2 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full transition-all"
|
||||
style={{ width: `${storagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-secondary mt-1">
|
||||
<span>Used: {formatBytes(usedStorage)}</span>
|
||||
<span>Free: {formatBytes(totalStorage - usedStorage)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tabs & Logs */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl shadow-sm overflow-hidden flex flex-col h-[400px]">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex border-b border-border-dark bg-[#161f29]">
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`px-6 py-4 text-sm font-bold transition-colors ${
|
||||
activeTab === 'jobs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Active Jobs{' '}
|
||||
{activeJobs.length > 0 && (
|
||||
<span className="ml-2 bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs">
|
||||
{activeJobs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-6 py-4 text-sm font-medium transition-colors ${
|
||||
activeTab === 'logs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
System Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('alerts')}
|
||||
className={`px-6 py-4 text-sm font-medium transition-colors ${
|
||||
activeTab === 'alerts'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Alerts History
|
||||
</button>
|
||||
<div className="flex-1 flex justify-end items-center px-4">
|
||||
<div className="relative">
|
||||
<Activity className="absolute left-2 top-1.5 text-text-secondary w-4 h-4" />
|
||||
<input
|
||||
className="bg-[#111a22] border border-border-dark rounded-md py-1 pl-8 pr-3 text-sm text-white focus:outline-none focus:border-primary w-48 transition-all"
|
||||
placeholder="Search logs..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{activeTab === 'jobs' && (
|
||||
<div className="p-0">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#1a2632] text-xs uppercase text-text-secondary font-medium sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Job Name</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Type</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark w-1/3">Progress</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Speed</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm divide-y divide-border-dark">
|
||||
{activeJobs.map((job) => (
|
||||
<tr key={job.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{job.name}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.type}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full bg-[#111a22] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full relative overflow-hidden"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white">{job.progress}%</span>
|
||||
</div>
|
||||
{job.eta && (
|
||||
<p className="text-[10px] text-text-secondary mt-1">ETA: {job.eta}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{job.speed}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-primary/20 text-primary">
|
||||
Running
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<>
|
||||
<div className="px-6 py-2 bg-[#161f29] border-y border-border-dark flex items-center justify-between">
|
||||
<h4 className="text-xs uppercase text-text-secondary font-bold tracking-wider">
|
||||
Recent System Events
|
||||
</h4>
|
||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
||||
View All Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||
{systemLogs.map((log, idx) => (
|
||||
<tr key={idx} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
|
||||
{log.time}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span
|
||||
className={
|
||||
log.level === 'INFO'
|
||||
? 'text-emerald-500'
|
||||
: log.level === 'WARN'
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2 w-32 text-white">{log.source}</td>
|
||||
<td className="px-6 py-2 text-text-secondary truncate max-w-lg">
|
||||
{log.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22] p-6">
|
||||
{alerts?.alerts && alerts.alerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{alerts.alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="bg-[#1a2632] border border-border-dark rounded-lg p-4 hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle
|
||||
className={`w-4 h-4 ${
|
||||
alert.severity === 'critical'
|
||||
? 'text-red-500'
|
||||
: alert.severity === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<h4 className="text-white font-medium">{alert.title}</h4>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
alert.severity === 'critical'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: alert.severity === 'warning'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{alert.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{alert.message}</p>
|
||||
<p className="text-text-secondary text-xs mt-2 font-mono">
|
||||
{new Date(alert.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{alerts.alerts.length > 3 && (
|
||||
<Link to="/alerts">
|
||||
<Button variant="link" className="p-0 h-auto">
|
||||
View all alerts →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-500 mx-auto mb-4" />
|
||||
<p className="text-text-secondary">No alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,17 @@ export default function ISCSITargetDetail() {
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-gray-500">Loading target details...</div>
|
||||
return <div className="text-sm text-text-secondary min-h-screen bg-background-dark p-6">Loading target details...</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-sm text-red-500">Target not found</div>
|
||||
return <div className="text-sm text-red-400 min-h-screen bg-background-dark p-6">Target not found</div>
|
||||
}
|
||||
|
||||
const { target, luns } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -44,9 +44,9 @@ export default function ISCSITargetDetail() {
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 font-mono text-lg">{target.iqn}</h1>
|
||||
<h1 className="text-3xl font-bold text-white font-mono text-lg">{target.iqn}</h1>
|
||||
{target.alias && (
|
||||
<p className="mt-1 text-sm text-gray-600">{target.alias}</p>
|
||||
<p className="mt-1 text-sm text-text-secondary">{target.alias}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,14 +70,14 @@ export default function ISCSITargetDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-400' : 'text-text-secondary'}>
|
||||
{target.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">IQN:</span>
|
||||
<span className="font-mono text-xs">{target.iqn}</span>
|
||||
<span className="text-text-secondary">IQN:</span>
|
||||
<span className="font-mono text-xs text-white">{target.iqn}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -90,12 +90,12 @@ export default function ISCSITargetDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Total LUNs:</span>
|
||||
<span className="font-medium">{luns.length}</span>
|
||||
<span className="text-text-secondary">Total LUNs:</span>
|
||||
<span className="font-medium text-white">{luns.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Active:</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-text-secondary">Active:</span>
|
||||
<span className="font-medium text-white">
|
||||
{luns.filter((l) => l.is_active).length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -150,38 +150,38 @@ export default function ISCSITargetDetail() {
|
||||
{luns.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-[#1a2632]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
LUN #
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Handler
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Device Path
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
{luns.map((lun) => (
|
||||
<tr key={lun.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<tr key={lun.id} className="hover:bg-[#233648]">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{lun.lun_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{lun.handler}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-xs">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-xs text-white">
|
||||
{lun.device_path}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{lun.device_type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -203,7 +203,7 @@ export default function ISCSITargetDetail() {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm text-gray-500 mb-4">No LUNs configured</p>
|
||||
<p className="text-sm text-text-secondary mb-4">No LUNs configured</p>
|
||||
<Button variant="outline" onClick={() => setShowAddLUN(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add First LUN
|
||||
@@ -414,7 +414,7 @@ function AddInitiatorForm({ targetId, onClose, onSuccess }: AddInitiatorFormProp
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Format: iqn.YYYY-MM.reverse.domain:identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -24,11 +24,11 @@ export default function ISCSITargets() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">iSCSI Targets</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">iSCSI Targets</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Manage SCST iSCSI targets, LUNs, and initiators
|
||||
</p>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ export default function ISCSITargets() {
|
||||
|
||||
{/* Targets List */}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading targets...</p>
|
||||
<p className="text-sm text-text-secondary">Loading targets...</p>
|
||||
) : targets && targets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{targets.map((target) => (
|
||||
@@ -72,8 +72,8 @@ export default function ISCSITargets() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No iSCSI Targets</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<h3 className="text-lg font-medium text-white mb-2">No iSCSI Targets</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Create your first iSCSI target to start exporting storage
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
@@ -111,14 +111,14 @@ function TargetCard({ target }: TargetCardProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-400' : 'text-text-secondary'}>
|
||||
{target.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Created:</span>
|
||||
<span className="text-gray-700">
|
||||
<span className="text-text-secondary">Created:</span>
|
||||
<span className="text-white">
|
||||
{new Date(target.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Format: iqn.YYYY-MM.reverse.domain:identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,20 +29,20 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
|
||||
<div className="min-h-screen flex items-center justify-center bg-background-dark">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-card-dark border border-border-dark rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
|
||||
AtlasOS - Calypso
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
<p className="mt-2 text-center text-sm text-text-secondary">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
<div className="rounded-md bg-red-500/10 border border-red-500/30 p-4">
|
||||
<p className="text-sm text-red-400 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
@@ -55,7 +55,7 @@ export default function LoginPage() {
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -70,7 +70,7 @@ export default function LoginPage() {
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -82,9 +82,22 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="relative group relative w-full flex justify-center py-2.5 px-4 border border-primary/30 bg-card-dark text-white text-sm font-bold rounded-lg hover:bg-[#233648] transition-all overflow-hidden electric-glow electric-glow-border disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in'}
|
||||
{/* Electric glow gradient overlay */}
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-primary/0 via-primary/15 to-primary/0 opacity-60"></span>
|
||||
{/* Shimmer effect */}
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent animate-[shimmer_3s_infinite]"></span>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[18px] animate-spin">refresh</span>
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,11 @@ export default function TapeLibraries() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Tape Libraries</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">Tape Libraries</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Manage physical and virtual tape libraries
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,14 +49,14 @@ export default function TapeLibraries() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="border-b border-border-dark">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('vtl')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'vtl'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
||||
}`}
|
||||
>
|
||||
Virtual Tape Libraries ({vtlLibraries?.length || 0})
|
||||
@@ -65,8 +65,8 @@ export default function TapeLibraries() {
|
||||
onClick={() => setActiveTab('physical')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'physical'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
||||
}`}
|
||||
>
|
||||
Physical Libraries ({physicalLibraries?.length || 0})
|
||||
@@ -78,7 +78,7 @@ export default function TapeLibraries() {
|
||||
{activeTab === 'vtl' && (
|
||||
<div>
|
||||
{loadingVTL ? (
|
||||
<p className="text-sm text-gray-500">Loading VTL libraries...</p>
|
||||
<p className="text-sm text-text-secondary">Loading VTL libraries...</p>
|
||||
) : vtlLibraries && vtlLibraries.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{vtlLibraries.map((library: VirtualTapeLibrary) => (
|
||||
@@ -89,8 +89,8 @@ export default function TapeLibraries() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Virtual Tape Libraries</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<h3 className="text-lg font-medium text-white mb-2">No Virtual Tape Libraries</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Create your first virtual tape library to get started
|
||||
</p>
|
||||
<Link to="/tape/vtl/create">
|
||||
@@ -108,7 +108,7 @@ export default function TapeLibraries() {
|
||||
{activeTab === 'physical' && (
|
||||
<div>
|
||||
{loadingPhysical ? (
|
||||
<p className="text-sm text-gray-500">Loading physical libraries...</p>
|
||||
<p className="text-sm text-text-secondary">Loading physical libraries...</p>
|
||||
) : physicalLibraries && physicalLibraries.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{physicalLibraries.map((library: PhysicalTapeLibrary) => (
|
||||
@@ -119,8 +119,8 @@ export default function TapeLibraries() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Physical Tape Libraries</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<h3 className="text-lg font-medium text-white mb-2">No Physical Tape Libraries</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Discover physical tape libraries connected to the system
|
||||
</p>
|
||||
<Button variant="outline">
|
||||
@@ -168,17 +168,17 @@ function LibraryCard({ library, type }: LibraryCardProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Slots:</span>
|
||||
<span className="font-medium">{library.slot_count}</span>
|
||||
<span className="text-text-secondary">Slots:</span>
|
||||
<span className="font-medium text-white">{library.slot_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Drives:</span>
|
||||
<span className="font-medium">{library.drive_count}</span>
|
||||
<span className="text-text-secondary">Drives:</span>
|
||||
<span className="font-medium text-white">{library.drive_count}</span>
|
||||
</div>
|
||||
{isPhysical && 'vendor' in library && library.vendor && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Vendor:</span>
|
||||
<span className="font-medium">{library.vendor} {library.model}</span>
|
||||
<span className="text-text-secondary">Vendor:</span>
|
||||
<span className="font-medium text-white">{library.vendor} {library.model}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,17 +28,17 @@ export default function VTLDetail() {
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-gray-500">Loading library details...</div>
|
||||
return <div className="text-sm text-text-secondary min-h-screen bg-background-dark p-6">Loading library details...</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-sm text-red-500">Library not found</div>
|
||||
return <div className="text-sm text-red-400 min-h-screen bg-background-dark p-6">Library not found</div>
|
||||
}
|
||||
|
||||
const { library, drives, tapes } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -47,8 +47,8 @@ export default function VTLDetail() {
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{library.name}</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">{library.name}</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Virtual Tape Library • {library.slot_count} slots • {library.drive_count} drives
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,18 +75,18 @@ export default function VTLDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={library.is_active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className={library.is_active ? 'text-green-400' : 'text-text-secondary'}>
|
||||
{library.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">mhVTL ID:</span>
|
||||
<span className="font-medium">{library.mhvtl_library_id}</span>
|
||||
<span className="text-text-secondary">mhVTL ID:</span>
|
||||
<span className="font-medium text-white">{library.mhvtl_library_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Storage Path:</span>
|
||||
<span className="font-mono text-xs">{library.storage_path}</span>
|
||||
<span className="text-text-secondary">Storage Path:</span>
|
||||
<span className="font-mono text-xs text-white">{library.storage_path}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -99,16 +99,16 @@ export default function VTLDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Total Slots:</span>
|
||||
<span className="font-medium">{library.slot_count}</span>
|
||||
<span className="text-text-secondary">Total Slots:</span>
|
||||
<span className="font-medium text-white">{library.slot_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Used Slots:</span>
|
||||
<span className="font-medium">{tapes.length}</span>
|
||||
<span className="text-text-secondary">Used Slots:</span>
|
||||
<span className="font-medium text-white">{tapes.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Free Slots:</span>
|
||||
<span className="font-medium">{library.slot_count - tapes.length}</span>
|
||||
<span className="text-text-secondary">Free Slots:</span>
|
||||
<span className="font-medium text-white">{library.slot_count - tapes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -121,18 +121,18 @@ export default function VTLDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Total Drives:</span>
|
||||
<span className="font-medium">{library.drive_count}</span>
|
||||
<span className="text-text-secondary">Total Drives:</span>
|
||||
<span className="font-medium text-white">{library.drive_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Idle:</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-text-secondary">Idle:</span>
|
||||
<span className="font-medium text-white">
|
||||
{drives.filter((d) => d.status === 'idle').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Ready:</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-text-secondary">Ready:</span>
|
||||
<span className="font-medium text-white">
|
||||
{drives.filter((d) => d.status === 'ready').length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ export default function VTLDetail() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No drives configured</p>
|
||||
<p className="text-sm text-text-secondary">No drives configured</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -194,35 +194,35 @@ export default function VTLDetail() {
|
||||
{tapes.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-[#1a2632]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Barcode
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Slot
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
{tapes.map((tape) => (
|
||||
<tr key={tape.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<tr key={tape.id} className="hover:bg-[#233648]">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{tape.barcode}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{tape.slot_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{formatBytes(tape.size_bytes)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -259,7 +259,7 @@ export default function VTLDetail() {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No tapes created yet</p>
|
||||
<p className="text-sm text-text-secondary">No tapes created yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -299,12 +299,12 @@ function DriveCard({ drive, tapes, isSelected, onSelect }: DriveCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
{currentTape ? (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Tape: {currentTape.barcode}</p>
|
||||
<p className="text-xs text-gray-500">{formatBytes(currentTape.size_bytes)}</p>
|
||||
<div className="text-sm text-text-secondary">
|
||||
<p className="text-white">Tape: {currentTape.barcode}</p>
|
||||
<p className="text-xs text-text-secondary">{formatBytes(currentTape.size_bytes)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No tape loaded</p>
|
||||
<p className="text-sm text-text-secondary">No tape loaded</p>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
DEFAULT: "#137fec",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
@@ -41,11 +41,20 @@ export default {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
// Dark theme colors from example
|
||||
"background-dark": "#111a22",
|
||||
"card-dark": "#1a2632",
|
||||
"border-dark": "#324d67",
|
||||
"text-secondary": "#92adc9",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["Manrope", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
lg: "0.5rem",
|
||||
xl: "0.75rem",
|
||||
DEFAULT: "0.25rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
73
scripts/restart-api-service.sh
Executable file
73
scripts/restart-api-service.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# AtlasOS - Calypso API Service Restart Script
|
||||
# Restarts the API server using systemd service
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "Please run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/../backend" || exit 1
|
||||
|
||||
log_info "Building Calypso API..."
|
||||
|
||||
# Build the application
|
||||
if go build -o bin/calypso-api ./cmd/calypso-api; then
|
||||
log_info "✓ Build successful"
|
||||
else
|
||||
log_error "✗ Build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Restart the service
|
||||
log_info "Restarting calypso-api service..."
|
||||
if systemctl restart calypso-api; then
|
||||
log_info "✓ Service restarted successfully"
|
||||
sleep 2
|
||||
|
||||
# Check service status
|
||||
if systemctl is-active --quiet calypso-api; then
|
||||
log_info "✓ Service is running"
|
||||
log_info ""
|
||||
log_info "Service Status:"
|
||||
systemctl status calypso-api --no-pager -l | head -15
|
||||
log_info ""
|
||||
log_info "Recent logs:"
|
||||
journalctl -u calypso-api --no-pager -n 10 | tail -5
|
||||
else
|
||||
log_error "✗ Service failed to start"
|
||||
log_error "Check logs with: sudo journalctl -u calypso-api -n 50"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_error "✗ Failed to restart service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info ""
|
||||
log_info "✅ API server restarted successfully!"
|
||||
log_info " Health check: curl http://localhost:8080/api/v1/health"
|
||||
log_info " View logs: sudo journalctl -u calypso-api -f"
|
||||
|
||||
Reference in New Issue
Block a user