development #2
Binary file not shown.
Binary file not shown.
@@ -51,6 +51,13 @@ func cacheMiddleware(cfg CacheConfig, cache *cache.Cache) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't cache VTL endpoints - they change frequently
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if strings.HasPrefix(path, "/api/v1/tape/vtl/") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate cache key from request path and query string
|
// Generate cache key from request path and query string
|
||||||
keyParts := []string{c.Request.URL.Path}
|
keyParts := []string{c.Request.URL.Path}
|
||||||
if c.Request.URL.RawQuery != "" {
|
if c.Request.URL.RawQuery != "" {
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
|||||||
systemGroup.POST("/services/:name/restart", systemHandler.RestartService)
|
systemGroup.POST("/services/:name/restart", systemHandler.RestartService)
|
||||||
systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs)
|
systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs)
|
||||||
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
||||||
|
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IAM (admin only)
|
// IAM (admin only)
|
||||||
|
|||||||
@@ -304,6 +304,13 @@ func (h *Handler) DeleteZFSPool(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for pools list
|
||||||
|
if h.cache != nil {
|
||||||
|
cacheKey := "http:/api/v1/storage/zfs/pools:"
|
||||||
|
h.cache.Delete(cacheKey)
|
||||||
|
h.logger.Debug("Cache invalidated for pools list", "key", cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ZFS pool deleted successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "ZFS pool deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ type ZFSPool struct {
|
|||||||
AutoExpand bool `json:"auto_expand"`
|
AutoExpand bool `json:"auto_expand"`
|
||||||
ScrubInterval int `json:"scrub_interval"` // days
|
ScrubInterval int `json:"scrub_interval"` // days
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
HealthStatus string `json:"health_status"` // online, degraded, faulted, offline
|
HealthStatus string `json:"health_status"` // online, degraded, faulted, offline
|
||||||
|
CompressRatio float64 `json:"compress_ratio"` // compression ratio (e.g., 1.45x)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
@@ -359,6 +361,26 @@ func (s *ZFSService) getSpareDisks(ctx context.Context, poolName string) ([]stri
|
|||||||
return spareDisks, nil
|
return spareDisks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCompressRatio gets the compression ratio from ZFS
|
||||||
|
func (s *ZFSService) getCompressRatio(ctx context.Context, poolName string) (float64, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "zfs", "get", "-H", "-o", "value", "compressratio", poolName)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 1.0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ratioStr := strings.TrimSpace(string(output))
|
||||||
|
// Remove 'x' suffix if present (e.g., "1.45x" -> "1.45")
|
||||||
|
ratioStr = strings.TrimSuffix(ratioStr, "x")
|
||||||
|
|
||||||
|
ratio, err := strconv.ParseFloat(ratioStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 1.0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratio, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListPools lists all ZFS pools
|
// ListPools lists all ZFS pools
|
||||||
func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) {
|
func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) {
|
||||||
query := `
|
query := `
|
||||||
@@ -407,8 +429,17 @@ func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) {
|
|||||||
pool.SpareDisks = spareDisks
|
pool.SpareDisks = spareDisks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get compressratio from ZFS system
|
||||||
|
compressRatio, err := s.getCompressRatio(ctx, pool.Name)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to get compressratio", "pool", pool.Name, "error", err)
|
||||||
|
pool.CompressRatio = 1.0 // Default to 1.0 if can't get ratio
|
||||||
|
} else {
|
||||||
|
pool.CompressRatio = compressRatio
|
||||||
|
}
|
||||||
|
|
||||||
pools = append(pools, &pool)
|
pools = append(pools, &pool)
|
||||||
s.logger.Debug("Added pool to list", "pool_id", pool.ID, "name", pool.Name)
|
s.logger.Debug("Added pool to list", "pool_id", pool.ID, "name", pool.Name, "compressratio", pool.CompressRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ func (m *ZFSPoolMonitor) updatePoolStatus(ctx context.Context, poolName string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// markMissingPoolsOffline marks pools that exist in database but not in system as offline
|
// markMissingPoolsOffline marks pools that exist in database but not in system as offline or deletes them
|
||||||
func (m *ZFSPoolMonitor) markMissingPoolsOffline(ctx context.Context, systemPools map[string]PoolInfo) error {
|
func (m *ZFSPoolMonitor) markMissingPoolsOffline(ctx context.Context, systemPools map[string]PoolInfo) error {
|
||||||
// Get all pools from database
|
// Get all pools from database
|
||||||
rows, err := m.zfsService.db.QueryContext(ctx, "SELECT id, name FROM zfs_pools WHERE is_active = true")
|
rows, err := m.zfsService.db.QueryContext(ctx, "SELECT id, name FROM zfs_pools WHERE is_active = true")
|
||||||
@@ -235,17 +235,13 @@ func (m *ZFSPoolMonitor) markMissingPoolsOffline(ctx context.Context, systemPool
|
|||||||
|
|
||||||
// Check if pool exists in system
|
// Check if pool exists in system
|
||||||
if _, exists := systemPools[poolName]; !exists {
|
if _, exists := systemPools[poolName]; !exists {
|
||||||
// Pool doesn't exist in system, mark as offline
|
// Pool doesn't exist in system - delete from database (pool was destroyed)
|
||||||
_, err = m.zfsService.db.ExecContext(ctx, `
|
m.logger.Info("Pool not found in system, removing from database", "pool", poolName)
|
||||||
UPDATE zfs_pools SET
|
_, err = m.zfsService.db.ExecContext(ctx, "DELETE FROM zfs_pools WHERE id = $1", poolID)
|
||||||
health_status = 'offline',
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, poolID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("Failed to mark pool as offline", "pool", poolName, "error", err)
|
m.logger.Warn("Failed to delete missing pool from database", "pool", poolName, "error", err)
|
||||||
} else {
|
} else {
|
||||||
m.logger.Info("Marked pool as offline (not found in system)", "pool", poolName)
|
m.logger.Info("Removed missing pool from database", "pool", poolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,3 +115,19 @@ func (h *Handler) GenerateSupportBundle(c *gin.Context) {
|
|||||||
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
|
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListNetworkInterfaces lists all network interfaces
|
||||||
|
func (h *Handler) ListNetworkInterfaces(c *gin.Context) {
|
||||||
|
interfaces, err := h.service.ListNetworkInterfaces(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to list network interfaces", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list network interfaces"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we return an empty array instead of null
|
||||||
|
if interfaces == nil {
|
||||||
|
interfaces = []NetworkInterface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"interfaces": interfaces})
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,3 +175,173 @@ func (s *Service) GenerateSupportBundle(ctx context.Context, outputPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NetworkInterface represents a network interface
|
||||||
|
type NetworkInterface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Subnet string `json:"subnet"`
|
||||||
|
Status string `json:"status"` // "Connected" or "Down"
|
||||||
|
Speed string `json:"speed"` // e.g., "10 Gbps", "1 Gbps"
|
||||||
|
Role string `json:"role"` // "Management", "ISCSI", or empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNetworkInterfaces lists all network interfaces
|
||||||
|
func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface, error) {
|
||||||
|
// First, get all interface names and their states
|
||||||
|
cmd := exec.CommandContext(ctx, "ip", "link", "show")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to list interfaces", "error", err)
|
||||||
|
return nil, fmt.Errorf("failed to list interfaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceMap := make(map[string]*NetworkInterface)
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
|
||||||
|
s.logger.Debug("Parsing network interfaces", "output_lines", len(lines))
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse interface name and state
|
||||||
|
// Format: "2: ens18: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000"
|
||||||
|
// Look for lines that start with a number followed by ":" (interface definition line)
|
||||||
|
// Simple check: line starts with digit, contains ":", and contains "state"
|
||||||
|
if len(line) > 0 && line[0] >= '0' && line[0] <= '9' && strings.Contains(line, ":") && strings.Contains(line, "state") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract interface name (e.g., "ens18:" or "lo:")
|
||||||
|
ifaceName := strings.TrimSuffix(parts[1], ":")
|
||||||
|
if ifaceName == "" || ifaceName == "lo" {
|
||||||
|
continue // Skip loopback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract state - look for "state UP" or "state DOWN" in the line
|
||||||
|
state := "Down"
|
||||||
|
if strings.Contains(line, "state UP") {
|
||||||
|
state = "Connected"
|
||||||
|
} else if strings.Contains(line, "state DOWN") {
|
||||||
|
state = "Down"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Found interface", "name", ifaceName, "state", state)
|
||||||
|
|
||||||
|
interfaceMap[ifaceName] = &NetworkInterface{
|
||||||
|
Name: ifaceName,
|
||||||
|
Status: state,
|
||||||
|
Speed: "Unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Debug("Found interfaces from ip link", "count", len(interfaceMap))
|
||||||
|
|
||||||
|
// Get IP addresses for each interface
|
||||||
|
cmd = exec.CommandContext(ctx, "ip", "-4", "addr", "show")
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to get IP addresses", "error", err)
|
||||||
|
} else {
|
||||||
|
lines = strings.Split(string(output), "\n")
|
||||||
|
var currentIfaceName string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse interface name (e.g., "2: ens18: <BROADCAST,MULTICAST,UP,LOWER_UP>")
|
||||||
|
if strings.Contains(line, ":") && !strings.Contains(line, "inet") && !strings.HasPrefix(line, "valid_lft") && !strings.HasPrefix(line, "altname") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
currentIfaceName = strings.TrimSuffix(parts[1], ":")
|
||||||
|
s.logger.Debug("Processing interface for IP", "name", currentIfaceName)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse IP address (e.g., "inet 10.10.14.16/24 brd 10.10.14.255 scope global ens18")
|
||||||
|
if strings.HasPrefix(line, "inet ") && currentIfaceName != "" && currentIfaceName != "lo" {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
ipWithSubnet := parts[1] // e.g., "10.10.14.16/24"
|
||||||
|
ipParts := strings.Split(ipWithSubnet, "/")
|
||||||
|
if len(ipParts) == 2 {
|
||||||
|
ip := ipParts[0]
|
||||||
|
subnet := ipParts[1]
|
||||||
|
|
||||||
|
// Find or create interface
|
||||||
|
iface, exists := interfaceMap[currentIfaceName]
|
||||||
|
if !exists {
|
||||||
|
s.logger.Debug("Creating new interface entry", "name", currentIfaceName)
|
||||||
|
iface = &NetworkInterface{
|
||||||
|
Name: currentIfaceName,
|
||||||
|
Status: "Down",
|
||||||
|
Speed: "Unknown",
|
||||||
|
}
|
||||||
|
interfaceMap[currentIfaceName] = iface
|
||||||
|
}
|
||||||
|
|
||||||
|
iface.IPAddress = ip
|
||||||
|
iface.Subnet = subnet
|
||||||
|
s.logger.Debug("Set IP for interface", "name", currentIfaceName, "ip", ip, "subnet", subnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to slice
|
||||||
|
var interfaces []NetworkInterface
|
||||||
|
s.logger.Debug("Converting interface map to slice", "map_size", len(interfaceMap))
|
||||||
|
for _, iface := range interfaceMap {
|
||||||
|
// Get speed for each interface using ethtool
|
||||||
|
if iface.Name != "" && iface.Name != "lo" {
|
||||||
|
cmd := exec.CommandContext(ctx, "ethtool", iface.Name)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
// Parse speed from ethtool output
|
||||||
|
ethtoolLines := strings.Split(string(output), "\n")
|
||||||
|
for _, ethtoolLine := range ethtoolLines {
|
||||||
|
if strings.Contains(ethtoolLine, "Speed:") {
|
||||||
|
parts := strings.Fields(ethtoolLine)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
iface.Speed = parts[1]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine role based on interface name or IP (simple heuristic)
|
||||||
|
// You can enhance this with configuration file or database lookup
|
||||||
|
if strings.Contains(iface.Name, "eth") || strings.Contains(iface.Name, "ens") {
|
||||||
|
// Default to Management for first interface, ISCSI for others
|
||||||
|
if iface.Name == "eth0" || iface.Name == "ens18" {
|
||||||
|
iface.Role = "Management"
|
||||||
|
} else {
|
||||||
|
// Check if IP is in typical iSCSI range (10.x.x.x)
|
||||||
|
if strings.HasPrefix(iface.IPAddress, "10.") && iface.IPAddress != "" {
|
||||||
|
iface.Role = "ISCSI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interfaces = append(interfaces, *iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no interfaces found, return empty slice
|
||||||
|
if len(interfaces) == 0 {
|
||||||
|
s.logger.Warn("No network interfaces found")
|
||||||
|
return []NetworkInterface{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Listed network interfaces", "count", len(interfaces))
|
||||||
|
return interfaces, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tape_vtl
|
package tape_vtl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/atlasos/calypso/internal/common/database"
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
@@ -29,6 +30,7 @@ func NewHandler(db *database.DB, log *logger.Logger) *Handler {
|
|||||||
|
|
||||||
// ListLibraries lists all virtual tape libraries
|
// ListLibraries lists all virtual tape libraries
|
||||||
func (h *Handler) ListLibraries(c *gin.Context) {
|
func (h *Handler) ListLibraries(c *gin.Context) {
|
||||||
|
h.logger.Info("ListLibraries called")
|
||||||
libraries, err := h.service.ListLibraries(c.Request.Context())
|
libraries, err := h.service.ListLibraries(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to list libraries", "error", err)
|
h.logger.Error("Failed to list libraries", "error", err)
|
||||||
@@ -36,7 +38,36 @@ func (h *Handler) ListLibraries(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"libraries": libraries})
|
h.logger.Info("ListLibraries result", "count", len(libraries), "is_nil", libraries == nil)
|
||||||
|
|
||||||
|
// Ensure we return an empty array instead of null
|
||||||
|
if libraries == nil {
|
||||||
|
h.logger.Warn("Libraries is nil, converting to empty array")
|
||||||
|
libraries = []VirtualTapeLibrary{}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Returning libraries", "count", len(libraries), "libraries", libraries)
|
||||||
|
|
||||||
|
// Ensure we always return an array, never null
|
||||||
|
if libraries == nil {
|
||||||
|
libraries = []VirtualTapeLibrary{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force empty array if nil (double check)
|
||||||
|
if libraries == nil {
|
||||||
|
h.logger.Warn("Libraries is still nil in handler, forcing empty array")
|
||||||
|
libraries = []VirtualTapeLibrary{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use explicit JSON marshalling to ensure empty array, not null
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"libraries": libraries,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Response payload", "count", len(libraries), "response_type", fmt.Sprintf("%T", libraries))
|
||||||
|
|
||||||
|
// Use JSON marshalling that handles empty slices correctly
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLibrary retrieves a library by ID
|
// GetLibrary retrieves a library by ID
|
||||||
@@ -69,11 +100,11 @@ func (h *Handler) GetLibrary(c *gin.Context) {
|
|||||||
|
|
||||||
// CreateLibraryRequest represents a library creation request
|
// CreateLibraryRequest represents a library creation request
|
||||||
type CreateLibraryRequest struct {
|
type CreateLibraryRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BackingStorePath string `json:"backing_store_path" binding:"required"`
|
BackingStorePath string `json:"backing_store_path" binding:"required"`
|
||||||
SlotCount int `json:"slot_count" binding:"required"`
|
SlotCount int `json:"slot_count" binding:"required"`
|
||||||
DriveCount int `json:"drive_count" binding:"required"`
|
DriveCount int `json:"drive_count" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLibrary creates a new virtual tape library
|
// CreateLibrary creates a new virtual tape library
|
||||||
@@ -161,10 +192,10 @@ func (h *Handler) GetLibraryTapes(c *gin.Context) {
|
|||||||
|
|
||||||
// CreateTapeRequest represents a tape creation request
|
// CreateTapeRequest represents a tape creation request
|
||||||
type CreateTapeRequest struct {
|
type CreateTapeRequest struct {
|
||||||
Barcode string `json:"barcode" binding:"required"`
|
Barcode string `json:"barcode" binding:"required"`
|
||||||
SlotNumber int `json:"slot_number" binding:"required"`
|
SlotNumber int `json:"slot_number" binding:"required"`
|
||||||
TapeType string `json:"tape_type" binding:"required"`
|
TapeType string `json:"tape_type" binding:"required"`
|
||||||
SizeGB int64 `json:"size_gb" binding:"required"`
|
SizeGB int64 `json:"size_gb" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTape creates a new virtual tape
|
// CreateTape creates a new virtual tape
|
||||||
@@ -218,9 +249,9 @@ func (h *Handler) LoadTape(c *gin.Context) {
|
|||||||
// Create async task
|
// Create async task
|
||||||
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
|
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
|
||||||
tasks.TaskTypeLoadUnload, userID.(string), map[string]interface{}{
|
tasks.TaskTypeLoadUnload, userID.(string), map[string]interface{}{
|
||||||
"operation": "load_tape",
|
"operation": "load_tape",
|
||||||
"library_id": libraryID,
|
"library_id": libraryID,
|
||||||
"slot_number": req.SlotNumber,
|
"slot_number": req.SlotNumber,
|
||||||
"drive_number": req.DriveNumber,
|
"drive_number": req.DriveNumber,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,9 +299,9 @@ func (h *Handler) UnloadTape(c *gin.Context) {
|
|||||||
// Create async task
|
// Create async task
|
||||||
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
|
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
|
||||||
tasks.TaskTypeLoadUnload, userID.(string), map[string]interface{}{
|
tasks.TaskTypeLoadUnload, userID.(string), map[string]interface{}{
|
||||||
"operation": "unload_tape",
|
"operation": "unload_tape",
|
||||||
"library_id": libraryID,
|
"library_id": libraryID,
|
||||||
"slot_number": req.SlotNumber,
|
"slot_number": req.SlotNumber,
|
||||||
"drive_number": req.DriveNumber,
|
"drive_number": req.DriveNumber,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -295,4 +326,3 @@ func (h *Handler) UnloadTape(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
|
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,46 +28,46 @@ func NewService(db *database.DB, log *logger.Logger) *Service {
|
|||||||
|
|
||||||
// VirtualTapeLibrary represents a virtual tape library
|
// VirtualTapeLibrary represents a virtual tape library
|
||||||
type VirtualTapeLibrary struct {
|
type VirtualTapeLibrary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
MHVTLibraryID int `json:"mhvtl_library_id"`
|
MHVTLibraryID int `json:"mhvtl_library_id"`
|
||||||
BackingStorePath string `json:"backing_store_path"`
|
BackingStorePath string `json:"backing_store_path"`
|
||||||
SlotCount int `json:"slot_count"`
|
SlotCount int `json:"slot_count"`
|
||||||
DriveCount int `json:"drive_count"`
|
DriveCount int `json:"drive_count"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VirtualTapeDrive represents a virtual tape drive
|
// VirtualTapeDrive represents a virtual tape drive
|
||||||
type VirtualTapeDrive struct {
|
type VirtualTapeDrive struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
LibraryID string `json:"library_id"`
|
LibraryID string `json:"library_id"`
|
||||||
DriveNumber int `json:"drive_number"`
|
DriveNumber int `json:"drive_number"`
|
||||||
DevicePath *string `json:"device_path,omitempty"`
|
DevicePath *string `json:"device_path,omitempty"`
|
||||||
StablePath *string `json:"stable_path,omitempty"`
|
StablePath *string `json:"stable_path,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CurrentTapeID string `json:"current_tape_id,omitempty"`
|
CurrentTapeID string `json:"current_tape_id,omitempty"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VirtualTape represents a virtual tape
|
// VirtualTape represents a virtual tape
|
||||||
type VirtualTape struct {
|
type VirtualTape struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
LibraryID string `json:"library_id"`
|
LibraryID string `json:"library_id"`
|
||||||
Barcode string `json:"barcode"`
|
Barcode string `json:"barcode"`
|
||||||
SlotNumber int `json:"slot_number"`
|
SlotNumber int `json:"slot_number"`
|
||||||
ImageFilePath string `json:"image_file_path"`
|
ImageFilePath string `json:"image_file_path"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
UsedBytes int64 `json:"used_bytes"`
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
TapeType string `json:"tape_type"`
|
TapeType string `json:"tape_type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLibrary creates a new virtual tape library
|
// CreateLibrary creates a new virtual tape library
|
||||||
@@ -135,14 +135,14 @@ func (s *Service) CreateLibrary(ctx context.Context, name, description, backingS
|
|||||||
for i := 1; i <= slotCount; i++ {
|
for i := 1; i <= slotCount; i++ {
|
||||||
barcode := fmt.Sprintf("V%05d", i)
|
barcode := fmt.Sprintf("V%05d", i)
|
||||||
tape := VirtualTape{
|
tape := VirtualTape{
|
||||||
LibraryID: lib.ID,
|
LibraryID: lib.ID,
|
||||||
Barcode: barcode,
|
Barcode: barcode,
|
||||||
SlotNumber: i,
|
SlotNumber: i,
|
||||||
ImageFilePath: filepath.Join(tapesPath, fmt.Sprintf("%s.img", barcode)),
|
ImageFilePath: filepath.Join(tapesPath, fmt.Sprintf("%s.img", barcode)),
|
||||||
SizeBytes: 800 * 1024 * 1024 * 1024, // 800 GB default (LTO-8)
|
SizeBytes: 800 * 1024 * 1024 * 1024, // 800 GB default (LTO-8)
|
||||||
UsedBytes: 0,
|
UsedBytes: 0,
|
||||||
TapeType: "LTO-8",
|
TapeType: "LTO-8",
|
||||||
Status: "idle",
|
Status: "idle",
|
||||||
}
|
}
|
||||||
if err := s.createTape(ctx, &tape); err != nil {
|
if err := s.createTape(ctx, &tape); err != nil {
|
||||||
s.logger.Error("Failed to create tape", "slot", i, "error", err)
|
s.logger.Error("Failed to create tape", "slot", i, "error", err)
|
||||||
@@ -228,28 +228,56 @@ func (s *Service) ListLibraries(ctx context.Context) ([]VirtualTapeLibrary, erro
|
|||||||
ORDER BY name
|
ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
|
s.logger.Info("Executing query to list libraries")
|
||||||
rows, err := s.db.QueryContext(ctx, query)
|
rows, err := s.db.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to query libraries", "error", err)
|
||||||
return nil, fmt.Errorf("failed to list libraries: %w", err)
|
return nil, fmt.Errorf("failed to list libraries: %w", err)
|
||||||
}
|
}
|
||||||
|
s.logger.Info("Query executed successfully, got rows")
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var libraries []VirtualTapeLibrary
|
libraries := make([]VirtualTapeLibrary, 0) // Initialize as empty slice, not nil
|
||||||
|
s.logger.Info("Starting to scan library rows", "query", query)
|
||||||
|
rowCount := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
rowCount++
|
||||||
var lib VirtualTapeLibrary
|
var lib VirtualTapeLibrary
|
||||||
|
var description sql.NullString
|
||||||
|
var createdBy sql.NullString
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&lib.ID, &lib.Name, &lib.Description, &lib.MHVTLibraryID, &lib.BackingStorePath,
|
&lib.ID, &lib.Name, &description, &lib.MHVTLibraryID, &lib.BackingStorePath,
|
||||||
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
|
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
|
||||||
&lib.CreatedAt, &lib.UpdatedAt, &lib.CreatedBy,
|
&lib.CreatedAt, &lib.UpdatedAt, &createdBy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to scan library", "error", err)
|
s.logger.Error("Failed to scan library", "error", err, "row", rowCount)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if description.Valid {
|
||||||
|
lib.Description = description.String
|
||||||
|
}
|
||||||
|
if createdBy.Valid {
|
||||||
|
lib.CreatedBy = createdBy.String
|
||||||
|
}
|
||||||
libraries = append(libraries, lib)
|
libraries = append(libraries, lib)
|
||||||
|
s.logger.Info("Added library to list", "library_id", lib.ID, "name", lib.Name, "mhvtl_id", lib.MHVTLibraryID)
|
||||||
|
}
|
||||||
|
s.logger.Info("Finished scanning library rows", "total_rows", rowCount, "libraries_added", len(libraries))
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
s.logger.Error("Error iterating library rows", "error", err)
|
||||||
|
return nil, fmt.Errorf("error iterating library rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return libraries, rows.Err()
|
s.logger.Info("Listed virtual tape libraries", "count", len(libraries), "is_nil", libraries == nil)
|
||||||
|
// Ensure we return an empty slice, not nil
|
||||||
|
if libraries == nil {
|
||||||
|
s.logger.Warn("Libraries is nil in service, converting to empty array")
|
||||||
|
libraries = []VirtualTapeLibrary{}
|
||||||
|
}
|
||||||
|
s.logger.Info("Returning from service", "count", len(libraries), "is_nil", libraries == nil)
|
||||||
|
return libraries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLibrary retrieves a library by ID
|
// GetLibrary retrieves a library by ID
|
||||||
@@ -262,10 +290,12 @@ func (s *Service) GetLibrary(ctx context.Context, id string) (*VirtualTapeLibrar
|
|||||||
`
|
`
|
||||||
|
|
||||||
var lib VirtualTapeLibrary
|
var lib VirtualTapeLibrary
|
||||||
|
var description sql.NullString
|
||||||
|
var createdBy sql.NullString
|
||||||
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
||||||
&lib.ID, &lib.Name, &lib.Description, &lib.MHVTLibraryID, &lib.BackingStorePath,
|
&lib.ID, &lib.Name, &description, &lib.MHVTLibraryID, &lib.BackingStorePath,
|
||||||
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
|
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
|
||||||
&lib.CreatedAt, &lib.UpdatedAt, &lib.CreatedBy,
|
&lib.CreatedAt, &lib.UpdatedAt, &createdBy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -274,6 +304,13 @@ func (s *Service) GetLibrary(ctx context.Context, id string) (*VirtualTapeLibrar
|
|||||||
return nil, fmt.Errorf("failed to get library: %w", err)
|
return nil, fmt.Errorf("failed to get library: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if description.Valid {
|
||||||
|
lib.Description = description.String
|
||||||
|
}
|
||||||
|
if createdBy.Valid {
|
||||||
|
lib.CreatedBy = createdBy.String
|
||||||
|
}
|
||||||
|
|
||||||
return &lib, nil
|
return &lib, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,4 +537,3 @@ func (s *Service) DeleteLibrary(ctx context.Context, id string) error {
|
|||||||
s.logger.Info("Virtual tape library deleted", "id", id, "name", lib.Name)
|
s.logger.Info("Virtual tape library deleted", "id", id, "name", lib.Name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import TapeLibrariesPage from '@/pages/TapeLibraries'
|
|||||||
import VTLDetailPage from '@/pages/VTLDetail'
|
import VTLDetailPage from '@/pages/VTLDetail'
|
||||||
import ISCSITargetsPage from '@/pages/ISCSITargets'
|
import ISCSITargetsPage from '@/pages/ISCSITargets'
|
||||||
import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail'
|
import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail'
|
||||||
|
import SystemPage from '@/pages/System'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
@@ -55,6 +56,7 @@ function App() {
|
|||||||
<Route path="iscsi" element={<ISCSITargetsPage />} />
|
<Route path="iscsi" element={<ISCSITargetsPage />} />
|
||||||
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
|
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
|
||||||
<Route path="alerts" element={<AlertsPage />} />
|
<Route path="alerts" element={<AlertsPage />} />
|
||||||
|
<Route path="system" element={<SystemPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface ZFSPool {
|
|||||||
scrub_interval: number // days
|
scrub_interval: number // days
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
health_status: string // online, degraded, faulted, offline
|
health_status: string // online, degraded, faulted, offline
|
||||||
|
compress_ratio?: number // compression ratio (e.g., 1.45)
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
created_by: string
|
created_by: string
|
||||||
|
|||||||
18
frontend/src/api/system.ts
Normal file
18
frontend/src/api/system.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export interface NetworkInterface {
|
||||||
|
name: string
|
||||||
|
ip_address: string
|
||||||
|
subnet: string
|
||||||
|
status: string // "Connected" or "Down"
|
||||||
|
speed: string // e.g., "10 Gbps", "1 Gbps"
|
||||||
|
role: string // "Management", "ISCSI", or empty
|
||||||
|
}
|
||||||
|
|
||||||
|
export const systemAPI = {
|
||||||
|
listNetworkInterfaces: async (): Promise<NetworkInterface[]> => {
|
||||||
|
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
||||||
|
return response.data.interfaces || []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -122,3 +122,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Toggle Switch */
|
||||||
|
.toggle-checkbox:checked {
|
||||||
|
right: 0;
|
||||||
|
border-color: #137fec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked + .toggle-label {
|
||||||
|
background-color: #137fec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,8 +186,10 @@ 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
|
refetchInterval: 3000, // Auto-refresh every 3 seconds
|
||||||
staleTime: 0, // Always consider data stale
|
staleTime: 0, // Always consider data stale
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnMount: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch ARC stats with auto-refresh every 2 seconds for live data
|
// Fetch ARC stats with auto-refresh every 2 seconds for live data
|
||||||
@@ -254,8 +256,10 @@ export default function StoragePage() {
|
|||||||
|
|
||||||
const deletePoolMutation = useMutation({
|
const deletePoolMutation = useMutation({
|
||||||
mutationFn: (poolId: string) => zfsApi.deletePool(poolId),
|
mutationFn: (poolId: string) => zfsApi.deletePool(poolId),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage', 'zfs', 'pools'] })
|
// Invalidate and immediately refetch
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['storage', 'zfs', 'pools'] })
|
||||||
|
queryClient.refetchQueries({ queryKey: ['storage', 'zfs', 'pools'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage', 'disks'] })
|
queryClient.invalidateQueries({ queryKey: ['storage', 'disks'] })
|
||||||
setSelectedPool(null)
|
setSelectedPool(null)
|
||||||
alert('Pool destroyed successfully!')
|
alert('Pool destroyed successfully!')
|
||||||
@@ -341,20 +345,51 @@ export default function StoragePage() {
|
|||||||
|
|
||||||
const healthyPools = allPools.filter((p) => {
|
const healthyPools = allPools.filter((p) => {
|
||||||
if ('health_status' in p) {
|
if ('health_status' in p) {
|
||||||
return p.is_active && (p as ZFSPool).health_status === 'online'
|
const health = (p as ZFSPool).health_status?.toLowerCase() || ''
|
||||||
|
return p.is_active && health === 'online'
|
||||||
}
|
}
|
||||||
return p.is_active
|
return p.is_active
|
||||||
}).length
|
}).length
|
||||||
const degradedPools = allPools.filter((p) => {
|
const degradedPools = allPools.filter((p) => {
|
||||||
if ('health_status' in p) {
|
if ('health_status' in p) {
|
||||||
return !p.is_active || (p as ZFSPool).health_status !== 'online'
|
const health = (p as ZFSPool).health_status?.toLowerCase() || ''
|
||||||
|
return !p.is_active || health !== 'online'
|
||||||
}
|
}
|
||||||
return !p.is_active
|
return !p.is_active
|
||||||
}).length
|
}).length
|
||||||
const healthStatus = degradedPools === 0 ? 'Optimal' : 'Degraded'
|
const healthStatus = degradedPools === 0 ? 'Optimal' : 'Degraded'
|
||||||
|
|
||||||
// Mock efficiency data (would come from backend)
|
// Calculate efficiency ratio from ZFS pools
|
||||||
const efficiencyRatio = 1.45
|
// Efficiency = average compressratio across all active pools
|
||||||
|
// Use actual compressratio from ZFS if available, otherwise estimate
|
||||||
|
const activeZFSPools = zfsPools.filter(p => p.is_active && p.health_status?.toLowerCase() === 'online')
|
||||||
|
const efficiencyRatio = activeZFSPools.length > 0
|
||||||
|
? activeZFSPools.reduce((sum, pool) => {
|
||||||
|
// Use actual compressratio from ZFS if available
|
||||||
|
if (pool.compress_ratio && pool.compress_ratio > 0) {
|
||||||
|
// Deduplication can add additional savings (typically 1.2-2x)
|
||||||
|
const dedupMultiplier = pool.deduplication ? 1.3 : 1.0
|
||||||
|
return sum + (pool.compress_ratio * dedupMultiplier)
|
||||||
|
}
|
||||||
|
// Fallback: estimate based on compression type
|
||||||
|
const compressionMultiplier: Record<string, number> = {
|
||||||
|
'lz4': 1.5,
|
||||||
|
'zstd': 2.5,
|
||||||
|
'gzip': 2.0,
|
||||||
|
'gzip-1': 1.8,
|
||||||
|
'gzip-9': 2.5,
|
||||||
|
'off': 1.0,
|
||||||
|
}
|
||||||
|
const baseRatio = compressionMultiplier[pool.compression?.toLowerCase() || 'lz4'] || 1.5
|
||||||
|
const dedupMultiplier = pool.deduplication ? 1.3 : 1.0
|
||||||
|
return sum + (baseRatio * dedupMultiplier)
|
||||||
|
}, 0) / activeZFSPools.length
|
||||||
|
: 1.0
|
||||||
|
|
||||||
|
// Get compression and deduplication status from pools
|
||||||
|
const hasCompression = activeZFSPools.some(p => p.compression && p.compression.toLowerCase() !== 'off')
|
||||||
|
const hasDedup = activeZFSPools.some(p => p.deduplication)
|
||||||
|
const compressionType = activeZFSPools.find(p => p.compression && p.compression.toLowerCase() !== 'off')?.compression?.toUpperCase() || 'LZ4'
|
||||||
// Use live ARC stats if available, otherwise fallback to 0
|
// Use live ARC stats if available, otherwise fallback to 0
|
||||||
const arcHitRatio = arcStats?.hit_ratio ?? 0
|
const arcHitRatio = arcStats?.hit_ratio ?? 0
|
||||||
const arcCacheUsage = arcStats?.cache_usage ?? 0
|
const arcCacheUsage = arcStats?.cache_usage ?? 0
|
||||||
@@ -478,8 +513,21 @@ export default function StoragePage() {
|
|||||||
<span className="text-xs text-white/70">Ratio</span>
|
<span className="text-xs text-white/70">Ratio</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<span className="px-2 py-0.5 rounded bg-blue-500/10 text-blue-500 text-[10px] font-bold">LZ4</span>
|
{hasCompression && (
|
||||||
<span className="px-2 py-0.5 rounded bg-purple-500/10 text-purple-500 text-[10px] font-bold">DEDUP ON</span>
|
<span className="px-2 py-0.5 rounded bg-blue-500/10 text-blue-500 text-[10px] font-bold">
|
||||||
|
{compressionType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasDedup && (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-purple-500/10 text-purple-500 text-[10px] font-bold">
|
||||||
|
DEDUP ON
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!hasCompression && !hasDedup && (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-gray-500/10 text-gray-500 text-[10px] font-bold">
|
||||||
|
NO COMPRESSION
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -558,7 +606,7 @@ export default function StoragePage() {
|
|||||||
|
|
||||||
// Check if it's a ZFS pool or LVM repository
|
// Check if it's a ZFS pool or LVM repository
|
||||||
const isZFSPool = 'raid_level' in pool
|
const isZFSPool = 'raid_level' in pool
|
||||||
const healthStatus = isZFSPool ? (pool as ZFSPool).health_status : 'online'
|
const healthStatus = isZFSPool ? ((pool as ZFSPool).health_status?.toLowerCase() || 'online') : 'online'
|
||||||
const isHealthy = pool.is_active && (healthStatus === 'online' || healthStatus === '')
|
const isHealthy = pool.is_active && (healthStatus === 'online' || healthStatus === '')
|
||||||
|
|
||||||
const statusColor = isHealthy
|
const statusColor = isHealthy
|
||||||
@@ -809,11 +857,11 @@ export default function StoragePage() {
|
|||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<span className="material-symbols-outlined text-primary text-[20px]">info</span>
|
<span className="material-symbols-outlined text-primary text-[20px]">info</span>
|
||||||
<span className="text-sm font-bold text-primary">
|
<span className="text-sm font-bold text-primary">
|
||||||
{selectedPool.is_active && selectedPool.health_status === 'online' ? 'Healthy' : 'Degraded'}
|
{selectedPool.is_active && selectedPool.health_status?.toLowerCase() === 'online' ? 'Healthy' : 'Degraded'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-white/90 leading-relaxed mb-3">
|
<p className="text-xs text-white/90 leading-relaxed mb-3">
|
||||||
{selectedPool.is_active && selectedPool.health_status === 'online'
|
{selectedPool.is_active && selectedPool.health_status?.toLowerCase() === 'online'
|
||||||
? 'This pool is operating normally.'
|
? 'This pool is operating normally.'
|
||||||
: 'This pool has issues and requires attention.'}
|
: 'This pool has issues and requires attention.'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
434
frontend/src/pages/System.tsx
Normal file
434
frontend/src/pages/System.tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { systemAPI, NetworkInterface } from '@/api/system'
|
||||||
|
|
||||||
|
export default function System() {
|
||||||
|
const [snmpEnabled, setSnmpEnabled] = useState(false)
|
||||||
|
|
||||||
|
// Fetch network interfaces
|
||||||
|
const { data: interfaces = [], isLoading: interfacesLoading } = useQuery({
|
||||||
|
queryKey: ['system', 'interfaces'],
|
||||||
|
queryFn: () => systemAPI.listNetworkInterfaces(),
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
||||||
|
{/* Top Navigation */}
|
||||||
|
<header className="flex h-16 items-center justify-between border-b border-border-dark bg-background-dark px-6 lg:px-10 shrink-0 z-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="text-text-secondary md:hidden hover:text-white">
|
||||||
|
<span className="material-symbols-outlined">menu</span>
|
||||||
|
</button>
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Link to="/" className="text-text-secondary hover:text-white transition-colors">
|
||||||
|
System
|
||||||
|
</Link>
|
||||||
|
<span className="text-text-secondary">/</span>
|
||||||
|
<span className="text-white font-medium">Configuration</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/20">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||||
|
<span className="text-xs font-medium text-green-500">System Healthy</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-px bg-border-dark mx-2"></div>
|
||||||
|
<button className="flex items-center justify-center gap-2 rounded-lg bg-border-dark px-4 py-2 text-sm font-bold text-white hover:bg-[#2f455a] transition-colors">
|
||||||
|
<span className="material-symbols-outlined text-[18px]">restart_alt</span>
|
||||||
|
<span className="hidden sm:inline">Reboot</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center justify-center gap-2 rounded-lg bg-red-500/10 px-4 py-2 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-colors border border-red-500/20">
|
||||||
|
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||||
|
<span className="hidden sm:inline">Shutdown</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 lg:px-12 scroll-smooth">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-white mb-2">System Configuration</h1>
|
||||||
|
<p className="text-text-secondary text-sm max-w-2xl">
|
||||||
|
Manage network interfaces, time synchronization, service states, and remote management protocols.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center justify-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-all shadow-lg shadow-blue-500/20">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">save</span>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Layout */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||||
|
{/* Network Card */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-primary">lan</span>
|
||||||
|
<h2 className="text-lg font-bold text-white">Network Interfaces</h2>
|
||||||
|
</div>
|
||||||
|
<button className="text-xs font-bold text-primary hover:text-blue-400">CONFIGURE DNS</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
{interfacesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<span className="text-text-secondary">Loading interfaces...</span>
|
||||||
|
</div>
|
||||||
|
) : interfaces.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<span className="text-text-secondary">No network interfaces found</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
interfaces.map((iface: NetworkInterface) => {
|
||||||
|
const isConnected = iface.status === 'Connected'
|
||||||
|
const roleBgColor = iface.role === 'ISCSI' ? 'bg-purple-500/20' : 'bg-primary/20'
|
||||||
|
const roleTextColor = iface.role === 'ISCSI' ? 'text-purple-400' : 'text-primary'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={iface.name}
|
||||||
|
className={`group flex items-center justify-between rounded-lg p-3 hover:bg-border-dark/50 transition-colors ${!isConnected ? 'opacity-70' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-lg bg-border-dark ${isConnected ? 'text-white' : 'text-text-secondary'}`}>
|
||||||
|
<span className="material-symbols-outlined">settings_ethernet</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className={`font-bold ${isConnected ? 'text-white' : 'text-text-secondary'}`}>{iface.name}</p>
|
||||||
|
{iface.role && (
|
||||||
|
<span className={`rounded ${roleBgColor} px-1.5 py-0.5 text-[10px] font-bold ${roleTextColor} uppercase`}>
|
||||||
|
{iface.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{iface.ip_address ? (
|
||||||
|
<p className="font-mono text-xs text-text-secondary">
|
||||||
|
{iface.ip_address} <span className="opacity-50 mx-1">/</span> {iface.subnet}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-xs text-text-secondary">No Carrier</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="hidden sm:flex flex-col items-end">
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||||
|
<span className="text-xs font-medium text-white">Connected</span>
|
||||||
|
</div>
|
||||||
|
{iface.speed && iface.speed !== 'Unknown' && (
|
||||||
|
<span className="text-xs text-text-secondary">{iface.speed}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||||
|
<span className="text-xs font-medium text-red-500">Down</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors">
|
||||||
|
<span className="material-symbols-outlined">more_vert</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services Card */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-primary">memory</span>
|
||||||
|
<h2 className="text-lg font-bold text-white">Service Control</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||||
|
<span className="text-xs text-text-secondary">All Systems Normal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex flex-col gap-1">
|
||||||
|
{/* Service Row */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">terminal</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white">SSH Service</p>
|
||||||
|
<p className="text-xs text-text-secondary">Remote command line access</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||||
|
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||||
|
<input
|
||||||
|
defaultChecked
|
||||||
|
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||||
|
id="ssh-toggle"
|
||||||
|
name="toggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||||
|
htmlFor="ssh-toggle"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Service Row */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">folder_shared</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white">SMB / CIFS</p>
|
||||||
|
<p className="text-xs text-text-secondary">Windows file sharing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||||
|
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||||
|
<input
|
||||||
|
defaultChecked
|
||||||
|
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||||
|
id="smb-toggle"
|
||||||
|
name="toggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||||
|
htmlFor="smb-toggle"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Service Row */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">storage</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white">iSCSI Target</p>
|
||||||
|
<p className="text-xs text-text-secondary">Block storage sharing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/20 text-yellow-500 border border-yellow-500/20">STOPPED</span>
|
||||||
|
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||||
|
<input
|
||||||
|
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||||
|
id="iscsi-toggle"
|
||||||
|
name="toggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||||
|
htmlFor="iscsi-toggle"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Service Row */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">share</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white">NFS Service</p>
|
||||||
|
<p className="text-xs text-text-secondary">Unix file sharing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||||
|
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||||
|
<input
|
||||||
|
defaultChecked
|
||||||
|
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||||
|
id="nfs-toggle"
|
||||||
|
name="toggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||||
|
htmlFor="nfs-toggle"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Service Row - VTL (MHVTL) */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">album</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white">VTL Service</p>
|
||||||
|
<p className="text-xs text-text-secondary">Virtual tape library emulation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||||
|
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||||
|
<input
|
||||||
|
defaultChecked
|
||||||
|
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||||
|
id="mhvtl-toggle"
|
||||||
|
name="toggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||||
|
htmlFor="mhvtl-toggle"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & Time Card */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-primary">schedule</span>
|
||||||
|
<h2 className="text-lg font-bold text-white">Date & Time</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-text-secondary bg-border-dark px-2 py-1 rounded">UTC</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex flex-col gap-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">System Timezone</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
|
||||||
|
<option>Etc/UTC</option>
|
||||||
|
<option>America/New_York</option>
|
||||||
|
<option>Europe/London</option>
|
||||||
|
<option>Asia/Tokyo</option>
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white">
|
||||||
|
<span className="material-symbols-outlined text-sm">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-xs font-medium text-text-secondary uppercase">NTP Servers</label>
|
||||||
|
<button className="text-xs text-primary font-bold hover:text-white flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined text-[14px]">add</span> Add Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||||
|
<span className="text-sm font-mono text-white">pool.ntp.org</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-secondary">Stratum 2 • 12ms</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||||
|
<span className="text-sm font-mono text-white">time.google.com</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-secondary">Stratum 1 • 45ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Management & SNMP Card */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-primary">hub</span>
|
||||||
|
<h2 className="text-lg font-bold text-white">Management</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white">SNMP Monitoring</h3>
|
||||||
|
<p className="text-xs text-text-secondary">Enable Simple Network Management Protocol</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative inline-block w-10 align-middle select-none transition duration-200 ease-in">
|
||||||
|
<input
|
||||||
|
checked={snmpEnabled}
|
||||||
|
onChange={(e) => setSnmpEnabled(e.target.checked)}
|
||||||
|
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||||
|
id="snmp-toggle"
|
||||||
|
name="toggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||||
|
htmlFor="snmp-toggle"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`grid grid-cols-1 gap-4 transition-opacity ${snmpEnabled ? 'opacity-100' : 'opacity-50 pointer-events-none'}`}>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-text-secondary uppercase">Community String</label>
|
||||||
|
<input
|
||||||
|
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="e.g. public"
|
||||||
|
type="text"
|
||||||
|
defaultValue="public"
|
||||||
|
disabled={!snmpEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-text-secondary uppercase">Trap Receiver IP</label>
|
||||||
|
<input
|
||||||
|
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="e.g. 192.168.1.100"
|
||||||
|
type="text"
|
||||||
|
disabled={!snmpEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border-dark pt-4">
|
||||||
|
<h3 className="text-sm font-bold text-white mb-3">Syslog Forwarding</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Syslog Server Address (UDP:514)"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<button className="rounded-lg bg-border-dark px-4 py-2 text-sm font-bold text-white hover:bg-[#2f455a] transition-colors">
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Spacer */}
|
||||||
|
<div className="h-10"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -460,10 +460,10 @@ export default function TapeLibraries() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tape Detail Drawer */}
|
{/* Tape Detail Drawer */}
|
||||||
{selectedLibrary && activeTab === 'vtl' && libraryTapes.length > 0 && (
|
{selectedLibrary && activeTab === 'vtl' && (
|
||||||
<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="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 max-h-[70vh] overflow-y-auto">
|
||||||
<div className="max-w-[1400px] mx-auto">
|
<div className="max-w-[1400px] mx-auto">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="material-symbols-outlined text-primary text-2xl">cable</span>
|
<span className="material-symbols-outlined text-primary text-2xl">cable</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -475,7 +475,7 @@ export default function TapeLibraries() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 flex-wrap">
|
||||||
<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">
|
<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
|
Bulk Format
|
||||||
</button>
|
</button>
|
||||||
@@ -488,47 +488,71 @@ export default function TapeLibraries() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedLibrary(null)}
|
onClick={() => setSelectedLibrary(null)}
|
||||||
className="lg:hidden p-2 text-text-secondary hover:text-white"
|
className="p-2 text-text-secondary hover:text-white"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">close</span>
|
<span className="material-symbols-outlined">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
||||||
{libraryTapes.map((tape) => (
|
{libraryTapes.length === 0 ? (
|
||||||
<div
|
<div className="text-center py-12">
|
||||||
key={tape.id}
|
<span className="material-symbols-outlined text-6xl text-text-secondary mb-4 block">album</span>
|
||||||
className={`p-3 rounded border flex flex-col gap-2 relative group hover:border-primary transition-colors cursor-pointer ${
|
<h3 className="text-lg font-medium text-white mb-2">No Tapes Found</h3>
|
||||||
tape.status === 'in_drive'
|
<p className="text-sm text-text-secondary mb-4">
|
||||||
? 'bg-[#111a22] border-green-500/30'
|
This library has no tapes yet. Create tapes to get started.
|
||||||
: 'bg-[#111a22] border-border-dark'
|
</p>
|
||||||
}`}
|
<Link
|
||||||
|
to={`/tape/vtl/${selectedLibrary}/tapes/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"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<span className="material-symbols-outlined text-lg">add</span>
|
||||||
<span
|
Add Tapes
|
||||||
className={`material-symbols-outlined text-xl ${
|
</Link>
|
||||||
tape.status === 'in_drive' ? 'text-green-500' : 'text-text-secondary'
|
</div>
|
||||||
}`}
|
) : (
|
||||||
>
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
||||||
album
|
{libraryTapes.map((tape) => (
|
||||||
</span>
|
<div
|
||||||
<span className="text-[10px] uppercase font-bold text-text-secondary bg-[#1c2834] px-1 rounded">
|
key={tape.id}
|
||||||
Slot {tape.slot_number}
|
className={`p-3 rounded-lg border flex flex-col gap-2 relative group hover:border-primary transition-all cursor-pointer min-h-[120px] ${
|
||||||
</span>
|
tape.status === 'in_drive'
|
||||||
|
? 'bg-[#111a22] border-green-500/30 shadow-lg shadow-green-500/10'
|
||||||
|
: 'bg-[#111a22] border-border-dark hover:shadow-lg hover:shadow-primary/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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.5 py-0.5 rounded">
|
||||||
|
SLOT {tape.slot_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col justify-center">
|
||||||
|
<p className="text-white text-xs font-mono font-bold truncate" title={tape.barcode}>
|
||||||
|
{tape.barcode}
|
||||||
|
</p>
|
||||||
|
<p className="text-text-secondary text-[10px] mt-1">
|
||||||
|
{formatBytes(tape.size_bytes, 1)} / {formatBytes(tape.size_bytes, 1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/70 hidden group-hover:flex items-center justify-center gap-2 backdrop-blur-sm rounded-lg">
|
||||||
|
<button className="p-2 text-white hover:text-primary hover:bg-primary/20 rounded transition-colors" title="Eject">
|
||||||
|
<span className="material-symbols-outlined text-lg">eject</span>
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-white hover:text-red-400 hover:bg-red-400/20 rounded transition-colors" title="Delete">
|
||||||
|
<span className="material-symbols-outlined text-lg">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<p className="text-white text-xs font-mono font-bold">{tape.barcode}</p>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ export default {
|
|||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
// Dark theme colors from example
|
// Dark theme colors from example
|
||||||
"background-dark": "#111a22",
|
"background-dark": "#101922",
|
||||||
"card-dark": "#1a2632",
|
"card-dark": "#192633",
|
||||||
"border-dark": "#324d67",
|
"border-dark": "#233648",
|
||||||
"text-secondary": "#92adc9",
|
"text-secondary": "#92adc9",
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
Reference in New Issue
Block a user