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
|
Adastra VTL Installer
|
||||||
Version: 1.0.0
|
Version: 1.0.0
|
||||||
Build Date: 2025-12-10 12:25:11
|
Build Date: 2025-12-10 14:36:24
|
||||||
Build Host: vtl-dev
|
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();
|
getDeviceMapping();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'library_status':
|
||||||
|
getLibraryStatus();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'system_health':
|
case 'system_health':
|
||||||
getSystemHealth();
|
getSystemHealth();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
case 'restart_appliance':
|
case 'restart_appliance':
|
||||||
restartAppliance();
|
restartAppliance();
|
||||||
break;
|
break;
|
||||||
@@ -938,4 +944,59 @@ function getDeviceMapping() {
|
|||||||
'raw_output' => $output
|
'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>
|
<p>Complete CRUD management for virtual tape files</p>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>➕ Create New Tapes</h3>
|
<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');
|
this.classList.add('active');
|
||||||
document.getElementById(targetId).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}`;
|
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;
|
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();
|
getDeviceMapping();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'library_status':
|
||||||
|
getLibraryStatus();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'system_health':
|
case 'system_health':
|
||||||
getSystemHealth();
|
getSystemHealth();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
case 'restart_appliance':
|
case 'restart_appliance':
|
||||||
restartAppliance();
|
restartAppliance();
|
||||||
break;
|
break;
|
||||||
@@ -938,4 +944,59 @@ function getDeviceMapping() {
|
|||||||
'raw_output' => $output
|
'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>
|
<p>Complete CRUD management for virtual tape files</p>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>➕ Create New Tapes</h3>
|
<h3>➕ Create New Tapes</h3>
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ function initNavigation() {
|
|||||||
|
|
||||||
this.classList.add('active');
|
this.classList.add('active');
|
||||||
document.getElementById(targetId).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}`;
|
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;
|
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