development #1
Binary file not shown.
@@ -137,8 +137,8 @@ func cacheControlMiddleware() gin.HandlerFunc {
|
|||||||
case path == "/api/v1/system/services":
|
case path == "/api/v1/system/services":
|
||||||
// Service list can be cached briefly
|
// Service list can be cached briefly
|
||||||
c.Header("Cache-Control", "public, max-age=60")
|
c.Header("Cache-Control", "public, max-age=60")
|
||||||
case strings.HasPrefix(path, "/api/v1/storage/zfs/pools/") && strings.HasSuffix(path, "/datasets"):
|
case strings.HasPrefix(path, "/api/v1/storage/zfs/pools"):
|
||||||
// ZFS datasets should not be cached - they change frequently
|
// ZFS pools and datasets should not be cached - they change frequently
|
||||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
default:
|
default:
|
||||||
// Default: no cache for other endpoints
|
// Default: no cache for other endpoints
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
|||||||
if responseCache != nil {
|
if responseCache != nil {
|
||||||
storageHandler.SetCache(responseCache)
|
storageHandler.SetCache(responseCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start disk monitor service in background (syncs disks every 5 minutes)
|
||||||
|
diskMonitor := storage.NewDiskMonitor(db, log, 5*time.Minute)
|
||||||
|
go diskMonitor.Start(context.Background())
|
||||||
|
|
||||||
|
// Start ZFS pool monitor service in background (syncs pools every 2 minutes)
|
||||||
|
zfsPoolMonitor := storage.NewZFSPoolMonitor(db, log, 2*time.Minute)
|
||||||
|
go zfsPoolMonitor.Start(context.Background())
|
||||||
|
|
||||||
storageGroup := protected.Group("/storage")
|
storageGroup := protected.Group("/storage")
|
||||||
storageGroup.Use(requirePermission("storage", "read"))
|
storageGroup.Use(requirePermission("storage", "read"))
|
||||||
{
|
{
|
||||||
@@ -217,6 +226,11 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
|||||||
|
|
||||||
// Virtual Tape Libraries
|
// Virtual Tape Libraries
|
||||||
vtlHandler := tape_vtl.NewHandler(db, log)
|
vtlHandler := tape_vtl.NewHandler(db, log)
|
||||||
|
|
||||||
|
// Start MHVTL monitor service in background (syncs every 5 minutes)
|
||||||
|
mhvtlMonitor := tape_vtl.NewMHVTLMonitor(db, log, "/etc/mhvtl", 5*time.Minute)
|
||||||
|
go mhvtlMonitor.Start(context.Background())
|
||||||
|
|
||||||
vtlGroup := protected.Group("/tape/vtl")
|
vtlGroup := protected.Group("/tape/vtl")
|
||||||
vtlGroup.Use(requirePermission("tape", "read"))
|
vtlGroup.Use(requirePermission("tape", "read"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -75,7 +75,21 @@ func (s *DiskService) DiscoverDisks(ctx context.Context) ([]PhysicalDisk, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
devicePath := "/dev/" + device.Name
|
devicePath := "/dev/" + device.Name
|
||||||
|
|
||||||
|
// Skip ZFS volume block devices (zd* devices are ZFS volumes exported as block devices)
|
||||||
|
// These are not physical disks and should not appear in physical disk list
|
||||||
|
if strings.HasPrefix(device.Name, "zd") {
|
||||||
|
s.logger.Debug("Skipping ZFS volume block device", "device", devicePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip devices under /dev/zvol (ZFS volume devices in zvol directory)
|
||||||
|
// These are virtual block devices created from ZFS volumes, not physical hardware
|
||||||
|
if strings.HasPrefix(devicePath, "/dev/zvol/") {
|
||||||
|
s.logger.Debug("Skipping ZFS volume device", "device", devicePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Skip OS disk (disk that has root or boot partition)
|
// Skip OS disk (disk that has root or boot partition)
|
||||||
if s.isOSDisk(ctx, devicePath) {
|
if s.isOSDisk(ctx, devicePath) {
|
||||||
s.logger.Debug("Skipping OS disk", "device", devicePath)
|
s.logger.Debug("Skipping OS disk", "device", devicePath)
|
||||||
@@ -113,8 +127,8 @@ func (s *DiskService) DiscoverDisks(ctx context.Context) ([]PhysicalDisk, error)
|
|||||||
// getDiskInfo retrieves detailed information about a disk
|
// getDiskInfo retrieves detailed information about a disk
|
||||||
func (s *DiskService) getDiskInfo(ctx context.Context, devicePath string) (*PhysicalDisk, error) {
|
func (s *DiskService) getDiskInfo(ctx context.Context, devicePath string) (*PhysicalDisk, error) {
|
||||||
disk := &PhysicalDisk{
|
disk := &PhysicalDisk{
|
||||||
DevicePath: devicePath,
|
DevicePath: devicePath,
|
||||||
HealthStatus: "unknown",
|
HealthStatus: "unknown",
|
||||||
HealthDetails: make(map[string]interface{}),
|
HealthDetails: make(map[string]interface{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +143,7 @@ func (s *DiskService) getDiskInfo(ctx context.Context, devicePath string) (*Phys
|
|||||||
disk.Vendor = props["ID_VENDOR"]
|
disk.Vendor = props["ID_VENDOR"]
|
||||||
disk.Model = props["ID_MODEL"]
|
disk.Model = props["ID_MODEL"]
|
||||||
disk.SerialNumber = props["ID_SERIAL_SHORT"]
|
disk.SerialNumber = props["ID_SERIAL_SHORT"]
|
||||||
|
|
||||||
if props["ID_ATA_ROTATION_RATE"] == "0" {
|
if props["ID_ATA_ROTATION_RATE"] == "0" {
|
||||||
disk.IsSSD = true
|
disk.IsSSD = true
|
||||||
}
|
}
|
||||||
@@ -258,11 +272,15 @@ func (s *DiskService) isOSDisk(ctx context.Context, devicePath string) bool {
|
|||||||
|
|
||||||
// SyncDisksToDatabase syncs discovered disks to the database
|
// SyncDisksToDatabase syncs discovered disks to the database
|
||||||
func (s *DiskService) SyncDisksToDatabase(ctx context.Context) error {
|
func (s *DiskService) SyncDisksToDatabase(ctx context.Context) error {
|
||||||
|
s.logger.Info("Starting disk discovery and sync")
|
||||||
disks, err := s.DiscoverDisks(ctx)
|
disks, err := s.DiscoverDisks(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to discover disks", "error", err)
|
||||||
return fmt.Errorf("failed to discover disks: %w", err)
|
return fmt.Errorf("failed to discover disks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Discovered disks", "count", len(disks))
|
||||||
|
|
||||||
for _, disk := range disks {
|
for _, disk := range disks {
|
||||||
// Check if disk exists
|
// Check if disk exists
|
||||||
var existingID string
|
var existingID string
|
||||||
@@ -300,10 +318,80 @@ func (s *DiskService) SyncDisksToDatabase(ctx context.Context) error {
|
|||||||
disk.HealthStatus, healthDetailsJSON, disk.IsUsed, existingID)
|
disk.HealthStatus, healthDetailsJSON, disk.IsUsed, existingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to update disk", "device", disk.DevicePath, "error", err)
|
s.logger.Error("Failed to update disk", "device", disk.DevicePath, "error", err)
|
||||||
|
} else {
|
||||||
|
s.logger.Debug("Updated disk", "device", disk.DevicePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Disk sync completed", "total_disks", len(disks))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDisksFromDatabase retrieves all physical disks from the database
|
||||||
|
func (s *DiskService) ListDisksFromDatabase(ctx context.Context) ([]PhysicalDisk, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, device_path, vendor, model, serial_number, size_bytes,
|
||||||
|
sector_size, is_ssd, health_status, health_details, is_used,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM physical_disks
|
||||||
|
ORDER BY device_path
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query disks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var disks []PhysicalDisk
|
||||||
|
for rows.Next() {
|
||||||
|
var disk PhysicalDisk
|
||||||
|
var healthDetailsJSON []byte
|
||||||
|
var attachedToPool sql.NullString
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&disk.ID, &disk.DevicePath, &disk.Vendor, &disk.Model,
|
||||||
|
&disk.SerialNumber, &disk.SizeBytes, &disk.SectorSize,
|
||||||
|
&disk.IsSSD, &disk.HealthStatus, &healthDetailsJSON,
|
||||||
|
&disk.IsUsed, &disk.CreatedAt, &disk.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to scan disk row", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse health details JSON
|
||||||
|
if len(healthDetailsJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(healthDetailsJSON, &disk.HealthDetails); err != nil {
|
||||||
|
s.logger.Warn("Failed to parse health details", "error", err)
|
||||||
|
disk.HealthDetails = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disk.HealthDetails = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ZFS pool attachment if disk is used
|
||||||
|
if disk.IsUsed {
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT zp.name FROM zfs_pools zp
|
||||||
|
INNER JOIN zfs_pool_disks zpd ON zp.id = zpd.pool_id
|
||||||
|
WHERE zpd.disk_id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
disk.ID,
|
||||||
|
).Scan(&attachedToPool)
|
||||||
|
if err == nil && attachedToPool.Valid {
|
||||||
|
disk.AttachedToPool = attachedToPool.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disks = append(disks, disk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error iterating disk rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return disks, nil
|
||||||
|
}
|
||||||
|
|||||||
65
backend/internal/storage/disk_monitor.go
Normal file
65
backend/internal/storage/disk_monitor.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
|
"github.com/atlasos/calypso/internal/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiskMonitor handles periodic disk discovery and sync to database
|
||||||
|
type DiskMonitor struct {
|
||||||
|
diskService *DiskService
|
||||||
|
logger *logger.Logger
|
||||||
|
interval time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDiskMonitor creates a new disk monitor service
|
||||||
|
func NewDiskMonitor(db *database.DB, log *logger.Logger, interval time.Duration) *DiskMonitor {
|
||||||
|
return &DiskMonitor{
|
||||||
|
diskService: NewDiskService(db, log),
|
||||||
|
logger: log,
|
||||||
|
interval: interval,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the disk monitor background service
|
||||||
|
func (m *DiskMonitor) Start(ctx context.Context) {
|
||||||
|
m.logger.Info("Starting disk monitor service", "interval", m.interval)
|
||||||
|
ticker := time.NewTicker(m.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run initial sync immediately
|
||||||
|
m.syncDisks(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.logger.Info("Disk monitor service stopped")
|
||||||
|
return
|
||||||
|
case <-m.stopCh:
|
||||||
|
m.logger.Info("Disk monitor service stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.syncDisks(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the disk monitor service
|
||||||
|
func (m *DiskMonitor) Stop() {
|
||||||
|
close(m.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDisks performs disk discovery and sync to database
|
||||||
|
func (m *DiskMonitor) syncDisks(ctx context.Context) {
|
||||||
|
m.logger.Debug("Running periodic disk sync")
|
||||||
|
if err := m.diskService.SyncDisksToDatabase(ctx); err != nil {
|
||||||
|
m.logger.Error("Periodic disk sync failed", "error", err)
|
||||||
|
} else {
|
||||||
|
m.logger.Debug("Periodic disk sync completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,9 +43,9 @@ func NewHandler(db *database.DB, log *logger.Logger) *Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDisks lists all physical disks
|
// ListDisks lists all physical disks from database
|
||||||
func (h *Handler) ListDisks(c *gin.Context) {
|
func (h *Handler) ListDisks(c *gin.Context) {
|
||||||
disks, err := h.diskService.DiscoverDisks(c.Request.Context())
|
disks, err := h.diskService.ListDisksFromDatabase(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to list disks", "error", err)
|
h.logger.Error("Failed to list disks", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list disks"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list disks"})
|
||||||
@@ -70,15 +71,19 @@ func (h *Handler) SyncDisks(c *gin.Context) {
|
|||||||
|
|
||||||
// Run sync in background
|
// Run sync in background
|
||||||
go func() {
|
go func() {
|
||||||
ctx := c.Request.Context()
|
// Create new context for background task (don't use request context which may expire)
|
||||||
|
ctx := context.Background()
|
||||||
h.taskEngine.StartTask(ctx, taskID)
|
h.taskEngine.StartTask(ctx, taskID)
|
||||||
h.taskEngine.UpdateProgress(ctx, taskID, 50, "Discovering disks...")
|
h.taskEngine.UpdateProgress(ctx, taskID, 50, "Discovering disks...")
|
||||||
|
h.logger.Info("Starting disk sync", "task_id", taskID)
|
||||||
|
|
||||||
if err := h.diskService.SyncDisksToDatabase(ctx); err != nil {
|
if err := h.diskService.SyncDisksToDatabase(ctx); err != nil {
|
||||||
|
h.logger.Error("Disk sync failed", "task_id", taskID, "error", err)
|
||||||
h.taskEngine.FailTask(ctx, taskID, err.Error())
|
h.taskEngine.FailTask(ctx, taskID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Disk sync completed", "task_id", taskID)
|
||||||
h.taskEngine.UpdateProgress(ctx, taskID, 100, "Disk sync completed")
|
h.taskEngine.UpdateProgress(ctx, taskID, 100, "Disk sync completed")
|
||||||
h.taskEngine.CompleteTask(ctx, taskID, "Disks synchronized successfully")
|
h.taskEngine.CompleteTask(ctx, taskID, "Disks synchronized successfully")
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -391,7 +391,8 @@ func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) {
|
|||||||
&pool.CreatedAt, &pool.UpdatedAt, &pool.CreatedBy,
|
&pool.CreatedAt, &pool.UpdatedAt, &pool.CreatedBy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan pool: %w", err)
|
s.logger.Error("Failed to scan pool row", "error", err)
|
||||||
|
continue // Skip this pool instead of failing entire query
|
||||||
}
|
}
|
||||||
if description.Valid {
|
if description.Valid {
|
||||||
pool.Description = description.String
|
pool.Description = description.String
|
||||||
@@ -407,8 +408,14 @@ func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pools = append(pools, &pool)
|
pools = append(pools, &pool)
|
||||||
|
s.logger.Debug("Added pool to list", "pool_id", pool.ID, "name", pool.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error iterating pool rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Debug("Listed ZFS pools", "count", len(pools))
|
||||||
return pools, nil
|
return pools, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,11 +467,22 @@ func (s *ZFSService) DeletePool(ctx context.Context, poolID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy ZFS pool
|
// Destroy ZFS pool with -f flag to force destroy (works for both empty and non-empty pools)
|
||||||
cmd := exec.CommandContext(ctx, "zpool", "destroy", pool.Name)
|
// The -f flag is needed to destroy pools even if they have datasets or are in use
|
||||||
|
s.logger.Info("Destroying ZFS pool", "pool", pool.Name)
|
||||||
|
cmd := exec.CommandContext(ctx, "zpool", "destroy", "-f", pool.Name)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to destroy ZFS pool: %s: %w", string(output), err)
|
errorMsg := string(output)
|
||||||
|
// Check if pool doesn't exist (might have been destroyed already)
|
||||||
|
if strings.Contains(errorMsg, "no such pool") || strings.Contains(errorMsg, "cannot open") {
|
||||||
|
s.logger.Warn("Pool does not exist in ZFS, continuing with database cleanup", "pool", pool.Name)
|
||||||
|
// Continue with database cleanup even if pool doesn't exist
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to destroy ZFS pool: %s: %w", errorMsg, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.Info("ZFS pool destroyed successfully", "pool", pool.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark disks as unused
|
// Mark disks as unused
|
||||||
|
|||||||
254
backend/internal/storage/zfs_pool_monitor.go
Normal file
254
backend/internal/storage/zfs_pool_monitor.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
|
"github.com/atlasos/calypso/internal/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ZFSPoolMonitor handles periodic ZFS pool status monitoring and sync to database
|
||||||
|
type ZFSPoolMonitor struct {
|
||||||
|
zfsService *ZFSService
|
||||||
|
logger *logger.Logger
|
||||||
|
interval time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewZFSPoolMonitor creates a new ZFS pool monitor service
|
||||||
|
func NewZFSPoolMonitor(db *database.DB, log *logger.Logger, interval time.Duration) *ZFSPoolMonitor {
|
||||||
|
return &ZFSPoolMonitor{
|
||||||
|
zfsService: NewZFSService(db, log),
|
||||||
|
logger: log,
|
||||||
|
interval: interval,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the ZFS pool monitor background service
|
||||||
|
func (m *ZFSPoolMonitor) Start(ctx context.Context) {
|
||||||
|
m.logger.Info("Starting ZFS pool monitor service", "interval", m.interval)
|
||||||
|
ticker := time.NewTicker(m.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run initial sync immediately
|
||||||
|
m.syncPools(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.logger.Info("ZFS pool monitor service stopped")
|
||||||
|
return
|
||||||
|
case <-m.stopCh:
|
||||||
|
m.logger.Info("ZFS pool monitor service stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.syncPools(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the ZFS pool monitor service
|
||||||
|
func (m *ZFSPoolMonitor) Stop() {
|
||||||
|
close(m.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPools syncs ZFS pool status from system to database
|
||||||
|
func (m *ZFSPoolMonitor) syncPools(ctx context.Context) {
|
||||||
|
m.logger.Debug("Running periodic ZFS pool sync")
|
||||||
|
|
||||||
|
// Get all pools from system
|
||||||
|
systemPools, err := m.getSystemPools(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Failed to get system pools", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Debug("Found pools in system", "count", len(systemPools))
|
||||||
|
|
||||||
|
// Update each pool in database
|
||||||
|
for poolName, poolInfo := range systemPools {
|
||||||
|
if err := m.updatePoolStatus(ctx, poolName, poolInfo); err != nil {
|
||||||
|
m.logger.Error("Failed to update pool status", "pool", poolName, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark pools that don't exist in system as offline
|
||||||
|
if err := m.markMissingPoolsOffline(ctx, systemPools); err != nil {
|
||||||
|
m.logger.Error("Failed to mark missing pools offline", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Debug("ZFS pool sync completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolInfo represents pool information from system
|
||||||
|
type PoolInfo struct {
|
||||||
|
Name string
|
||||||
|
SizeBytes int64
|
||||||
|
UsedBytes int64
|
||||||
|
Health string // online, degraded, faulted, offline, unavailable, removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSystemPools gets all pools from ZFS system
|
||||||
|
func (m *ZFSPoolMonitor) getSystemPools(ctx context.Context) (map[string]PoolInfo, error) {
|
||||||
|
pools := make(map[string]PoolInfo)
|
||||||
|
|
||||||
|
// Get pool list
|
||||||
|
cmd := exec.CommandContext(ctx, "zpool", "list", "-H", "-o", "name,size,alloc,free,health")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
poolName := fields[0]
|
||||||
|
sizeStr := fields[1]
|
||||||
|
allocStr := fields[2]
|
||||||
|
health := fields[4]
|
||||||
|
|
||||||
|
// Parse size (e.g., "95.5G" -> bytes)
|
||||||
|
sizeBytes, err := parseSize(sizeStr)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to parse pool size", "pool", poolName, "size", sizeStr, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse allocated (used) size
|
||||||
|
usedBytes, err := parseSize(allocStr)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to parse pool used size", "pool", poolName, "alloc", allocStr, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize health status to lowercase
|
||||||
|
healthNormalized := strings.ToLower(health)
|
||||||
|
|
||||||
|
pools[poolName] = PoolInfo{
|
||||||
|
Name: poolName,
|
||||||
|
SizeBytes: sizeBytes,
|
||||||
|
UsedBytes: usedBytes,
|
||||||
|
Health: healthNormalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSize parses size string (e.g., "95.5G", "1.2T") to bytes
|
||||||
|
func parseSize(sizeStr string) (int64, error) {
|
||||||
|
// Remove any whitespace
|
||||||
|
sizeStr = strings.TrimSpace(sizeStr)
|
||||||
|
|
||||||
|
// Match pattern like "95.5G", "1.2T", "512M"
|
||||||
|
re := regexp.MustCompile(`^([\d.]+)([KMGT]?)$`)
|
||||||
|
matches := re.FindStringSubmatch(strings.ToUpper(sizeStr))
|
||||||
|
if len(matches) != 3 {
|
||||||
|
return 0, nil // Return 0 if can't parse
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(matches[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := matches[2]
|
||||||
|
var multiplier int64 = 1
|
||||||
|
|
||||||
|
switch unit {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(value * float64(multiplier)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePoolStatus updates pool status in database
|
||||||
|
func (m *ZFSPoolMonitor) updatePoolStatus(ctx context.Context, poolName string, poolInfo PoolInfo) error {
|
||||||
|
// Get pool from database by name
|
||||||
|
var poolID string
|
||||||
|
err := m.zfsService.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM zfs_pools WHERE name = $1",
|
||||||
|
poolName,
|
||||||
|
).Scan(&poolID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Pool not in database, skip (might be created outside of Calypso)
|
||||||
|
m.logger.Debug("Pool not found in database, skipping", "pool", poolName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pool status, size, and used bytes
|
||||||
|
_, err = m.zfsService.db.ExecContext(ctx, `
|
||||||
|
UPDATE zfs_pools SET
|
||||||
|
size_bytes = $1,
|
||||||
|
used_bytes = $2,
|
||||||
|
health_status = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, poolInfo.SizeBytes, poolInfo.UsedBytes, poolInfo.Health, poolID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Debug("Updated pool status", "pool", poolName, "health", poolInfo.Health, "size", poolInfo.SizeBytes, "used", poolInfo.UsedBytes)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markMissingPoolsOffline marks pools that exist in database but not in system as offline
|
||||||
|
func (m *ZFSPoolMonitor) markMissingPoolsOffline(ctx context.Context, systemPools map[string]PoolInfo) error {
|
||||||
|
// Get all pools from database
|
||||||
|
rows, err := m.zfsService.db.QueryContext(ctx, "SELECT id, name FROM zfs_pools WHERE is_active = true")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var poolID, poolName string
|
||||||
|
if err := rows.Scan(&poolID, &poolName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pool exists in system
|
||||||
|
if _, exists := systemPools[poolName]; !exists {
|
||||||
|
// Pool doesn't exist in system, mark as offline
|
||||||
|
_, err = m.zfsService.db.ExecContext(ctx, `
|
||||||
|
UPDATE zfs_pools SET
|
||||||
|
health_status = 'offline',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, poolID)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to mark pool as offline", "pool", poolName, "error", err)
|
||||||
|
} else {
|
||||||
|
m.logger.Info("Marked pool as offline (not found in system)", "pool", poolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
516
backend/internal/tape_vtl/mhvtl_monitor.go
Normal file
516
backend/internal/tape_vtl/mhvtl_monitor.go
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
package tape_vtl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
|
"github.com/atlasos/calypso/internal/common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MHVTLMonitor monitors mhvtl configuration files and syncs to database
|
||||||
|
type MHVTLMonitor struct {
|
||||||
|
service *Service
|
||||||
|
logger *logger.Logger
|
||||||
|
configPath string
|
||||||
|
interval time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMHVTLMonitor creates a new MHVTL monitor service
|
||||||
|
func NewMHVTLMonitor(db *database.DB, log *logger.Logger, configPath string, interval time.Duration) *MHVTLMonitor {
|
||||||
|
return &MHVTLMonitor{
|
||||||
|
service: NewService(db, log),
|
||||||
|
logger: log,
|
||||||
|
configPath: configPath,
|
||||||
|
interval: interval,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the MHVTL monitor background service
|
||||||
|
func (m *MHVTLMonitor) Start(ctx context.Context) {
|
||||||
|
m.logger.Info("Starting MHVTL monitor service", "config_path", m.configPath, "interval", m.interval)
|
||||||
|
ticker := time.NewTicker(m.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run initial sync immediately
|
||||||
|
m.syncMHVTL(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.logger.Info("MHVTL monitor service stopped")
|
||||||
|
return
|
||||||
|
case <-m.stopCh:
|
||||||
|
m.logger.Info("MHVTL monitor service stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.syncMHVTL(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the MHVTL monitor service
|
||||||
|
func (m *MHVTLMonitor) Stop() {
|
||||||
|
close(m.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncMHVTL parses mhvtl configuration and syncs to database
|
||||||
|
func (m *MHVTLMonitor) syncMHVTL(ctx context.Context) {
|
||||||
|
m.logger.Debug("Running MHVTL configuration sync")
|
||||||
|
|
||||||
|
deviceConfPath := filepath.Join(m.configPath, "device.conf")
|
||||||
|
if _, err := os.Stat(deviceConfPath); os.IsNotExist(err) {
|
||||||
|
m.logger.Warn("MHVTL device.conf not found", "path", deviceConfPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse device.conf to get libraries and drives
|
||||||
|
libraries, drives, err := m.parseDeviceConf(ctx, deviceConfPath)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Failed to parse device.conf", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Parsed MHVTL configuration", "libraries", len(libraries), "drives", len(drives))
|
||||||
|
|
||||||
|
// Sync libraries to database
|
||||||
|
for _, lib := range libraries {
|
||||||
|
if err := m.syncLibrary(ctx, lib); err != nil {
|
||||||
|
m.logger.Error("Failed to sync library", "library_id", lib.LibraryID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync drives to database
|
||||||
|
for _, drive := range drives {
|
||||||
|
if err := m.syncDrive(ctx, drive); err != nil {
|
||||||
|
m.logger.Error("Failed to sync drive", "drive_id", drive.DriveID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse library_contents files to get tapes
|
||||||
|
for _, lib := range libraries {
|
||||||
|
contentsPath := filepath.Join(m.configPath, fmt.Sprintf("library_contents.%d", lib.LibraryID))
|
||||||
|
if err := m.syncLibraryContents(ctx, lib.LibraryID, contentsPath); err != nil {
|
||||||
|
m.logger.Warn("Failed to sync library contents", "library_id", lib.LibraryID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Debug("MHVTL configuration sync completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryInfo represents a library from device.conf
|
||||||
|
type LibraryInfo struct {
|
||||||
|
LibraryID int
|
||||||
|
Vendor string
|
||||||
|
Product string
|
||||||
|
SerialNumber string
|
||||||
|
HomeDirectory string
|
||||||
|
Channel string
|
||||||
|
Target string
|
||||||
|
LUN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriveInfo represents a drive from device.conf
|
||||||
|
type DriveInfo struct {
|
||||||
|
DriveID int
|
||||||
|
LibraryID int
|
||||||
|
Slot int
|
||||||
|
Vendor string
|
||||||
|
Product string
|
||||||
|
SerialNumber string
|
||||||
|
Channel string
|
||||||
|
Target string
|
||||||
|
LUN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeviceConf parses mhvtl device.conf file
|
||||||
|
func (m *MHVTLMonitor) parseDeviceConf(ctx context.Context, path string) ([]LibraryInfo, []DriveInfo, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to open device.conf: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var libraries []LibraryInfo
|
||||||
|
var drives []DriveInfo
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
var currentLibrary *LibraryInfo
|
||||||
|
var currentDrive *DriveInfo
|
||||||
|
|
||||||
|
libraryRegex := regexp.MustCompile(`^Library:\s+(\d+)\s+CHANNEL:\s+(\S+)\s+TARGET:\s+(\S+)\s+LUN:\s+(\S+)`)
|
||||||
|
driveRegex := regexp.MustCompile(`^Drive:\s+(\d+)\s+CHANNEL:\s+(\S+)\s+TARGET:\s+(\S+)\s+LUN:\s+(\S+)`)
|
||||||
|
libraryIDRegex := regexp.MustCompile(`Library ID:\s+(\d+)\s+Slot:\s+(\d+)`)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if strings.HasPrefix(line, "#") || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Library entry
|
||||||
|
if matches := libraryRegex.FindStringSubmatch(line); matches != nil {
|
||||||
|
if currentLibrary != nil {
|
||||||
|
libraries = append(libraries, *currentLibrary)
|
||||||
|
}
|
||||||
|
libID, _ := strconv.Atoi(matches[1])
|
||||||
|
currentLibrary = &LibraryInfo{
|
||||||
|
LibraryID: libID,
|
||||||
|
Channel: matches[2],
|
||||||
|
Target: matches[3],
|
||||||
|
LUN: matches[4],
|
||||||
|
}
|
||||||
|
currentDrive = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Drive entry
|
||||||
|
if matches := driveRegex.FindStringSubmatch(line); matches != nil {
|
||||||
|
if currentDrive != nil {
|
||||||
|
drives = append(drives, *currentDrive)
|
||||||
|
}
|
||||||
|
driveID, _ := strconv.Atoi(matches[1])
|
||||||
|
currentDrive = &DriveInfo{
|
||||||
|
DriveID: driveID,
|
||||||
|
Channel: matches[2],
|
||||||
|
Target: matches[3],
|
||||||
|
LUN: matches[4],
|
||||||
|
}
|
||||||
|
if matches := libraryIDRegex.FindStringSubmatch(line); matches != nil {
|
||||||
|
libID, _ := strconv.Atoi(matches[1])
|
||||||
|
slot, _ := strconv.Atoi(matches[2])
|
||||||
|
currentDrive.LibraryID = libID
|
||||||
|
currentDrive.Slot = slot
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse library fields
|
||||||
|
if currentLibrary != nil {
|
||||||
|
if strings.HasPrefix(line, "Vendor identification:") {
|
||||||
|
currentLibrary.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:"))
|
||||||
|
} else if strings.HasPrefix(line, "Product identification:") {
|
||||||
|
currentLibrary.Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:"))
|
||||||
|
} else if strings.HasPrefix(line, "Unit serial number:") {
|
||||||
|
currentLibrary.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:"))
|
||||||
|
} else if strings.HasPrefix(line, "Home directory:") {
|
||||||
|
currentLibrary.HomeDirectory = strings.TrimSpace(strings.TrimPrefix(line, "Home directory:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse drive fields
|
||||||
|
if currentDrive != nil {
|
||||||
|
if strings.HasPrefix(line, "Vendor identification:") {
|
||||||
|
currentDrive.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:"))
|
||||||
|
} else if strings.HasPrefix(line, "Product identification:") {
|
||||||
|
currentDrive.Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:"))
|
||||||
|
} else if strings.HasPrefix(line, "Unit serial number:") {
|
||||||
|
currentDrive.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:"))
|
||||||
|
} else if strings.HasPrefix(line, "Library ID:") && strings.Contains(line, "Slot:") {
|
||||||
|
matches := libraryIDRegex.FindStringSubmatch(line)
|
||||||
|
if matches != nil {
|
||||||
|
libID, _ := strconv.Atoi(matches[1])
|
||||||
|
slot, _ := strconv.Atoi(matches[2])
|
||||||
|
currentDrive.LibraryID = libID
|
||||||
|
currentDrive.Slot = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last library and drive
|
||||||
|
if currentLibrary != nil {
|
||||||
|
libraries = append(libraries, *currentLibrary)
|
||||||
|
}
|
||||||
|
if currentDrive != nil {
|
||||||
|
drives = append(drives, *currentDrive)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("error reading device.conf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return libraries, drives, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLibrary syncs a library to database
|
||||||
|
func (m *MHVTLMonitor) syncLibrary(ctx context.Context, libInfo LibraryInfo) error {
|
||||||
|
// Check if library exists by mhvtl_library_id
|
||||||
|
var existingID string
|
||||||
|
err := m.service.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM virtual_tape_libraries WHERE mhvtl_library_id = $1",
|
||||||
|
libInfo.LibraryID,
|
||||||
|
).Scan(&existingID)
|
||||||
|
|
||||||
|
libraryName := fmt.Sprintf("VTL-%d", libInfo.LibraryID)
|
||||||
|
if libInfo.Product != "" {
|
||||||
|
libraryName = fmt.Sprintf("%s-%d", libInfo.Product, libInfo.LibraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Create new library
|
||||||
|
// Get backing store path from mhvtl.conf
|
||||||
|
backingStorePath := "/opt/mhvtl"
|
||||||
|
if libInfo.HomeDirectory != "" {
|
||||||
|
backingStorePath = libInfo.HomeDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count slots and drives from library_contents file
|
||||||
|
contentsPath := filepath.Join(m.configPath, fmt.Sprintf("library_contents.%d", libInfo.LibraryID))
|
||||||
|
slotCount, driveCount := m.countSlotsAndDrives(contentsPath)
|
||||||
|
|
||||||
|
_, err = m.service.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO virtual_tape_libraries (
|
||||||
|
name, description, mhvtl_library_id, backing_store_path,
|
||||||
|
slot_count, drive_count, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, libraryName, fmt.Sprintf("MHVTL Library %d (%s)", libInfo.LibraryID, libInfo.Product),
|
||||||
|
libInfo.LibraryID, backingStorePath, slotCount, driveCount, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert library: %w", err)
|
||||||
|
}
|
||||||
|
m.logger.Info("Created virtual library from MHVTL", "library_id", libInfo.LibraryID, "name", libraryName)
|
||||||
|
} else if err == nil {
|
||||||
|
// Update existing library
|
||||||
|
_, err = m.service.db.ExecContext(ctx, `
|
||||||
|
UPDATE virtual_tape_libraries SET
|
||||||
|
name = $1, description = $2, backing_store_path = $3,
|
||||||
|
is_active = $4, updated_at = NOW()
|
||||||
|
WHERE id = $5
|
||||||
|
`, libraryName, fmt.Sprintf("MHVTL Library %d (%s)", libInfo.LibraryID, libInfo.Product),
|
||||||
|
libInfo.HomeDirectory, true, existingID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update library: %w", err)
|
||||||
|
}
|
||||||
|
m.logger.Debug("Updated virtual library from MHVTL", "library_id", libInfo.LibraryID)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to check library existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDrive syncs a drive to database
|
||||||
|
func (m *MHVTLMonitor) syncDrive(ctx context.Context, driveInfo DriveInfo) error {
|
||||||
|
// Get library ID from mhvtl_library_id
|
||||||
|
var libraryID string
|
||||||
|
err := m.service.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM virtual_tape_libraries WHERE mhvtl_library_id = $1",
|
||||||
|
driveInfo.LibraryID,
|
||||||
|
).Scan(&libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("library not found for drive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate drive number from slot (drives are typically in slots 1, 2, 3, etc.)
|
||||||
|
driveNumber := driveInfo.Slot
|
||||||
|
|
||||||
|
// Check if drive exists
|
||||||
|
var existingID string
|
||||||
|
err = m.service.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM virtual_tape_drives WHERE library_id = $1 AND drive_number = $2",
|
||||||
|
libraryID, driveNumber,
|
||||||
|
).Scan(&existingID)
|
||||||
|
|
||||||
|
// Get device path (typically /dev/stX or /dev/nstX)
|
||||||
|
devicePath := fmt.Sprintf("/dev/st%d", driveInfo.DriveID-10) // Drive 11 -> st1, Drive 12 -> st2, etc.
|
||||||
|
stablePath := fmt.Sprintf("/dev/tape/by-id/scsi-%s", driveInfo.SerialNumber)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Create new drive
|
||||||
|
_, err = m.service.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO virtual_tape_drives (
|
||||||
|
library_id, drive_number, device_path, stable_path, status, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`, libraryID, driveNumber, devicePath, stablePath, "idle", true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert drive: %w", err)
|
||||||
|
}
|
||||||
|
m.logger.Info("Created virtual drive from MHVTL", "drive_id", driveInfo.DriveID, "library_id", driveInfo.LibraryID)
|
||||||
|
} else if err == nil {
|
||||||
|
// Update existing drive
|
||||||
|
_, err = m.service.db.ExecContext(ctx, `
|
||||||
|
UPDATE virtual_tape_drives SET
|
||||||
|
device_path = $1, stable_path = $2, is_active = $3, updated_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, devicePath, stablePath, true, existingID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update drive: %w", err)
|
||||||
|
}
|
||||||
|
m.logger.Debug("Updated virtual drive from MHVTL", "drive_id", driveInfo.DriveID)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to check drive existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLibraryContents syncs tapes from library_contents file
|
||||||
|
func (m *MHVTLMonitor) syncLibraryContents(ctx context.Context, libraryID int, contentsPath string) error {
|
||||||
|
// Get library ID from database
|
||||||
|
var dbLibraryID string
|
||||||
|
err := m.service.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM virtual_tape_libraries WHERE mhvtl_library_id = $1",
|
||||||
|
libraryID,
|
||||||
|
).Scan(&dbLibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("library not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backing store path
|
||||||
|
var backingStorePath string
|
||||||
|
err = m.service.db.QueryRowContext(ctx,
|
||||||
|
"SELECT backing_store_path FROM virtual_tape_libraries WHERE id = $1",
|
||||||
|
dbLibraryID,
|
||||||
|
).Scan(&backingStorePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get backing store path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(contentsPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open library_contents file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
slotRegex := regexp.MustCompile(`^Slot\s+(\d+):\s+(.+)`)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if strings.HasPrefix(line, "#") || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := slotRegex.FindStringSubmatch(line)
|
||||||
|
if matches != nil {
|
||||||
|
slotNumber, _ := strconv.Atoi(matches[1])
|
||||||
|
barcode := strings.TrimSpace(matches[2])
|
||||||
|
|
||||||
|
if barcode == "" || barcode == "?" {
|
||||||
|
continue // Empty slot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine tape type from barcode suffix
|
||||||
|
tapeType := "LTO-8" // Default
|
||||||
|
if len(barcode) >= 2 {
|
||||||
|
suffix := barcode[len(barcode)-2:]
|
||||||
|
switch suffix {
|
||||||
|
case "L1":
|
||||||
|
tapeType = "LTO-1"
|
||||||
|
case "L2":
|
||||||
|
tapeType = "LTO-2"
|
||||||
|
case "L3":
|
||||||
|
tapeType = "LTO-3"
|
||||||
|
case "L4":
|
||||||
|
tapeType = "LTO-4"
|
||||||
|
case "L5":
|
||||||
|
tapeType = "LTO-5"
|
||||||
|
case "L6":
|
||||||
|
tapeType = "LTO-6"
|
||||||
|
case "L7":
|
||||||
|
tapeType = "LTO-7"
|
||||||
|
case "L8":
|
||||||
|
tapeType = "LTO-8"
|
||||||
|
case "L9":
|
||||||
|
tapeType = "LTO-9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tape exists
|
||||||
|
var existingID string
|
||||||
|
err := m.service.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM virtual_tapes WHERE library_id = $1 AND barcode = $2",
|
||||||
|
dbLibraryID, barcode,
|
||||||
|
).Scan(&existingID)
|
||||||
|
|
||||||
|
imagePath := filepath.Join(backingStorePath, "tapes", fmt.Sprintf("%s.img", barcode))
|
||||||
|
defaultSize := int64(15 * 1024 * 1024 * 1024 * 1024) // 15 TB default for LTO-8
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Create new tape
|
||||||
|
_, err = m.service.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO virtual_tapes (
|
||||||
|
library_id, barcode, slot_number, image_file_path,
|
||||||
|
size_bytes, used_bytes, tape_type, status
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
`, dbLibraryID, barcode, slotNumber, imagePath, defaultSize, 0, tapeType, "idle")
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to insert tape", "barcode", barcode, "error", err)
|
||||||
|
} else {
|
||||||
|
m.logger.Debug("Created virtual tape from MHVTL", "barcode", barcode, "slot", slotNumber)
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
// Update existing tape slot
|
||||||
|
_, err = m.service.db.ExecContext(ctx, `
|
||||||
|
UPDATE virtual_tapes SET
|
||||||
|
slot_number = $1, tape_type = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
`, slotNumber, tapeType, existingID)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to update tape", "barcode", barcode, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// countSlotsAndDrives counts slots and drives from library_contents file
|
||||||
|
func (m *MHVTLMonitor) countSlotsAndDrives(contentsPath string) (slotCount, driveCount int) {
|
||||||
|
file, err := os.Open(contentsPath)
|
||||||
|
if err != nil {
|
||||||
|
return 10, 2 // Default values
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
slotRegex := regexp.MustCompile(`^Slot\s+(\d+):`)
|
||||||
|
driveRegex := regexp.MustCompile(`^Drive\s+(\d+):`)
|
||||||
|
|
||||||
|
maxSlot := 0
|
||||||
|
driveCount = 0
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if strings.HasPrefix(line, "#") || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches := slotRegex.FindStringSubmatch(line); matches != nil {
|
||||||
|
slot, _ := strconv.Atoi(matches[1])
|
||||||
|
if slot > maxSlot {
|
||||||
|
maxSlot = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches := driveRegex.FindStringSubmatch(line); matches != nil {
|
||||||
|
driveCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slotCount = maxSlot
|
||||||
|
if slotCount == 0 {
|
||||||
|
slotCount = 10 // Default
|
||||||
|
}
|
||||||
|
if driveCount == 0 {
|
||||||
|
driveCount = 2 // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
return slotCount, driveCount
|
||||||
|
}
|
||||||
@@ -186,6 +186,8 @@ export default function StoragePage() {
|
|||||||
const { data: zfsPools = [], isLoading: poolsLoading } = useQuery({
|
const { data: zfsPools = [], isLoading: poolsLoading } = useQuery({
|
||||||
queryKey: ['storage', 'zfs', 'pools'],
|
queryKey: ['storage', 'zfs', 'pools'],
|
||||||
queryFn: zfsApi.listPools,
|
queryFn: zfsApi.listPools,
|
||||||
|
refetchInterval: 2000, // Auto-refresh every 2 seconds
|
||||||
|
staleTime: 0, // Always consider data stale
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch ARC stats with auto-refresh every 2 seconds for live data
|
// Fetch ARC stats with auto-refresh every 2 seconds for live data
|
||||||
@@ -199,8 +201,16 @@ export default function StoragePage() {
|
|||||||
|
|
||||||
const syncDisksMutation = useMutation({
|
const syncDisksMutation = useMutation({
|
||||||
mutationFn: storageApi.syncDisks,
|
mutationFn: storageApi.syncDisks,
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage'] })
|
// Invalidate and refetch disks
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['storage', 'disks'] })
|
||||||
|
// Refetch disks immediately
|
||||||
|
queryClient.refetchQueries({ queryKey: ['storage', 'disks'] })
|
||||||
|
alert('Disk rescan completed!')
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to rescan disks:', error)
|
||||||
|
alert(error.response?.data?.error || 'Failed to rescan disks')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,6 +252,20 @@ export default function StoragePage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deletePoolMutation = useMutation({
|
||||||
|
mutationFn: (poolId: string) => zfsApi.deletePool(poolId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['storage', 'zfs', 'pools'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['storage', 'disks'] })
|
||||||
|
setSelectedPool(null)
|
||||||
|
alert('Pool destroyed successfully!')
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to delete pool:', error)
|
||||||
|
alert(error.response?.data?.error || 'Failed to destroy pool')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const createDatasetMutation = useMutation({
|
const createDatasetMutation = useMutation({
|
||||||
mutationFn: ({ poolId, data }: { poolId: string; data: any }) =>
|
mutationFn: ({ poolId, data }: { poolId: string; data: any }) =>
|
||||||
zfsApi.createDataset(poolId, data),
|
zfsApi.createDataset(poolId, data),
|
||||||
@@ -376,8 +400,10 @@ export default function StoragePage() {
|
|||||||
disabled={syncDisksMutation.isPending}
|
disabled={syncDisksMutation.isPending}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border-dark bg-card-dark text-white text-sm font-bold hover:bg-[#233648] transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border-dark bg-card-dark text-white text-sm font-bold hover:bg-[#233648] transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[20px]">refresh</span>
|
<span className={`material-symbols-outlined text-[20px] ${syncDisksMutation.isPending ? 'animate-spin' : ''}`}>
|
||||||
Rescan Disks
|
refresh
|
||||||
|
</span>
|
||||||
|
{syncDisksMutation.isPending ? 'Rescanning...' : 'Rescan Disks'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
@@ -923,9 +949,19 @@ export default function StoragePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 pt-0 bg-[#1e2832]">
|
<div className="p-6 pt-0 bg-[#1e2832]">
|
||||||
<button className="w-full px-4 py-2 border border-red-500/30 text-red-500 hover:bg-red-500/10 text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2">
|
<button
|
||||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
onClick={() => {
|
||||||
Export / Destroy Pool
|
if (selectedPool && confirm(`Are you sure you want to destroy pool "${selectedPool.name}"? This action cannot be undone and will delete all data in the pool.`)) {
|
||||||
|
deletePoolMutation.mutate(selectedPool.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deletePoolMutation.isPending || !selectedPool}
|
||||||
|
className="w-full px-4 py-2 border border-red-500/30 text-red-500 hover:bg-red-500/10 text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">
|
||||||
|
{deletePoolMutation.isPending ? 'hourglass_empty' : 'delete'}
|
||||||
|
</span>
|
||||||
|
{deletePoolMutation.isPending ? 'Destroying...' : 'Export / Destroy Pool'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,190 +1,537 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { physicalTapeAPI, vtlAPI, type PhysicalTapeLibrary, type VirtualTapeLibrary } from '@/api/tape'
|
import { physicalTapeAPI, vtlAPI, type PhysicalTapeLibrary, type VirtualTapeLibrary, type VirtualTape } from '@/api/tape'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { HardDrive, Plus, RefreshCw } from 'lucide-react'
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { formatBytes } from '@/lib/format'
|
||||||
|
|
||||||
export default function TapeLibraries() {
|
export default function TapeLibraries() {
|
||||||
const [activeTab, setActiveTab] = useState<'physical' | 'vtl'>('vtl')
|
const [activeTab, setActiveTab] = useState<'physical' | 'vtl'>('vtl')
|
||||||
|
const [selectedLibrary, setSelectedLibrary] = useState<string | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: physicalLibraries, isLoading: loadingPhysical } = useQuery<PhysicalTapeLibrary[]>({
|
const { data: physicalLibraries = [], isLoading: loadingPhysical } = useQuery<PhysicalTapeLibrary[]>({
|
||||||
queryKey: ['physical-tape-libraries'],
|
queryKey: ['physical-tape-libraries'],
|
||||||
queryFn: physicalTapeAPI.listLibraries,
|
queryFn: physicalTapeAPI.listLibraries,
|
||||||
enabled: activeTab === 'physical',
|
enabled: activeTab === 'physical',
|
||||||
|
refetchInterval: 5000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: vtlLibraries, isLoading: loadingVTL } = useQuery<VirtualTapeLibrary[]>({
|
const { data: vtlLibraries = [], isLoading: loadingVTL } = useQuery<VirtualTapeLibrary[]>({
|
||||||
queryKey: ['vtl-libraries'],
|
queryKey: ['vtl-libraries'],
|
||||||
queryFn: vtlAPI.listLibraries,
|
queryFn: vtlAPI.listLibraries,
|
||||||
enabled: activeTab === 'vtl',
|
enabled: activeTab === 'vtl',
|
||||||
|
refetchInterval: 5000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get tapes for selected library
|
||||||
|
const { data: libraryTapes = [] } = useQuery<VirtualTape[]>({
|
||||||
|
queryKey: ['vtl-library-tapes', selectedLibrary],
|
||||||
|
queryFn: () => vtlAPI.getLibraryTapes(selectedLibrary!),
|
||||||
|
enabled: !!selectedLibrary && activeTab === 'vtl',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const totalLibraries = activeTab === 'vtl' ? vtlLibraries.length : physicalLibraries.length
|
||||||
|
const onlineLibraries = activeTab === 'vtl'
|
||||||
|
? vtlLibraries.filter(l => l.is_active).length
|
||||||
|
: physicalLibraries.filter(l => l.is_active).length
|
||||||
|
|
||||||
|
const totalSlots = activeTab === 'vtl'
|
||||||
|
? vtlLibraries.reduce((sum, l) => sum + l.slot_count, 0)
|
||||||
|
: physicalLibraries.reduce((sum, l) => sum + l.slot_count, 0)
|
||||||
|
|
||||||
|
const usedSlots = libraryTapes.filter(t => t.slot_number > 0).length
|
||||||
|
|
||||||
|
// Filter libraries by search
|
||||||
|
const filteredLibraries = (activeTab === 'vtl' ? vtlLibraries : physicalLibraries).filter(lib =>
|
||||||
|
lib.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(activeTab === 'vtl' && 'mhvtl_library_id' in lib && lib.mhvtl_library_id.toString().includes(searchQuery))
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: activeTab === 'vtl' ? ['vtl-libraries'] : ['physical-tape-libraries'] })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
||||||
<div className="flex items-center justify-between">
|
{/* Top Header & Breadcrumbs */}
|
||||||
<div>
|
<header className="flex-none px-6 py-5 border-b border-border-dark bg-[#111a22]/95 backdrop-blur z-10">
|
||||||
<h1 className="text-3xl font-bold text-white">Tape Libraries</h1>
|
<div className="max-w-[1400px] mx-auto w-full flex flex-col gap-4">
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
{/* Breadcrumbs */}
|
||||||
Manage physical and virtual tape libraries
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
</p>
|
<Link to="/" className="text-text-secondary text-sm font-medium hover:text-primary transition-colors">
|
||||||
</div>
|
Home
|
||||||
<div className="flex gap-2">
|
|
||||||
{activeTab === 'vtl' && (
|
|
||||||
<Link to="/tape/vtl/create">
|
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Create VTL
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
<span className="text-text-secondary text-xs">/</span>
|
||||||
{activeTab === 'physical' && (
|
<span className="text-white text-sm font-medium">Virtual Tape Libraries</span>
|
||||||
<Button variant="outline">
|
</div>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Discover Libraries
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Page Heading & Actions */}
|
||||||
<div className="border-b border-border-dark">
|
<div className="flex flex-wrap justify-between items-end gap-4">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<h2 className="text-white text-3xl font-bold tracking-tight">Virtual Tape Libraries</h2>
|
||||||
onClick={() => setActiveTab('vtl')}
|
<p className="text-text-secondary text-base max-w-2xl">
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
Manage virtual tape devices, emulation profiles, and storage targets.
|
||||||
activeTab === 'vtl'
|
</p>
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Virtual Tape Libraries ({vtlLibraries?.length || 0})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('physical')}
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'physical'
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Physical Libraries ({physicalLibraries?.length || 0})
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{activeTab === 'vtl' && (
|
|
||||||
<div>
|
|
||||||
{loadingVTL ? (
|
|
||||||
<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) => (
|
|
||||||
<LibraryCard key={library.id} library={library} type="vtl" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex gap-3">
|
||||||
<Card>
|
<button
|
||||||
<CardContent className="p-12 text-center">
|
onClick={handleRefresh}
|
||||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
className="px-4 py-2 bg-surface-dark hover:bg-[#2a3b4d] border border-border-dark rounded-lg text-white text-sm font-medium flex items-center gap-2 transition-all"
|
||||||
<h3 className="text-lg font-medium text-white mb-2">No Virtual Tape Libraries</h3>
|
>
|
||||||
<p className="text-sm text-text-secondary mb-4">
|
<span className="material-symbols-outlined text-lg">refresh</span>
|
||||||
Create your first virtual tape library to get started
|
Refresh
|
||||||
</p>
|
</button>
|
||||||
<Link to="/tape/vtl/create">
|
{activeTab === 'vtl' && (
|
||||||
<Button>
|
<Link
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
to="/tape/vtl/create"
|
||||||
Create VTL Library
|
className="px-4 py-2 bg-primary hover:bg-blue-600 rounded-lg text-white text-sm font-bold shadow-lg shadow-blue-900/20 flex items-center gap-2 transition-all"
|
||||||
</Button>
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
|
Create VTL
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</header>
|
||||||
|
|
||||||
{activeTab === 'physical' && (
|
{/* Scrollable Content */}
|
||||||
<div>
|
<div className="flex-1 overflow-y-auto bg-background-dark">
|
||||||
{loadingPhysical ? (
|
<div className="max-w-[1400px] mx-auto p-6 flex flex-col gap-6 pb-20">
|
||||||
<p className="text-sm text-text-secondary">Loading physical libraries...</p>
|
{/* Tabs */}
|
||||||
) : physicalLibraries && physicalLibraries.length > 0 ? (
|
<div className="border-b border-border-dark">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<nav className="-mb-px flex space-x-8">
|
||||||
{physicalLibraries.map((library: PhysicalTapeLibrary) => (
|
<button
|
||||||
<LibraryCard key={library.id} library={library} type="physical" />
|
onClick={() => {
|
||||||
|
setActiveTab('vtl')
|
||||||
|
setSelectedLibrary(null)
|
||||||
|
}}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'vtl'
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Virtual Tape Libraries ({vtlLibraries.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('physical')
|
||||||
|
setSelectedLibrary(null)
|
||||||
|
}}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'physical'
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Physical Libraries ({physicalLibraries.length})
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Stat Card 1 */}
|
||||||
|
<div className="flex flex-col gap-1 p-5 rounded-xl border border-border-dark bg-surface-dark relative overflow-hidden group">
|
||||||
|
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<span className="material-symbols-outlined text-6xl text-white">dns</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm font-medium">Total Libraries</p>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<p className="text-white text-3xl font-bold tracking-tight">{totalLibraries}</p>
|
||||||
|
<span className="text-green-500 text-xs font-medium mb-1.5 flex items-center">
|
||||||
|
<span className="material-symbols-outlined text-sm mr-0.5">check_circle</span>
|
||||||
|
{onlineLibraries === totalLibraries ? 'All Online' : `${onlineLibraries} Online`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat Card 2 */}
|
||||||
|
<div className="flex flex-col gap-1 p-5 rounded-xl border border-border-dark bg-surface-dark relative overflow-hidden group">
|
||||||
|
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<span className="material-symbols-outlined text-6xl text-white">database</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm font-medium">Total Capacity</p>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<p className="text-white text-3xl font-bold tracking-tight">
|
||||||
|
{formatBytes(
|
||||||
|
activeTab === 'vtl'
|
||||||
|
? vtlLibraries.reduce((sum, l) => sum + (l.slot_count * 15 * 1024 * 1024 * 1024 * 1024), 0)
|
||||||
|
: 0,
|
||||||
|
1
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat Card 3 */}
|
||||||
|
<div className="flex flex-col gap-1 p-5 rounded-xl border border-border-dark bg-surface-dark relative overflow-hidden group">
|
||||||
|
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<span className="material-symbols-outlined text-6xl text-white">album</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm font-medium">Tapes Online</p>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<p className="text-white text-3xl font-bold tracking-tight">{usedSlots}</p>
|
||||||
|
<span className="text-text-secondary text-xs font-medium mb-1.5">/ {totalSlots} Slots</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat Card 4 */}
|
||||||
|
<div className="flex flex-col gap-1 p-5 rounded-xl border border-border-dark bg-surface-dark relative overflow-hidden group">
|
||||||
|
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<span className="material-symbols-outlined text-6xl text-white">swap_horiz</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm font-medium">Active Sessions</p>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<p className="text-white text-3xl font-bold tracking-tight">
|
||||||
|
{activeTab === 'vtl'
|
||||||
|
? vtlLibraries.reduce((sum, l) => sum + l.drive_count, 0)
|
||||||
|
: physicalLibraries.reduce((sum, l) => sum + l.drive_count, 0)}
|
||||||
|
</p>
|
||||||
|
<span className="text-blue-400 text-xs font-medium mb-1.5">Drives</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Progress Section */}
|
||||||
|
<div className="p-5 rounded-xl bg-surface-dark border border-border-dark flex flex-col gap-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-text-secondary">pie_chart</span>
|
||||||
|
<h3 className="text-white text-sm font-semibold">VTL Partition Usage (ZFS Pool)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-white text-sm font-medium">
|
||||||
|
{formatBytes(usedSlots * 15 * 1024 * 1024 * 1024 * 1024, 1)} /{' '}
|
||||||
|
{formatBytes(totalSlots * 15 * 1024 * 1024 * 1024 * 1024, 1)} Used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[#111a22] rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-primary h-3 rounded-full"
|
||||||
|
style={{ width: `${totalSlots > 0 ? (usedSlots / totalSlots) * 100 : 0}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs text-text-secondary">
|
||||||
|
<span>
|
||||||
|
Compression Ratio: <span className="text-white font-mono">1.5x</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500"></span> Pool Healthy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Data Table Section */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="p-4 border-b border-border-dark flex flex-col sm:flex-row gap-4 justify-between items-center bg-[#1c2834]">
|
||||||
|
<div className="relative w-full sm:w-96">
|
||||||
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-lg">
|
||||||
|
search
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="w-full bg-[#111a22] border border-border-dark text-white text-sm rounded-lg pl-10 pr-4 py-2 focus:ring-1 focus:ring-primary focus:border-primary placeholder-gray-600 outline-none transition-all"
|
||||||
|
placeholder="Search libraries by name or ID..."
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
|
<button className="px-3 py-2 bg-[#111a22] hover:bg-[#1a2632] border border-border-dark rounded-lg text-text-secondary text-sm font-medium flex items-center gap-2 transition-colors">
|
||||||
|
<span className="material-symbols-outlined text-lg">filter_list</span>
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-2 bg-[#111a22] hover:bg-[#1a2632] border border-border-dark rounded-lg text-text-secondary text-sm font-medium flex items-center gap-2 transition-colors">
|
||||||
|
<span className="material-symbols-outlined text-lg">settings</span>
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loadingVTL || loadingPhysical ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-text-secondary">Loading libraries...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredLibraries.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<span className="material-symbols-outlined text-6xl text-text-secondary mb-4 block">database</span>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
No {activeTab === 'vtl' ? 'Virtual' : 'Physical'} Tape Libraries
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-secondary mb-4">
|
||||||
|
{activeTab === 'vtl'
|
||||||
|
? 'Create your first virtual tape library to get started'
|
||||||
|
: 'Discover physical tape libraries connected to the system'}
|
||||||
|
</p>
|
||||||
|
{activeTab === 'vtl' && (
|
||||||
|
<Link
|
||||||
|
to="/tape/vtl/create"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-blue-600 rounded-lg text-white text-sm font-bold"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
|
Create VTL Library
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#151e29] border-b border-border-dark">
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary w-12">
|
||||||
|
<input
|
||||||
|
className="rounded border-border-dark bg-[#111a22] text-primary focus:ring-offset-background-dark focus:ring-primary h-4 w-4"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||||
|
Library Name
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||||
|
Emulation
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||||
|
Tapes / Slots
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||||
|
iSCSI Target
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-6 text-xs font-semibold uppercase tracking-wider text-text-secondary text-right">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-dark">
|
||||||
|
{filteredLibraries.map((library) => {
|
||||||
|
const isVTL = activeTab === 'vtl'
|
||||||
|
const libraryId = isVTL ? (library as VirtualTapeLibrary).mhvtl_library_id : library.id
|
||||||
|
const status = library.is_active ? 'Ready' : 'Offline'
|
||||||
|
const statusColor = library.is_active ? 'green' : 'gray'
|
||||||
|
const tapesCount = isVTL && selectedLibrary === library.id ? libraryTapes.length : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={library.id}
|
||||||
|
className="group hover:bg-[#233342] transition-colors cursor-pointer"
|
||||||
|
onClick={() => isVTL && setSelectedLibrary(selectedLibrary === library.id ? null : library.id)}
|
||||||
|
>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<input
|
||||||
|
className="rounded border-border-dark bg-[#111a22] text-primary focus:ring-offset-background-dark focus:ring-primary h-4 w-4"
|
||||||
|
type="checkbox"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`size-8 rounded flex items-center justify-center ${
|
||||||
|
library.is_active
|
||||||
|
? 'bg-blue-900/30 text-primary'
|
||||||
|
: 'bg-gray-700/30 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">shelves</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm font-bold">{library.name}</p>
|
||||||
|
<p className="text-text-secondary text-xs">ID: {libraryId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${
|
||||||
|
statusColor === 'green'
|
||||||
|
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||||
|
: 'bg-gray-500/10 text-gray-400 border-gray-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusColor === 'green' && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||||
|
)}
|
||||||
|
{statusColor === 'gray' && <span className="w-1.5 h-1.5 rounded-full bg-gray-500"></span>}
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<p className="text-white text-sm font-medium">
|
||||||
|
{isVTL ? 'MHVTL' : 'physical' in library ? (library as PhysicalTapeLibrary).vendor : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-text-secondary text-xs">
|
||||||
|
LTO-8 • {library.drive_count} {library.drive_count === 1 ? 'Drive' : 'Drives'}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-text-secondary text-lg">album</span>
|
||||||
|
<span className="text-white text-sm">
|
||||||
|
{tapesCount || 0} / {library.slot_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 bg-[#111a22] rounded-full h-1 mt-1.5">
|
||||||
|
<div
|
||||||
|
className={`h-1 rounded-full ${
|
||||||
|
library.is_active ? 'bg-primary' : 'bg-gray-500'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${library.slot_count > 0 ? ((tapesCount || 0) / library.slot_count) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<div className="flex items-center gap-2 group/copy cursor-pointer">
|
||||||
|
<code className="text-xs text-text-secondary font-mono bg-[#111a22] px-2 py-1 rounded border border-border-dark group-hover/copy:text-white transition-colors">
|
||||||
|
iqn.2023-10.com.vtl:{library.name.toLowerCase().replace(/\s+/g, '')}
|
||||||
|
</code>
|
||||||
|
<span className="material-symbols-outlined text-text-secondary text-sm opacity-0 group-hover/copy:opacity-100 transition-opacity">
|
||||||
|
content_copy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link
|
||||||
|
to={isVTL ? `/tape/vtl/${library.id}` : `/tape/physical/${library.id}`}
|
||||||
|
className="p-2 text-text-secondary hover:text-white hover:bg-[#324d67] rounded-lg transition-colors"
|
||||||
|
title="Manage Tapes"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">cable</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="p-2 text-text-secondary hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||||
|
title="Edit Configuration"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">edit</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 text-text-secondary hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||||
|
title="Delete Library"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="px-6 py-4 border-t border-border-dark flex items-center justify-between bg-[#1c2834]">
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
Showing <span className="text-white font-medium">1-{filteredLibraries.length}</span> of{' '}
|
||||||
|
<span className="text-white font-medium">{filteredLibraries.length}</span> libraries
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-border-dark bg-[#111a22] text-text-secondary text-sm hover:text-white disabled:opacity-50"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-border-dark bg-[#111a22] text-text-secondary text-sm hover:text-white disabled:opacity-50"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tape Detail Drawer */}
|
||||||
|
{selectedLibrary && activeTab === 'vtl' && libraryTapes.length > 0 && (
|
||||||
|
<div className="bg-surface-dark border-t border-border-dark p-6 absolute bottom-0 w-full transform translate-y-0 transition-transform z-30 shadow-2xl shadow-black">
|
||||||
|
<div className="max-w-[1400px] mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-primary text-2xl">cable</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white text-lg font-bold">
|
||||||
|
Tape Management: {vtlLibraries.find((l) => l.id === selectedLibrary)?.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
Manage virtual cartridges, import/export slots, and barcodes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="px-3 py-2 bg-[#111a22] border border-border-dark rounded-lg text-text-secondary hover:text-white text-sm font-medium transition-colors">
|
||||||
|
Bulk Format
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/tape/vtl/${selectedLibrary}/tapes/create`}
|
||||||
|
className="px-3 py-2 bg-primary hover:bg-blue-600 rounded-lg text-white text-sm font-bold transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
|
Add Tapes
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLibrary(null)}
|
||||||
|
className="lg:hidden p-2 text-text-secondary hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{libraryTapes.map((tape) => (
|
||||||
|
<div
|
||||||
|
key={tape.id}
|
||||||
|
className={`p-3 rounded border flex flex-col gap-2 relative group hover:border-primary transition-colors cursor-pointer ${
|
||||||
|
tape.status === 'in_drive'
|
||||||
|
? 'bg-[#111a22] border-green-500/30'
|
||||||
|
: 'bg-[#111a22] border-border-dark'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined text-xl ${
|
||||||
|
tape.status === 'in_drive' ? 'text-green-500' : 'text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
album
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase font-bold text-text-secondary bg-[#1c2834] px-1 rounded">
|
||||||
|
Slot {tape.slot_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-xs font-mono font-bold">{tape.barcode}</p>
|
||||||
|
<p className="text-text-secondary text-[10px]">
|
||||||
|
{formatBytes(tape.size_bytes, 1)} / {formatBytes(tape.size_bytes, 1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/60 hidden group-hover:flex items-center justify-center gap-2 backdrop-blur-[1px] rounded">
|
||||||
|
<span className="material-symbols-outlined text-white hover:text-primary text-lg">eject</span>
|
||||||
|
<span className="material-symbols-outlined text-white hover:text-red-400 text-lg">delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<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-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">
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Discover Libraries
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LibraryCardProps {
|
|
||||||
library: PhysicalTapeLibrary | VirtualTapeLibrary
|
|
||||||
type: 'physical' | 'vtl'
|
|
||||||
}
|
|
||||||
|
|
||||||
function LibraryCard({ library, type }: LibraryCardProps) {
|
|
||||||
const isPhysical = type === 'physical'
|
|
||||||
const libraryPath = isPhysical ? `/tape/physical/${library.id}` : `/tape/vtl/${library.id}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={libraryPath}>
|
|
||||||
<Card className="hover:border-blue-500 transition-colors">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">{library.name}</CardTitle>
|
|
||||||
{library.is_active ? (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isPhysical && 'serial_number' in library && (
|
|
||||||
<CardDescription>Serial: {library.serial_number}</CardDescription>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<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-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-text-secondary">Vendor:</span>
|
|
||||||
<span className="font-medium text-white">{library.vendor} {library.model}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user