feat: Add Library Visualizer (Slots & Drives) to Web UI
This commit is contained in:
BIN
dist/adastra-vtl-installer-1.0.0.tar.gz
vendored
BIN
dist/adastra-vtl-installer-1.0.0.tar.gz
vendored
Binary file not shown.
2
dist/adastra-vtl-installer/VERSION
vendored
2
dist/adastra-vtl-installer/VERSION
vendored
@@ -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
|
||||
|
||||
61
dist/adastra-vtl-installer/web-ui/api.php
vendored
61
dist/adastra-vtl-installer/web-ui/api.php
vendored
@@ -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]);
|
||||
}
|
||||
?>
|
||||
|
||||
29
dist/adastra-vtl-installer/web-ui/index.html
vendored
29
dist/adastra-vtl-installer/web-ui/index.html
vendored
@@ -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>
|
||||
|
||||
65
dist/adastra-vtl-installer/web-ui/script.js
vendored
65
dist/adastra-vtl-installer/web-ui/script.js
vendored
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
90
dist/adastra-vtl-installer/web-ui/style.css
vendored
90
dist/adastra-vtl-installer/web-ui/style.css
vendored
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user