feat: Add Library Visualizer (Slots & Drives) to Web UI

This commit is contained in:
2025-12-10 14:36:24 +00:00
parent 0b026aa11f
commit 79cf24cb8c
10 changed files with 491 additions and 1 deletions

Binary file not shown.

View File

@@ -1,4 +1,4 @@
Adastra VTL Installer
Version: 1.0.0
Build Date: 2025-12-10 12:25:11
Build Date: 2025-12-10 14:36:24
Build Host: vtl-dev

View File

@@ -148,10 +148,16 @@ switch ($action) {
getDeviceMapping();
break;
case 'library_status':
getLibraryStatus();
break;
case 'system_health':
getSystemHealth();
break;
case 'restart_appliance':
restartAppliance();
break;
@@ -938,4 +944,59 @@ function getDeviceMapping() {
'raw_output' => $output
]);
}
function getLibraryStatus() {
// Find library contents file
$files = glob('/etc/mhvtl/library_contents.*');
if (empty($files)) {
echo json_encode(['success' => false, 'error' => 'No library config found']);
return;
}
// Use the first one found, typically library_contents.10
$file = $files[0];
$libId = substr(strrchr($file, '.'), 1);
$lines = file($file);
$status = [
'library_id' => $libId,
'drives' => [],
'slots' => [],
'maps' => [],
'pickers' => []
];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] == '#') continue;
if (preg_match('/^Drive\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['drives'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
} elseif (preg_match('/^Slot\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['slots'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
} elseif (preg_match('/^MAP\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['maps'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
} elseif (preg_match('/^Picker\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['pickers'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
}
}
echo json_encode(['success' => true, 'data' => $status]);
}
?>

View File

@@ -208,6 +208,35 @@
<p>Complete CRUD management for virtual tape files</p>
</div>
<div class="card">
<div class="card-header">
<h3>👀 Library Visualizer</h3>
<button class="btn btn-primary btn-small" onclick="loadLibraryStatus()" style="float: right;">
<span>🔄</span> Refresh
</button>
</div>
<div class="card-body">
<div id="viz-loading" style="display: none; text-align: center; padding: 2rem;">
<strong></strong> Loading library status...
</div>
<div id="viz-error" class="alert alert-danger" style="display: none;"></div>
<div id="library-viz" class="library-viz" style="display: none;">
<div class="viz-section">
<h4>Drives</h4>
<div id="viz-drives" class="viz-grid"></div>
</div>
<div class="viz-section">
<h4>MAPs / Ports</h4>
<div id="viz-maps" class="viz-grid"></div>
</div>
<div class="viz-section">
<h4>Storage Slots</h4>
<div id="viz-slots" class="viz-grid"></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3> Create New Tapes</h3>

View File

@@ -87,6 +87,11 @@ function initNavigation() {
this.classList.add('active');
document.getElementById(targetId).classList.add('active');
// Auto reload for Viz
if (targetId === 'manage-tapes') {
loadLibraryStatus();
}
});
});
}
@@ -1941,3 +1946,63 @@ async function changeUserPassword() {
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
}
}
// Library Visualizer
function loadLibraryStatus() {
const loading = document.getElementById('viz-loading');
const container = document.getElementById('library-viz');
const errorEl = document.getElementById('viz-error');
if (!loading || !container || !errorEl) return;
loading.style.display = 'block';
container.style.display = 'none';
errorEl.style.display = 'none';
fetch('api.php?action=library_status')
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
if (data.success) {
container.style.display = 'flex';
renderVizGrid('viz-drives', data.data.drives, 'drive');
renderVizGrid('viz-maps', data.data.maps, 'map');
renderVizGrid('viz-slots', data.data.slots, 'slot');
} else {
errorEl.textContent = 'Error: ' + data.error;
errorEl.style.display = 'block';
}
})
.catch(err => {
loading.style.display = 'none';
errorEl.textContent = 'Fetch Error: ' + err.message;
errorEl.style.display = 'block';
});
}
function renderVizGrid(containerId, items, type) {
const grid = document.getElementById(containerId);
if (!grid) return;
grid.innerHTML = '';
if (!items || items.length === 0) {
grid.innerHTML = '<div style="color:#aaa; font-style:italic; grid-column: 1/-1;">No items</div>';
return;
}
items.forEach(item => {
const el = document.createElement('div');
el.className = `viz-slot ${item.full ? 'full' : 'empty'} ${type === 'drive' ? 'drive-slot' : ''}`;
const icon = type === 'drive' ? '💾' : (type === 'map' ? '📥' : '📼');
const label = item.full ? item.barcode : 'Empty';
el.innerHTML = `
<span class="slot-icon">${icon}</span>
<div class="slot-id">${type.toUpperCase()} ${item.id}</div>
<div class="tape-label" title="${label}">${label}</div>
`;
grid.appendChild(el);
});
}

