working on vtl features: pending drive creation, media changer creation, iscsi mapping
This commit is contained in:
7
atlas.code-workspace
Normal file
7
atlas.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -113,6 +113,7 @@ func (a *App) isPublicEndpoint(path, method string) bool {
|
|||||||
"/storage", // Storage management page
|
"/storage", // Storage management page
|
||||||
"/shares", // Shares page
|
"/shares", // Shares page
|
||||||
"/iscsi", // iSCSI page
|
"/iscsi", // iSCSI page
|
||||||
|
"/vtl", // VTL (Virtual Tape Library) page
|
||||||
"/protection", // Data Protection page
|
"/protection", // Data Protection page
|
||||||
"/management", // System Management page
|
"/management", // System Management page
|
||||||
"/api/docs", // API documentation
|
"/api/docs", // API documentation
|
||||||
@@ -147,6 +148,11 @@ func (a *App) isPublicEndpoint(path, method string) bool {
|
|||||||
"/api/v1/shares/smb", // List SMB shares (GET only)
|
"/api/v1/shares/smb", // List SMB shares (GET only)
|
||||||
"/api/v1/exports/nfs", // List NFS exports (GET only)
|
"/api/v1/exports/nfs", // List NFS exports (GET only)
|
||||||
"/api/v1/iscsi/targets", // List iSCSI targets (GET only)
|
"/api/v1/iscsi/targets", // List iSCSI targets (GET only)
|
||||||
|
"/api/v1/vtl/status", // VTL status (GET only)
|
||||||
|
"/api/v1/vtl/drives", // List VTL drives (GET only)
|
||||||
|
"/api/v1/vtl/tapes", // List VTL tapes (GET only)
|
||||||
|
"/api/v1/vtl/changers", // List VTL media changers (GET only)
|
||||||
|
"/api/v1/vtl/changer/status", // VTL media changer status (GET only)
|
||||||
"/api/v1/snapshots", // List snapshots (GET only)
|
"/api/v1/snapshots", // List snapshots (GET only)
|
||||||
"/api/v1/snapshot-policies", // List snapshot policies (GET only)
|
"/api/v1/snapshot-policies", // List snapshot policies (GET only)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ type DashboardData struct {
|
|||||||
SMBStatus bool `json:"smb_status"`
|
SMBStatus bool `json:"smb_status"`
|
||||||
NFSStatus bool `json:"nfs_status"`
|
NFSStatus bool `json:"nfs_status"`
|
||||||
ISCSIStatus bool `json:"iscsi_status"`
|
ISCSIStatus bool `json:"iscsi_status"`
|
||||||
|
VTLStatus bool `json:"vtl_status"`
|
||||||
|
VTLDrives int `json:"vtl_drives"`
|
||||||
|
VTLTapes int `json:"vtl_tapes"`
|
||||||
} `json:"services"`
|
} `json:"services"`
|
||||||
Jobs struct {
|
Jobs struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -93,6 +96,16 @@ func (a *App) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
data.Services.ISCSIStatus, _ = a.iscsiService.GetStatus()
|
data.Services.ISCSIStatus, _ = a.iscsiService.GetStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VTL status
|
||||||
|
if a.vtlService != nil {
|
||||||
|
vtlStatus, err := a.vtlService.GetStatus()
|
||||||
|
if err == nil {
|
||||||
|
data.Services.VTLStatus = vtlStatus.ServiceRunning
|
||||||
|
data.Services.VTLDrives = vtlStatus.DrivesOnline
|
||||||
|
data.Services.VTLTapes = vtlStatus.TapesAvailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Job statistics
|
// Job statistics
|
||||||
allJobs := a.jobManager.List("")
|
allJobs := a.jobManager.List("")
|
||||||
data.Jobs.Total = len(allJobs)
|
data.Jobs.Total = len(allJobs)
|
||||||
|
|||||||
@@ -172,6 +172,24 @@ func (a *App) routes() {
|
|||||||
func(w http.ResponseWriter, r *http.Request) { a.handleVTLServiceControl(w, r) },
|
func(w http.ResponseWriter, r *http.Request) { a.handleVTLServiceControl(w, r) },
|
||||||
nil, nil, nil,
|
nil, nil, nil,
|
||||||
))
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/vtl/changers", methodHandler(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { a.handleListVTLMediaChangers(w, r) },
|
||||||
|
nil, nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/vtl/changer/status", methodHandler(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { a.handleGetVTLMediaChangerStatus(w, r) },
|
||||||
|
nil, nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/vtl/tape/load", methodHandler(
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { a.handleLoadTape(w, r) },
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/vtl/tape/eject", methodHandler(
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { a.handleEjectTape(w, r) },
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
|
||||||
// Job Management
|
// Job Management
|
||||||
a.mux.HandleFunc("/api/v1/jobs", methodHandler(
|
a.mux.HandleFunc("/api/v1/jobs", methodHandler(
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ func (a *App) handleCreateVTLTape(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Barcode string `json:"barcode"`
|
Barcode string `json:"barcode"`
|
||||||
Type string `json:"type"` // e.g., "LTO-5", "LTO-6"
|
Type string `json:"type"` // e.g., "LTO-5", "LTO-6"
|
||||||
Size uint64 `json:"size"` // Size in bytes (0 = default)
|
Size uint64 `json:"size"` // Size in bytes (0 = default, will use generation-based size)
|
||||||
|
LibraryID int `json:"library_id"` // Library ID where tape will be placed
|
||||||
|
SlotID int `json:"slot_id"` // Slot ID in library where tape will be placed
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -66,11 +68,22 @@ func (a *App) handleCreateVTLTape(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Type == "" {
|
if req.LibraryID <= 0 {
|
||||||
req.Type = "LTO-5" // Default type
|
writeError(w, errors.ErrValidation("library_id is required and must be greater than 0"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.vtlService.CreateTape(req.Barcode, req.Type, req.Size); err != nil {
|
if req.SlotID <= 0 {
|
||||||
|
writeError(w, errors.ErrValidation("slot_id is required and must be greater than 0"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type == "" {
|
||||||
|
// Will be determined from barcode suffix if not provided
|
||||||
|
req.Type = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.vtlService.CreateTape(req.Barcode, req.Type, req.Size, req.LibraryID, req.SlotID); err != nil {
|
||||||
log.Printf("create VTL tape error: %v", err)
|
log.Printf("create VTL tape error: %v", err)
|
||||||
writeError(w, errors.ErrInternal(fmt.Sprintf("failed to create VTL tape: %v", err)))
|
writeError(w, errors.ErrInternal(fmt.Sprintf("failed to create VTL tape: %v", err)))
|
||||||
return
|
return
|
||||||
@@ -188,3 +201,87 @@ func (a *App) handleGetVTLTape(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeError(w, errors.ErrNotFound("tape not found"))
|
writeError(w, errors.ErrNotFound("tape not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleListVTLMediaChangers returns all media changers
|
||||||
|
func (a *App) handleListVTLMediaChangers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
changers, err := a.vtlService.ListMediaChangers()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("list VTL media changers error: %v", err)
|
||||||
|
writeError(w, errors.ErrInternal(fmt.Sprintf("failed to list VTL media changers: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, changers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetVTLMediaChangerStatus returns media changer status (all changers)
|
||||||
|
func (a *App) handleGetVTLMediaChangerStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
changers, err := a.vtlService.ListMediaChangers()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("get VTL media changer status error: %v", err)
|
||||||
|
writeError(w, errors.ErrInternal(fmt.Sprintf("failed to get VTL media changer status: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all changers, or first one if only one is requested
|
||||||
|
if len(changers) == 0 {
|
||||||
|
writeError(w, errors.ErrNotFound("no media changer found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all changers as array
|
||||||
|
writeJSON(w, http.StatusOK, changers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoadTape loads a tape into a drive
|
||||||
|
func (a *App) handleLoadTape(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
DriveID int `json:"drive_id"`
|
||||||
|
Barcode string `json:"barcode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, errors.ErrValidation(fmt.Sprintf("invalid request body: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Barcode == "" {
|
||||||
|
writeError(w, errors.ErrValidation("barcode is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.vtlService.LoadTape(req.DriveID, req.Barcode); err != nil {
|
||||||
|
log.Printf("load tape error: %v", err)
|
||||||
|
writeError(w, errors.ErrInternal(fmt.Sprintf("failed to load tape: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Tape loaded successfully",
|
||||||
|
"barcode": req.Barcode,
|
||||||
|
"drive_id": fmt.Sprintf("%d", req.DriveID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEjectTape ejects a tape from a drive
|
||||||
|
func (a *App) handleEjectTape(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
DriveID int `json:"drive_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, errors.ErrValidation(fmt.Sprintf("invalid request body: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.vtlService.EjectTape(req.DriveID); err != nil {
|
||||||
|
log.Printf("eject tape error: %v", err)
|
||||||
|
writeError(w, errors.ErrInternal(fmt.Sprintf("failed to eject tape: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Tape ejected successfully",
|
||||||
|
"drive_id": fmt.Sprintf("%d", req.DriveID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,10 @@
|
|||||||
<span class="text-slate-300">iSCSI Target</span>
|
<span class="text-slate-300">iSCSI Target</span>
|
||||||
<span id="iscsi-status" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
<span id="iscsi-status" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-300">VTL (mhvtl)</span>
|
||||||
|
<span id="vtl-status" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-slate-300">API Status</span>
|
<span class="text-slate-300">API Status</span>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium bg-green-900 text-green-300">Online</span>
|
<span class="px-3 py-1 rounded-full text-xs font-medium bg-green-900 text-green-300">Online</span>
|
||||||
@@ -153,6 +157,7 @@
|
|||||||
updateStatus('smb-status', data.services.smb_status);
|
updateStatus('smb-status', data.services.smb_status);
|
||||||
updateStatus('nfs-status', data.services.nfs_status);
|
updateStatus('nfs-status', data.services.nfs_status);
|
||||||
updateStatus('iscsi-status', data.services.iscsi_status);
|
updateStatus('iscsi-status', data.services.iscsi_status);
|
||||||
|
updateStatus('vtl-status', data.services.vtl_status);
|
||||||
|
|
||||||
// Update jobs
|
// Update jobs
|
||||||
document.getElementById('jobs-running').textContent = data.jobs.running || 0;
|
document.getElementById('jobs-running').textContent = data.jobs.running || 0;
|
||||||
|
|||||||
890
web/templates/vtl.html
Normal file
890
web/templates/vtl.html
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
{{define "vtl-content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Virtual Tape Library</h1>
|
||||||
|
<p class="text-slate-400">Manage mhvtl drives, tapes, and media changer</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="refreshVTLStatus()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button onclick="showServiceControl()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
Service Control
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VTL Status Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Service</h2>
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-white mb-1" id="vtl-service-status">-</p>
|
||||||
|
<p class="text-sm text-slate-400">mhvtl Service</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Drives</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-white mb-1" id="vtl-drives-online">-</p>
|
||||||
|
<p class="text-sm text-slate-400"><span id="vtl-drives-total">-</span> Total</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Tapes</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-white mb-1" id="vtl-tapes-available">-</p>
|
||||||
|
<p class="text-sm text-slate-400"><span id="vtl-tapes-total">-</span> Total</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Media Changer</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-white mb-1" id="vtl-changer-status">-</p>
|
||||||
|
<p class="text-sm text-slate-400" id="vtl-changer-detail">Libraries</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs for Drives, Tapes, and Media Changer -->
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="flex border-b border-slate-700">
|
||||||
|
<button onclick="switchVTLTab('drives')" id="vtl-tab-drives" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-blue-600 text-blue-400 bg-slate-800">
|
||||||
|
Tape Drives
|
||||||
|
</button>
|
||||||
|
<button onclick="switchVTLTab('tapes')" id="vtl-tab-tapes" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-transparent text-slate-400 hover:text-white">
|
||||||
|
Tape Media
|
||||||
|
</button>
|
||||||
|
<button onclick="switchVTLTab('changer')" id="vtl-tab-changer" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-transparent text-slate-400 hover:text-white">
|
||||||
|
Media Changer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drives Tab -->
|
||||||
|
<div id="vtl-content-drives" class="tab-content">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Virtual Tape Drives</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Manage tape drives and loaded media</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadVTLDrives()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="vtl-drives-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tapes Tab -->
|
||||||
|
<div id="vtl-content-tapes" class="tab-content hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Virtual Tape Media</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Create, manage, and delete virtual tapes</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="loadVTLTapes()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
<button onclick="showCreateTapeModal()" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
Create Tape
|
||||||
|
</button>
|
||||||
|
<button onclick="showBulkCreateTapeModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
Bulk Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="vtl-tapes-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Changer Tab -->
|
||||||
|
<div id="vtl-content-changer" class="tab-content hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Media Changer</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Manage tape library slots and operations</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadMediaChanger()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="vtl-changer-content" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Tape Modal -->
|
||||||
|
<div id="create-tape-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Virtual Tape</h3>
|
||||||
|
<form id="create-tape-form" onsubmit="createTape(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Barcode <span class="text-red-400">*</span></label>
|
||||||
|
<input type="text" name="barcode" id="tape-barcode" placeholder="E01001L8" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Must end with LTO suffix (L1-L9, LW, LT, LU, LV). Example: E01001L8</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Library ID <span class="text-red-400">*</span></label>
|
||||||
|
<select name="library_id" id="tape-library-id" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Select Library...</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Library where tape will be placed</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Slot ID <span class="text-red-400">*</span></label>
|
||||||
|
<input type="number" name="slot_id" id="tape-slot-id" placeholder="1" required min="1" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Slot number in the library (must be greater than 0)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Tape Type</label>
|
||||||
|
<select name="type" id="tape-type" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Auto-detect from barcode</option>
|
||||||
|
<option value="LTO-5">LTO-5 (3 TB compressed)</option>
|
||||||
|
<option value="LTO-6">LTO-6 (6.25 TB compressed)</option>
|
||||||
|
<option value="LTO-7">LTO-7 (15 TB compressed)</option>
|
||||||
|
<option value="LTO-8">LTO-8 (30 TB compressed)</option>
|
||||||
|
<option value="LTO-9">LTO-9 (45 TB compressed)</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Leave empty to auto-detect from barcode suffix</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Size (bytes)</label>
|
||||||
|
<input type="number" name="size" id="tape-size" value="0" min="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Leave 0 to use default size for tape generation</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-tape-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Create Tape Modal -->
|
||||||
|
<div id="bulk-create-tape-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Bulk Create Virtual Tapes</h3>
|
||||||
|
<form id="bulk-create-tape-form" onsubmit="bulkCreateTapes(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Prefix <span class="text-red-400">*</span></label>
|
||||||
|
<input type="text" name="prefix" id="bulk-tape-prefix" placeholder="AVT" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Barcode prefix (e.g., "AVT" will create AVT001L8, AVT002L8, ...). Suffix LTO will be added automatically.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Library ID <span class="text-red-400">*</span></label>
|
||||||
|
<select name="library_id" id="bulk-tape-library-id" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Select Library...</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Library where tapes will be placed</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Start Slot ID <span class="text-red-400">*</span></label>
|
||||||
|
<input type="number" name="start_slot" id="bulk-tape-start-slot" placeholder="1" required min="1" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Starting slot number (tapes will be placed in consecutive slots)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Count</label>
|
||||||
|
<input type="number" name="count" id="bulk-tape-count" value="10" min="1" max="100" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Number of tapes to create</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Start Number</label>
|
||||||
|
<input type="number" name="start" id="bulk-tape-start" value="1" min="1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Starting number for barcode sequence</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Tape Type</label>
|
||||||
|
<select name="type" id="bulk-tape-type" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Auto-detect from suffix</option>
|
||||||
|
<option value="LTO-5">LTO-5 (3 TB compressed)</option>
|
||||||
|
<option value="LTO-6">LTO-6 (6.25 TB compressed)</option>
|
||||||
|
<option value="LTO-7">LTO-7 (15 TB compressed)</option>
|
||||||
|
<option value="LTO-8">LTO-8 (30 TB compressed)</option>
|
||||||
|
<option value="LTO-9">LTO-9 (45 TB compressed)</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Leave empty to auto-detect from barcode suffix (L1-L9, LW, LT, LU, LV)</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('bulk-create-tape-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Control Modal -->
|
||||||
|
<div id="service-control-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">mhvtl Service Control</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-300">Service Status</span>
|
||||||
|
<span id="service-status-badge" class="px-3 py-1 rounded-full text-xs font-medium bg-slate-700 text-slate-300">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button onclick="controlVTLService('start')" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-sm">
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button onclick="controlVTLService('stop')" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button onclick="controlVTLService('restart')" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button onclick="closeModal('service-control-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Check authentication on page load
|
||||||
|
(function() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
let currentVTLTab = 'drives';
|
||||||
|
function switchVTLTab(tab) {
|
||||||
|
currentVTLTab = tab;
|
||||||
|
|
||||||
|
// Update tab buttons
|
||||||
|
['drives', 'tapes', 'changer'].forEach(t => {
|
||||||
|
const tabEl = document.getElementById(`vtl-tab-${t}`);
|
||||||
|
const contentEl = document.getElementById(`vtl-content-${t}`);
|
||||||
|
|
||||||
|
if (t === tab) {
|
||||||
|
tabEl.classList.add('border-blue-600', 'text-blue-400');
|
||||||
|
tabEl.classList.remove('border-transparent', 'text-slate-400');
|
||||||
|
contentEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
tabEl.classList.remove('border-blue-600', 'text-blue-400');
|
||||||
|
tabEl.classList.add('border-transparent', 'text-slate-400');
|
||||||
|
contentEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load data for active tab
|
||||||
|
if (tab === 'drives') loadVTLDrives();
|
||||||
|
else if (tab === 'tapes') loadVTLTapes();
|
||||||
|
else if (tab === 'changer') loadMediaChanger();
|
||||||
|
}
|
||||||
|
|
||||||
|
// VTL Status
|
||||||
|
async function refreshVTLStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/status', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch VTL status');
|
||||||
|
|
||||||
|
const status = await res.json();
|
||||||
|
|
||||||
|
// Update status cards
|
||||||
|
document.getElementById('vtl-service-status').textContent = status.service_running ? 'Running' : 'Stopped';
|
||||||
|
document.getElementById('vtl-service-status').className = status.service_running
|
||||||
|
? 'text-2xl font-bold text-green-400 mb-1'
|
||||||
|
: 'text-2xl font-bold text-red-400 mb-1';
|
||||||
|
|
||||||
|
document.getElementById('vtl-drives-online').textContent = status.drives_online || 0;
|
||||||
|
document.getElementById('vtl-drives-total').textContent = status.drives_total || 0;
|
||||||
|
document.getElementById('vtl-tapes-available').textContent = status.tapes_available || 0;
|
||||||
|
document.getElementById('vtl-tapes-total').textContent = status.tapes_total || 0;
|
||||||
|
|
||||||
|
// Update media changer status
|
||||||
|
updateMediaChangerStatus();
|
||||||
|
|
||||||
|
// Refresh current tab
|
||||||
|
if (currentVTLTab === 'drives') loadVTLDrives();
|
||||||
|
else if (currentVTLTab === 'tapes') loadVTLTapes();
|
||||||
|
else if (currentVTLTab === 'changer') loadMediaChanger();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error refreshing VTL status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Drives
|
||||||
|
async function loadVTLDrives() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/drives', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to load drives');
|
||||||
|
|
||||||
|
const drives = await res.json();
|
||||||
|
const listEl = document.getElementById('vtl-drives-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(drives) || drives.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No tape drives found. Make sure mhvtl is installed and configured.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = drives.map(drive => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Drive ${drive.id}</h3>
|
||||||
|
${drive.library_id ? `<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">Library ${drive.library_id}</span>` : ''}
|
||||||
|
${drive.status === 'online'
|
||||||
|
? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Online</span>'
|
||||||
|
: '<span class="px-2 py-1 rounded text-xs font-medium bg-red-900 text-red-300">Offline</span>'}
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">${drive.type || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
<p><span class="text-slate-300">Library ID:</span> ${drive.library_id || 'N/A'}</p>
|
||||||
|
<p><span class="text-slate-300">Device:</span> ${drive.device}</p>
|
||||||
|
<p><span class="text-slate-300">Vendor:</span> ${drive.vendor || 'Unknown'}</p>
|
||||||
|
<p><span class="text-slate-300">Product:</span> ${drive.product || 'Unknown'}</p>
|
||||||
|
${drive.slot_id > 0 ? `<p><span class="text-slate-300">Slot:</span> ${drive.slot_id}</p>` : ''}
|
||||||
|
${drive.media_loaded
|
||||||
|
? `<p><span class="text-slate-300">Media:</span> <span class="text-green-300">Loaded - ${drive.barcode || 'Unknown'}</span></p>`
|
||||||
|
: '<p><span class="text-slate-300">Media:</span> <span class="text-slate-500">Not Loaded</span></p>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
${drive.media_loaded
|
||||||
|
? `<button onclick="ejectTape(${drive.id})" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm">Eject</button>`
|
||||||
|
: `<button onclick="loadTape(${drive.id})" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">Load Tape</button>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('vtl-drives-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Tapes
|
||||||
|
async function loadVTLTapes() {
|
||||||
|
try {
|
||||||
|
// Add timestamp to prevent caching
|
||||||
|
const res = await fetch('/api/v1/vtl/tapes?_=' + Date.now(), { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to load tapes');
|
||||||
|
|
||||||
|
const tapes = await res.json();
|
||||||
|
const listEl = document.getElementById('vtl-tapes-list');
|
||||||
|
|
||||||
|
console.log('Loaded tapes:', tapes); // Debug log
|
||||||
|
|
||||||
|
if (!Array.isArray(tapes) || tapes.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No tapes found. Create your first virtual tape.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-slate-400">Total: ${tapes.length} tapes</span>
|
||||||
|
<button onclick="bulkDeleteTapes()" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Bulk Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
${tapes.map(tape => `
|
||||||
|
<div class="bg-slate-900 rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${tape.barcode}</h3>
|
||||||
|
${tape.status === 'available'
|
||||||
|
? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Available</span>'
|
||||||
|
: tape.status === 'in_use'
|
||||||
|
? '<span class="px-2 py-1 rounded text-xs font-medium bg-yellow-900 text-yellow-300">In Use</span>'
|
||||||
|
: '<span class="px-2 py-1 rounded text-xs font-medium bg-red-900 text-red-300">Error</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1 mb-3">
|
||||||
|
<p><span class="text-slate-300">Type:</span> ${tape.type || 'Unknown'}</p>
|
||||||
|
${tape.library_id ? `<p><span class="text-slate-300">Library ID:</span> ${tape.library_id}</p>` : ''}
|
||||||
|
${tape.slot_id > 0 ? `<p><span class="text-slate-300">Slot:</span> ${tape.slot_id}</p>` : ''}
|
||||||
|
<p><span class="text-slate-300">Size:</span> ${formatBytes(tape.size || 0)}</p>
|
||||||
|
<p><span class="text-slate-300">Used:</span> ${formatBytes(tape.used || 0)}</p>
|
||||||
|
${tape.drive_id >= 0 ? `<p><span class="text-slate-300">Loaded in:</span> Drive ${tape.drive_id}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="deleteTape('${tape.barcode}')" class="flex-1 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('vtl-tapes-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Media Changer
|
||||||
|
async function loadMediaChanger() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/changer/status', { headers: getAuthHeaders() });
|
||||||
|
const changerEl = document.getElementById('vtl-changer-content');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
changerEl.innerHTML = `
|
||||||
|
<div class="bg-slate-900 rounded-lg p-6 border border-slate-700">
|
||||||
|
<p class="text-slate-400 text-sm mb-4">No media changer found or mhvtl not configured.</p>
|
||||||
|
<p class="text-slate-500 text-xs">This feature requires mhvtl media changer configuration.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changers = await res.json();
|
||||||
|
|
||||||
|
// Handle both single changer and array of changers
|
||||||
|
const changerList = Array.isArray(changers) ? changers : [changers];
|
||||||
|
|
||||||
|
if (changerList.length === 0) {
|
||||||
|
changerEl.innerHTML = `
|
||||||
|
<div class="bg-slate-900 rounded-lg p-6 border border-slate-700">
|
||||||
|
<p class="text-slate-400 text-sm mb-4">No media changer found.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changerEl.innerHTML = changerList.map(changer => `
|
||||||
|
<div class="bg-slate-900 rounded-lg p-6 border border-slate-700 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Media Changer ${changer.id || 'N/A'}</h3>
|
||||||
|
${changer.status === 'online'
|
||||||
|
? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Online</span>'
|
||||||
|
: '<span class="px-2 py-1 rounded text-xs font-medium bg-red-900 text-red-300">Offline</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-2">
|
||||||
|
<p><span class="text-slate-300">Device:</span> ${changer.device || 'N/A'}</p>
|
||||||
|
<p><span class="text-slate-300">Library ID:</span> ${changer.library_id || 'N/A'}</p>
|
||||||
|
<p><span class="text-slate-300">Slots:</span> ${changer.slots || 0}</p>
|
||||||
|
<p><span class="text-slate-300">Drives:</span> ${changer.drives || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('vtl-changer-content').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Tape
|
||||||
|
async function showCreateTapeModal() {
|
||||||
|
// Load available libraries
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/changers', { headers: getAuthHeaders() });
|
||||||
|
if (res.ok) {
|
||||||
|
const changers = await res.json();
|
||||||
|
const changerList = Array.isArray(changers) ? changers : [changers];
|
||||||
|
const librarySelect = document.getElementById('tape-library-id');
|
||||||
|
librarySelect.innerHTML = '<option value="">Select Library...</option>';
|
||||||
|
|
||||||
|
changerList.forEach(changer => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = changer.library_id || changer.id;
|
||||||
|
option.textContent = `Library ${changer.library_id || changer.id} (${changer.slots || 0} slots)`;
|
||||||
|
librarySelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load libraries:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('create-tape-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTape(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
const libraryID = parseInt(formData.get('library_id'));
|
||||||
|
const slotID = parseInt(formData.get('slot_id'));
|
||||||
|
|
||||||
|
if (!libraryID || libraryID <= 0) {
|
||||||
|
alert('Error: Please select a valid Library ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slotID || slotID <= 0) {
|
||||||
|
alert('Error: Please enter a valid Slot ID (must be greater than 0)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/tapes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
barcode: formData.get('barcode'),
|
||||||
|
type: formData.get('type') || '',
|
||||||
|
size: parseInt(formData.get('size')) || 0,
|
||||||
|
library_id: libraryID,
|
||||||
|
slot_id: slotID
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-tape-modal');
|
||||||
|
e.target.reset();
|
||||||
|
// Wait a bit for mhvtl service to restart and refresh
|
||||||
|
alert('Tape created successfully. Refreshing...');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadVTLTapes();
|
||||||
|
refreshVTLStatus();
|
||||||
|
}, 2000); // Wait 2 seconds for service restart
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Failed to create tape' }));
|
||||||
|
alert(`Error: ${err.error || err.message || 'Failed to create tape'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Create Tapes
|
||||||
|
async function showBulkCreateTapeModal() {
|
||||||
|
// Load available libraries
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/changers', { headers: getAuthHeaders() });
|
||||||
|
if (res.ok) {
|
||||||
|
const changers = await res.json();
|
||||||
|
const changerList = Array.isArray(changers) ? changers : [changers];
|
||||||
|
const librarySelect = document.getElementById('bulk-tape-library-id');
|
||||||
|
librarySelect.innerHTML = '<option value="">Select Library...</option>';
|
||||||
|
|
||||||
|
changerList.forEach(changer => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = changer.library_id || changer.id;
|
||||||
|
option.textContent = `Library ${changer.library_id || changer.id} (${changer.slots || 0} slots)`;
|
||||||
|
librarySelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load libraries:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('bulk-create-tape-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkCreateTapes(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const prefix = formData.get('prefix');
|
||||||
|
const libraryID = parseInt(formData.get('library_id'));
|
||||||
|
const startSlot = parseInt(formData.get('start_slot'));
|
||||||
|
const count = parseInt(formData.get('count'));
|
||||||
|
const start = parseInt(formData.get('start'));
|
||||||
|
const type = formData.get('type') || '';
|
||||||
|
|
||||||
|
if (!libraryID || libraryID <= 0) {
|
||||||
|
alert('Error: Please select a valid Library ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startSlot || startSlot <= 0) {
|
||||||
|
alert('Error: Please enter a valid Start Slot ID (must be greater than 0)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine LTO suffix from type, or use default L8
|
||||||
|
let ltoSuffix = 'L8'; // Default to LTO-8
|
||||||
|
if (type) {
|
||||||
|
const typeMap = {
|
||||||
|
'LTO-1': 'L1', 'LTO-2': 'L2', 'LTO-3': 'L3', 'LTO-4': 'L4',
|
||||||
|
'LTO-5': 'L5', 'LTO-6': 'L6', 'LTO-7': 'L7', 'LTO-8': 'L8', 'LTO-9': 'L9'
|
||||||
|
};
|
||||||
|
ltoSuffix = typeMap[type] || 'L8';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Create ${count} tapes with prefix "${prefix}" starting from ${start} in Library ${libraryID}, slots ${startSlot} to ${startSlot + count - 1}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const barcode = `${prefix}${String(start + i).padStart(3, '0')}${ltoSuffix}`;
|
||||||
|
const slotID = startSlot + i;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/tapes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
barcode: barcode,
|
||||||
|
type: type,
|
||||||
|
size: 0,
|
||||||
|
library_id: libraryID,
|
||||||
|
slot_id: slotID
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
success++;
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Failed to create tape' }));
|
||||||
|
console.error(`Failed to create tape ${barcode}:`, err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error creating tape ${barcode}:`, err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal('bulk-create-tape-modal');
|
||||||
|
e.target.reset();
|
||||||
|
|
||||||
|
// Wait a bit for service restart and refresh
|
||||||
|
setTimeout(() => {
|
||||||
|
loadVTLTapes();
|
||||||
|
refreshVTLStatus();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
alert(`Bulk create completed: ${success} created, ${failed} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Tape
|
||||||
|
async function deleteTape(barcode) {
|
||||||
|
if (!confirm(`Are you sure you want to delete tape "${barcode}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/vtl/tapes/${barcode}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadVTLTapes();
|
||||||
|
refreshVTLStatus();
|
||||||
|
alert('Tape deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Failed to delete tape' }));
|
||||||
|
alert(`Error: ${err.error || err.message || 'Failed to delete tape'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Delete Tapes
|
||||||
|
async function bulkDeleteTapes() {
|
||||||
|
if (!confirm('Are you sure you want to delete ALL tapes? This action cannot be undone!')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/tapes', { headers: getAuthHeaders() });
|
||||||
|
const tapes = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(tapes) || tapes.length === 0) {
|
||||||
|
alert('No tapes to delete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const tape of tapes) {
|
||||||
|
try {
|
||||||
|
const delRes = await fetch(`/api/v1/vtl/tapes/${tape.barcode}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (delRes.ok) {
|
||||||
|
deleted++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVTLTapes();
|
||||||
|
refreshVTLStatus();
|
||||||
|
alert(`Bulk delete completed: ${deleted} deleted, ${failed} failed`);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service Control
|
||||||
|
function showServiceControl() {
|
||||||
|
refreshVTLStatus().then(() => {
|
||||||
|
const statusEl = document.getElementById('service-status-badge');
|
||||||
|
const status = document.getElementById('vtl-service-status').textContent;
|
||||||
|
statusEl.textContent = status;
|
||||||
|
statusEl.className = status === 'Running'
|
||||||
|
? 'px-3 py-1 rounded-full text-xs font-medium bg-green-900 text-green-300'
|
||||||
|
: 'px-3 py-1 rounded-full text-xs font-medium bg-red-900 text-red-300';
|
||||||
|
document.getElementById('service-control-modal').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function controlVTLService(action) {
|
||||||
|
if (!confirm(`Are you sure you want to ${action} the mhvtl service?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/service', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ action: action })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshVTLStatus();
|
||||||
|
closeModal('service-control-modal');
|
||||||
|
alert(`Service ${action}ed successfully`);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: `Failed to ${action} service` }));
|
||||||
|
alert(`Error: ${err.error || err.message || `Failed to ${action} service`}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tape Operations
|
||||||
|
async function loadTape(driveId) {
|
||||||
|
const barcode = prompt('Enter tape barcode to load:');
|
||||||
|
if (!barcode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/tape/load', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
drive_id: driveId,
|
||||||
|
barcode: barcode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadVTLDrives();
|
||||||
|
refreshVTLStatus();
|
||||||
|
alert('Tape loaded successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Failed to load tape' }));
|
||||||
|
alert(`Error: ${err.error || err.message || 'Failed to load tape'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ejectTape(driveId) {
|
||||||
|
if (!confirm(`Are you sure you want to eject the tape from drive ${driveId}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/tape/eject', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
drive_id: driveId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadVTLDrives();
|
||||||
|
refreshVTLStatus();
|
||||||
|
alert('Tape ejected successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Failed to eject tape' }));
|
||||||
|
alert(`Error: ${err.error || err.message || 'Failed to eject tape'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Media Changer Status in dashboard
|
||||||
|
async function updateMediaChangerStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/vtl/changer/status', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('vtl-changer-status').textContent = 'N/A';
|
||||||
|
document.getElementById('vtl-changer-detail').textContent = 'No Libraries';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changers = await res.json();
|
||||||
|
const changerList = Array.isArray(changers) ? changers : [changers];
|
||||||
|
|
||||||
|
if (changerList.length === 0) {
|
||||||
|
document.getElementById('vtl-changer-status').textContent = 'None';
|
||||||
|
document.getElementById('vtl-changer-detail').textContent = 'No Libraries';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show count of online changers
|
||||||
|
const onlineCount = changerList.filter(c => c.status === 'online').length;
|
||||||
|
const totalCount = changerList.length;
|
||||||
|
document.getElementById('vtl-changer-status').textContent = `${onlineCount}/${totalCount}`;
|
||||||
|
document.getElementById('vtl-changer-detail').textContent = `${totalCount} Libraries`;
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('vtl-changer-status').textContent = 'Error';
|
||||||
|
document.getElementById('vtl-changer-detail').textContent = 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
refreshVTLStatus();
|
||||||
|
loadVTLDrives();
|
||||||
|
|
||||||
|
// Auto-refresh every 1 minute (60000 ms)
|
||||||
|
setInterval(() => {
|
||||||
|
console.log('Auto-refreshing VTL status...');
|
||||||
|
refreshVTLStatus();
|
||||||
|
|
||||||
|
// Refresh current tab content
|
||||||
|
if (currentVTLTab === 'drives') {
|
||||||
|
loadVTLDrives();
|
||||||
|
} else if (currentVTLTab === 'tapes') {
|
||||||
|
loadVTLTapes();
|
||||||
|
} else if (currentVTLTab === 'changer') {
|
||||||
|
loadMediaChanger();
|
||||||
|
}
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "vtl.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
Reference in New Issue
Block a user