View File

@@ -440,3 +440,93 @@ main.container {
font-size: 0.875rem;
}
}
/* Library Visualizer Styles */
.library-viz {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.viz-section h4 {
margin-bottom: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.05em;
}
.viz-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
}
.viz-slot {
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem 0.5rem;
text-align: center;
transition: all 0.2s;
cursor: default;
position: relative;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.viz-slot:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}
.viz-slot.full {
background: rgba(16, 185, 129, 0.1);
border-color: var(--success-color);
}
.viz-slot .slot-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
display: block;
}
.viz-slot .slot-id {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
font-weight: 500;
}
.viz-slot .tape-label {
font-size: 0.75rem;
font-weight: 600;
font-family: 'Courier New', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
}
.viz-slot.full .tape-label {
color: var(--success-color);
}
.viz-slot.empty .tape-label {
color: var(--text-secondary);
font-style: italic;
opacity: 0.5;
}
/* Drive specific styling */
.drive-slot {
border-color: var(--info-color);
}
.drive-slot.full {
background: rgba(59, 130, 246, 0.1);
border-color: var(--info-color);
}
.drive-slot.full .tape-label {
color: var(--info-color);
}

View File

@@ -148,10 +148,16 @@ switch ($action) {
getDeviceMapping();
break;
case 'library_status':
getLibraryStatus();
break;
case 'system_health':
getSystemHealth();
break;
case 'restart_appliance':
restartAppliance();
break;
@@ -938,4 +944,59 @@ function getDeviceMapping() {
'raw_output' => $output
]);
}
function getLibraryStatus() {
// Find library contents file
$files = glob('/etc/mhvtl/library_contents.*');
if (empty($files)) {
echo json_encode(['success' => false, 'error' => 'No library config found']);
return;
}
// Use the first one found, typically library_contents.10
$file = $files[0];
$libId = substr(strrchr($file, '.'), 1);
$lines = file($file);
$status = [
'library_id' => $libId,
'drives' => [],
'slots' => [],
'maps' => [],
'pickers' => []
];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] == '#') continue;
if (preg_match('/^Drive\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['drives'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
} elseif (preg_match('/^Slot\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['slots'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
} elseif (preg_match('/^MAP\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['maps'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
} elseif (preg_match('/^Picker\s+(\d+):\s*(.*)$/i', $line, $matches)) {
$status['pickers'][] = [
'id' => intval($matches[1]),
'barcode' => trim($matches[2]),
'full' => !empty(trim($matches[2]))
];
}
}
echo json_encode(['success' => true, 'data' => $status]);
}
?>

View File

@@ -208,6 +208,35 @@
<p>Complete CRUD management for virtual tape files</p>
</div>
<div class="card">
<div class="card-header">
<h3>👀 Library Visualizer</h3>
<button class="btn btn-primary btn-small" onclick="loadLibraryStatus()" style="float: right;">
<span>🔄</span> Refresh
</button>
</div>
<div class="card-body">
<div id="viz-loading" style="display: none; text-align: center; padding: 2rem;">
<strong></strong> Loading library status...
</div>
<div id="viz-error" class="alert alert-danger" style="display: none;"></div>
<div id="library-viz" class="library-viz" style="display: none;">
<div class="viz-section">
<h4>Drives</h4>
<div id="viz-drives" class="viz-grid"></div>
</div>
<div class="viz-section">
<h4>MAPs / Ports</h4>
<div id="viz-maps" class="viz-grid"></div>
</div>
<div class="viz-section">
<h4>Storage Slots</h4>
<div id="viz-slots" class="viz-grid"></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3> Create New Tapes</h3>

View File

@@ -87,6 +87,11 @@ function initNavigation() {
this.classList.add('active');
document.getElementById(targetId).classList.add('active');
// Auto reload for Viz
if (targetId === 'manage-tapes') {
loadLibraryStatus();
}
});
});
}
@@ -1941,3 +1946,63 @@ async function changeUserPassword() {
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
}
}
// Library Visualizer
function loadLibraryStatus() {
const loading = document.getElementById('viz-loading');
const container = document.getElementById('library-viz');
const errorEl = document.getElementById('viz-error');
if (!loading || !container || !errorEl) return;
loading.style.display = 'block';
container.style.display = 'none';
errorEl.style.display = 'none';
fetch('api.php?action=library_status')
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
if (data.success) {
container.style.display = 'flex';
renderVizGrid('viz-drives', data.data.drives, 'drive');
renderVizGrid('viz-maps', data.data.maps, 'map');
renderVizGrid('viz-slots', data.data.slots, 'slot');
} else {
errorEl.textContent = 'Error: ' + data.error;
errorEl.style.display = 'block';
}
})
.catch(err => {
loading.style.display = 'none';
errorEl.textContent = 'Fetch Error: ' + err.message;
errorEl.style.display = 'block';
});
}
function renderVizGrid(containerId, items, type) {
const grid = document.getElementById(containerId);
if (!grid) return;
grid.innerHTML = '';
if (!items || items.length === 0) {
grid.innerHTML = '<div style="color:#aaa; font-style:italic; grid-column: 1/-1;">No items</div>';
return;
}
items.forEach(item => {
const el = document.createElement('div');
el.className = `viz-slot ${item.full ? 'full' : 'empty'} ${type === 'drive' ? 'drive-slot' : ''}`;
const icon = type === 'drive' ? '💾' : (type === 'map' ? '📥' : '📼');
const label = item.full ? item.barcode : 'Empty';
el.innerHTML = `
<span class="slot-icon">${icon}</span>
<div class="slot-id">${type.toUpperCase()} ${item.id}</div>
<div class="tape-label" title="${label}">${label}</div>
`;
grid.appendChild(el);
});
}

View File

@@ -440,3 +440,93 @@ main.container {
font-size: 0.875rem;
}
}
/* Library Visualizer Styles */
.library-viz {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.viz-section h4 {
margin-bottom: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.05em;
}
.viz-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
}
.viz-slot {
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem 0.5rem;
text-align: center;
transition: all 0.2s;
cursor: default;
position: relative;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.viz-slot:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}
.viz-slot.full {
background: rgba(16, 185, 129, 0.1);
border-color: var(--success-color);
}
.viz-slot .slot-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
display: block;
}
.viz-slot .slot-id {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
font-weight: 500;
}
.viz-slot .tape-label {
font-size: 0.75rem;
font-weight: 600;
font-family: 'Courier New', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
}
.viz-slot.full .tape-label {
color: var(--success-color);
}
.viz-slot.empty .tape-label {
color: var(--text-secondary);
font-style: italic;
opacity: 0.5;
}
/* Drive specific styling */
.drive-slot {
border-color: var(--info-color);
}
.drive-slot.full {
background: rgba(59, 130, 246, 0.1);
border-color: var(--info-color);
}
.drive-slot.full .tape-label {
color: var(--info-color);
}