feat: Add complete iSCSI target management to Web UI- Add iSCSI tab with full target management- Implement create/delete targets with auto-generated IQN- Add LUN (backing store) management- Implement initiator ACL management (bind/unbind)- Add real-time target listing with LUN/ACL counts- Add comprehensive iSCSI management guide- Update sudoers to allow tgtadm commands- Add tape management features (create/list/delete/bulk delete)- Add service status monitoring- Security: Input validation, path security, sudo restrictions- Tested: Full CRUD operations working- Package size: 29KB, production ready

This commit is contained in:
2025-12-09 15:06:23 +00:00
parent fc2fb763f5
commit 8b6fad85a2
43 changed files with 9179 additions and 5 deletions

65
web-ui/README.md Normal file
View File

@@ -0,0 +1,65 @@
# mhvtl Configuration Web UI
Web-based configuration manager for mhvtl (Virtual Tape Library).
## Features
- 📚 Library configuration
- 💾 Drive management (add/remove/configure)
- 📼 Tape generation settings
- 📤 Export configuration files
- 🎨 Modern UI with Adastra theme
## Usage
### Local Development
Simply open `index.html` in your web browser:
```bash
cd web-ui
python3 -m http.server 8080
# or
php -S localhost:8080
```
Then open http://localhost:8080 in your browser.
### Deploy to VTL System
Copy the web-ui directory to your VTL system:
```bash
scp -r web-ui/ root@vtl-server:/var/www/html/mhvtl-config/
```
Or include it in the ISO build by adding to the build script.
## Configuration Workflow
1. **Library Tab**: Configure library settings (ID, vendor, serial, etc.)
2. **Drives Tab**: Add/remove drives and configure each drive
3. **Tapes Tab**: Set tape generation parameters
4. **Export Tab**:
- Generate configuration preview
- Download `device.conf` file
- Copy mktape command for tape generation
## Generated Files
- `device.conf` - Main mhvtl configuration file (goes to `/etc/mhvtl/`)
- `mktape` command - Run this to generate virtual tapes
## Integration with Build
To include this in the ISO build, add to `build/build-iso.sh`:
```bash
# Copy web UI
mkdir -p "$WORK_DIR/chroot/var/www/html"
cp -r web-ui "$WORK_DIR/chroot/var/www/html/mhvtl-config"
```
## Customization
Edit `style.css` to customize colors and theme. Current theme matches adastra.id branding.

655
web-ui/api.php Normal file
View File

@@ -0,0 +1,655 @@
<?php
header('Content-Type: application/json');
// Configuration
$CONFIG_DIR = '/etc/mhvtl';
$DEVICE_CONF = $CONFIG_DIR . '/device.conf';
$BACKUP_DIR = $CONFIG_DIR . '/backups';
// Ensure backup directory exists
if (!is_dir($BACKUP_DIR)) {
mkdir($BACKUP_DIR, 0755, true);
}
// Get POST data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['action'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request']);
exit;
}
$action = $input['action'];
switch ($action) {
case 'save_config':
saveConfig($input['config']);
break;
case 'load_config':
loadConfig();
break;
case 'restart_service':
restartService();
break;
case 'list_tapes':
listTapes();
break;
case 'delete_tape':
deleteTape($input['tape_name']);
break;
case 'bulk_delete_tapes':
bulkDeleteTapes($input['pattern']);
break;
case 'create_tapes':
createTapes($input);
break;
case 'list_targets':
listTargets();
break;
case 'create_target':
createTarget($input);
break;
case 'delete_target':
deleteTarget($input['tid']);
break;
case 'add_lun':
addLun($input);
break;
case 'bind_initiator':
bindInitiator($input);
break;
case 'unbind_initiator':
unbindInitiator($input);
break;
default:
echo json_encode(['success' => false, 'error' => 'Unknown action']);
}
// ============================================
// iSCSI Target Management Functions
// ============================================
function listTargets() {
$output = [];
$returnCode = 0;
exec('sudo tgtadm --lld iscsi --mode target --op show 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
echo json_encode([
'success' => false,
'error' => 'Failed to list targets: ' . implode(' ', $output)
]);
return;
}
$targets = [];
$currentTarget = null;
$inACLSection = false;
foreach ($output as $line) {
if (preg_match('/^Target (\d+): (.+)$/', $line, $matches)) {
if ($currentTarget) {
$targets[] = $currentTarget;
}
$currentTarget = [
'tid' => intval($matches[1]),
'name' => trim($matches[2]),
'luns' => 0,
'acls' => 0
];
$inACLSection = false;
} elseif ($currentTarget && preg_match('/^\s+LUN: (\d+)/', $line)) {
$currentTarget['luns']++;
$inACLSection = false;
} elseif ($currentTarget && preg_match('/^\s+ACL information:/', $line)) {
$inACLSection = true;
} elseif ($currentTarget && $inACLSection && preg_match('/^\s+(.+)$/', $line, $matches)) {
$acl = trim($matches[1]);
if (!empty($acl) && !preg_match('/^(Account|I_T nexus|LUN|System)/', $acl)) {
$currentTarget['acls']++;
}
}
}
if ($currentTarget) {
$targets[] = $currentTarget;
}
echo json_encode([
'success' => true,
'targets' => $targets
]);
}
function createTarget($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$name = isset($params['name']) ? trim($params['name']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Target name is required']);
return;
}
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
echo json_encode(['success' => false, 'error' => 'Invalid target name format']);
return;
}
$iqn = "iqn.2024-01.com.vtl-linux:$name";
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op new --tid %d --targetname %s 2>&1',
$tid,
escapeshellarg($iqn)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Target created successfully',
'iqn' => $iqn
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create target: ' . implode(' ', $output)
]);
}
}
function deleteTarget($tid) {
$tid = intval($tid);
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op delete --force --tid %d 2>&1',
$tid
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Target deleted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete target: ' . implode(' ', $output)
]);
}
}
function addLun($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$lun = isset($params['lun']) ? intval($params['lun']) : 0;
$device = isset($params['device']) ? trim($params['device']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if ($lun < 0) {
echo json_encode(['success' => false, 'error' => 'Invalid LUN number']);
return;
}
if (empty($device)) {
echo json_encode(['success' => false, 'error' => 'Device path is required']);
return;
}
if (!preg_match('#^/dev/(sg\d+|sd[a-z]+)$#', $device)) {
echo json_encode(['success' => false, 'error' => 'Invalid device path']);
return;
}
if (!file_exists($device)) {
echo json_encode(['success' => false, 'error' => 'Device does not exist']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode logicalunit --op new --tid %d --lun %d --backing-store %s 2>&1',
$tid,
$lun,
escapeshellarg($device)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'LUN added successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to add LUN: ' . implode(' ', $output)
]);
}
}
function bindInitiator($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$address = isset($params['address']) ? trim($params['address']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($address)) {
echo json_encode(['success' => false, 'error' => 'Initiator address is required']);
return;
}
if ($address !== 'ALL' && !filter_var($address, FILTER_VALIDATE_IP)) {
echo json_encode(['success' => false, 'error' => 'Invalid IP address']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op bind --tid %d --initiator-address %s 2>&1',
$tid,
escapeshellarg($address)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Initiator allowed successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to bind initiator: ' . implode(' ', $output)
]);
}
}
function unbindInitiator($params) {
$tid = isset($params['tid']) ? intval($params['tid']) : 0;
$address = isset($params['address']) ? trim($params['address']) : '';
if ($tid <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid TID']);
return;
}
if (empty($address)) {
echo json_encode(['success' => false, 'error' => 'Initiator address is required']);
return;
}
if ($address !== 'ALL' && !filter_var($address, FILTER_VALIDATE_IP)) {
echo json_encode(['success' => false, 'error' => 'Invalid IP address']);
return;
}
$command = sprintf(
'sudo tgtadm --lld iscsi --mode target --op unbind --tid %d --initiator-address %s 2>&1',
$tid,
escapeshellarg($address)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Initiator blocked successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to unbind initiator: ' . implode(' ', $output)
]);
}
}
function createTapes($params) {
$library = isset($params['library']) ? intval($params['library']) : 10;
$barcodePrefix = isset($params['barcode_prefix']) ? trim($params['barcode_prefix']) : '';
$startNum = isset($params['start_num']) ? intval($params['start_num']) : 0;
$count = isset($params['count']) ? intval($params['count']) : 1;
$size = isset($params['size']) ? intval($params['size']) : 2500000;
$mediaType = isset($params['media_type']) ? $params['media_type'] : 'data';
$density = isset($params['density']) ? $params['density'] : 'LTO6';
if (empty($barcodePrefix)) {
echo json_encode(['success' => false, 'error' => 'Barcode prefix is required']);
return;
}
if ($count < 1 || $count > 100) {
echo json_encode(['success' => false, 'error' => 'Count must be between 1 and 100']);
return;
}
if (strlen($barcodePrefix) > 6) {
echo json_encode(['success' => false, 'error' => 'Barcode prefix too long (max 6 chars)']);
return;
}
$validMediaTypes = ['data', 'clean', 'WORM'];
if (!in_array($mediaType, $validMediaTypes)) {
echo json_encode(['success' => false, 'error' => 'Invalid media type']);
return;
}
$validDensities = ['LTO5', 'LTO6', 'LTO7', 'LTO8', 'LTO9'];
if (!in_array($density, $validDensities)) {
echo json_encode(['success' => false, 'error' => 'Invalid density']);
return;
}
$createdCount = 0;
$errors = [];
for ($i = 0; $i < $count; $i++) {
$barcodeNum = str_pad($startNum + $i, 6, '0', STR_PAD_LEFT);
$barcode = $barcodePrefix . $barcodeNum;
$command = sprintf(
'mktape -l %d -m %s -s %d -t %s -d %s 2>&1',
$library,
escapeshellarg($barcode),
$size,
escapeshellarg($mediaType),
escapeshellarg($density)
);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
if ($returnCode === 0) {
$createdCount++;
} else {
$errors[] = $barcode . ': ' . implode(' ', $output);
}
}
if ($createdCount > 0) {
$response = [
'success' => true,
'created_count' => $createdCount,
'message' => "Created $createdCount tape(s)"
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
echo json_encode($response);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create tapes: ' . implode('; ', $errors)
]);
}
}
function saveConfig($config) {
global $DEVICE_CONF, $BACKUP_DIR;
if (empty($config)) {
echo json_encode(['success' => false, 'error' => 'Empty configuration']);
return;
}
// Create backup of existing config
if (file_exists($DEVICE_CONF)) {
$backupFile = $BACKUP_DIR . '/device.conf.' . date('Y-m-d_H-i-s');
if (!copy($DEVICE_CONF, $backupFile)) {
echo json_encode(['success' => false, 'error' => 'Failed to create backup']);
return;
}
}
// Write new config
if (file_put_contents($DEVICE_CONF, $config) === false) {
echo json_encode(['success' => false, 'error' => 'Failed to write configuration file. Check permissions.']);
return;
}
// Set proper permissions
chmod($DEVICE_CONF, 0644);
echo json_encode([
'success' => true,
'file' => $DEVICE_CONF,
'backup' => isset($backupFile) ? $backupFile : null,
'message' => 'Configuration saved successfully'
]);
}
function loadConfig() {
global $DEVICE_CONF;
if (!file_exists($DEVICE_CONF)) {
echo json_encode(['success' => false, 'error' => 'Configuration file not found']);
return;
}
$config = file_get_contents($DEVICE_CONF);
echo json_encode([
'success' => true,
'config' => $config
]);
}
function restartService() {
// Check if user has sudo privileges
$output = [];
$returnCode = 0;
exec('sudo systemctl restart mhvtl 2>&1', $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Service restarted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to restart service: ' . implode("\n", $output)
]);
}
}
function listTapes() {
$tapeDir = '/opt/mhvtl';
if (!is_dir($tapeDir)) {
echo json_encode(['success' => false, 'error' => 'Tape directory not found']);
return;
}
$tapes = [];
$items = scandir($tapeDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $tapeDir . '/' . $item;
if (is_dir($path)) {
$stat = stat($path);
$size = getDirSize($path);
$tapes[] = [
'name' => $item,
'size' => formatBytes($size),
'modified' => date('Y-m-d H:i:s', $stat['mtime'])
];
}
}
usort($tapes, function($a, $b) {
return strcmp($a['name'], $b['name']);
});
echo json_encode([
'success' => true,
'tapes' => $tapes
]);
}
function getDirSize($dir) {
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $file) {
$size += $file->getSize();
}
return $size;
}
function formatBytes($bytes) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
function deleteTape($tapeName) {
$tapeDir = '/opt/mhvtl';
$tapePath = $tapeDir . '/' . basename($tapeName);
if (!file_exists($tapePath)) {
echo json_encode(['success' => false, 'error' => 'Tape not found']);
return;
}
if (!is_dir($tapePath)) {
echo json_encode(['success' => false, 'error' => 'Invalid tape path']);
return;
}
if (strpos(realpath($tapePath), realpath($tapeDir)) !== 0) {
echo json_encode(['success' => false, 'error' => 'Security violation: Path traversal detected']);
return;
}
$output = [];
$returnCode = 0;
exec('sudo rm -rf ' . escapeshellarg($tapePath) . ' 2>&1', $output, $returnCode);
if ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Tape deleted successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete tape: ' . implode("\n", $output)
]);
}
}
function bulkDeleteTapes($pattern) {
$tapeDir = '/opt/mhvtl';
if (empty($pattern)) {
echo json_encode(['success' => false, 'error' => 'Pattern is required']);
return;
}
if (strpos($pattern, '/') !== false || strpos($pattern, '..') !== false) {
echo json_encode(['success' => false, 'error' => 'Invalid pattern']);
return;
}
$items = glob($tapeDir . '/' . $pattern, GLOB_ONLYDIR);
if (empty($items)) {
echo json_encode([
'success' => true,
'deleted_count' => 0,
'message' => 'No tapes found matching pattern'
]);
return;
}
$deletedCount = 0;
$errors = [];
foreach ($items as $item) {
if (strpos(realpath($item), realpath($tapeDir)) !== 0) {
continue;
}
$output = [];
$returnCode = 0;
exec('sudo rm -rf ' . escapeshellarg($item) . ' 2>&1', $output, $returnCode);
if ($returnCode === 0) {
$deletedCount++;
} else {
$errors[] = basename($item) . ': ' . implode(' ', $output);
}
}
if ($deletedCount > 0) {
$message = "Deleted $deletedCount tape(s)";
if (!empty($errors)) {
$message .= '. Errors: ' . implode('; ', $errors);
}
echo json_encode([
'success' => true,
'deleted_count' => $deletedCount,
'message' => $message
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete tapes: ' . implode('; ', $errors)
]);
}
}
?>

458
web-ui/index.html Normal file
View File

@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mhvtl Configuration Manager - Adastra VTL</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav class="navbar">
<div class="container">
<div class="nav-brand">
<h1>🎞️ Adastra VTL</h1>
<span class="subtitle">Virtual Tape Library Configuration</span>
</div>
<div class="nav-links">
<a href="#library" class="nav-link active">Library</a>
<a href="#drives" class="nav-link">Drives</a>
<a href="#tapes" class="nav-link">Tapes</a>
<a href="#manage-tapes" class="nav-link">Manage Tapes</a>
<a href="#iscsi" class="nav-link">iSCSI</a>
<a href="#export" class="nav-link">Export</a>
</div>
</div>
</nav>
<main class="container">
<section id="library" class="section active">
<div class="section-header">
<h2>📚 Library Configuration</h2>
<p>Configure your virtual tape library settings</p>
</div>
<div class="card">
<div class="card-header">
<h3>Library Settings</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="lib-id">Library ID</label>
<input type="number" id="lib-id" value="10" min="0" max="99">
</div>
<div class="form-group">
<label for="lib-channel">Channel</label>
<input type="number" id="lib-channel" value="0" min="0" max="15">
</div>
<div class="form-group">
<label for="lib-target">Target</label>
<input type="number" id="lib-target" value="0" min="0" max="15">
</div>
<div class="form-group">
<label for="lib-lun">LUN</label>
<input type="number" id="lib-lun" value="0" min="0" max="7">
</div>
<div class="form-group">
<label for="lib-vendor">Vendor</label>
<input type="text" id="lib-vendor" value="STK" maxlength="8">
</div>
<div class="form-group">
<label for="lib-product">Product</label>
<input type="text" id="lib-product" value="L700" maxlength="16">
</div>
<div class="form-group">
<label for="lib-serial">Serial Number</label>
<input type="text" id="lib-serial" value="XYZZY_A" maxlength="10">
</div>
<div class="form-group">
<label for="lib-naa">NAA</label>
<input type="text" id="lib-naa" value="10:22:33:44:ab:cd:ef:00" pattern="[0-9a-f:]+">
</div>
<div class="form-group">
<label for="lib-home">Home Directory</label>
<input type="text" id="lib-home" value="/opt/mhvtl">
</div>
<div class="form-group">
<label for="lib-backoff">Backoff (ms)</label>
<input type="number" id="lib-backoff" value="400" min="0" max="10000">
</div>
</div>
</div>
</div>
</section>
<section id="drives" class="section">
<div class="section-header">
<h2>💾 Drive Configuration</h2>
<p>Manage virtual tape drives</p>
</div>
<div class="drives-container" id="drives-container">
</div>
<button class="btn btn-primary" onclick="addDrive()">
<span></span> Add Drive
</button>
</section>
<section id="tapes" class="section">
<div class="section-header">
<h2>📼 Tape Configuration</h2>
<p>Configure virtual tape media</p>
</div>
<div class="card">
<div class="card-header">
<h3>Tape Generation Settings</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="tape-library">Library ID</label>
<input type="number" id="tape-library" value="10" min="0" max="99">
</div>
<div class="form-group">
<label for="tape-barcode-prefix">Barcode Prefix</label>
<input type="text" id="tape-barcode-prefix" value="CLN" maxlength="6">
</div>
<div class="form-group">
<label for="tape-start-num">Starting Number</label>
<input type="number" id="tape-start-num" value="100" min="1" max="9999">
</div>
<div class="form-group">
<label for="tape-size">Tape Size (MB)</label>
<input type="number" id="tape-size" value="2500000" min="1000" max="10000000">
</div>
<div class="form-group">
<label for="tape-media-type">Media Type</label>
<select id="tape-media-type">
<option value="data">Data</option>
<option value="clean">Cleaning</option>
<option value="WORM">WORM</option>
</select>
</div>
<div class="form-group">
<label for="tape-density">Density</label>
<select id="tape-density">
<option value="LTO5">LTO-5</option>
<option value="LTO6" selected>LTO-6</option>
<option value="LTO7">LTO-7</option>
<option value="LTO8">LTO-8</option>
<option value="LTO9">LTO-9</option>
</select>
</div>
<div class="form-group">
<label for="tape-count">Number of Tapes</label>
<input type="number" id="tape-count" value="20" min="1" max="1000">
</div>
</div>
<div class="alert alert-info">
<strong> Info:</strong> Generate mktape commands for creating virtual tapes. Run these commands on the server after installation.
</div>
</div>
</div>
</section>
<section id="manage-tapes" class="section">
<div class="section-header">
<h2>🗂️ Manage Virtual Tapes</h2>
<p>Complete CRUD management for virtual tape files</p>
</div>
<div class="card">
<div class="card-header">
<h3> Create New Tapes</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="create-library">Library Number</label>
<input type="number" id="create-library" value="10" min="1">
</div>
<div class="form-group">
<label for="create-barcode-prefix">Barcode Prefix</label>
<input type="text" id="create-barcode-prefix" value="CLN" maxlength="6">
</div>
<div class="form-group">
<label for="create-start-num">Starting Number</label>
<input type="number" id="create-start-num" value="100" min="0">
</div>
<div class="form-group">
<label for="create-count">Number of Tapes</label>
<input type="number" id="create-count" value="1" min="1" max="100">
</div>
<div class="form-group">
<label for="create-size">Tape Size (MB)</label>
<input type="number" id="create-size" value="2500000" min="1000">
<small>Default: 2.5TB = 2,500,000 MB</small>
</div>
<div class="form-group">
<label for="create-media-type">Media Type</label>
<select id="create-media-type">
<option value="data">Data</option>
<option value="clean">Cleaning</option>
<option value="WORM">WORM</option>
</select>
</div>
<div class="form-group">
<label for="create-density">Density</label>
<select id="create-density">
<option value="LTO5">LTO-5</option>
<option value="LTO6" selected>LTO-6</option>
<option value="LTO7">LTO-7</option>
<option value="LTO8">LTO-8</option>
<option value="LTO9">LTO-9</option>
</select>
</div>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="createTapes()">
<span></span> Create Tapes
</button>
<div id="create-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>📋 Tape Files</h3>
<button class="btn btn-primary" onclick="loadTapeList()">
<span>🔄</span> Refresh List
</button>
</div>
<div class="card-body">
<div id="tape-list-loading" style="display: none; text-align: center; padding: 2rem;">
<strong></strong> Loading tape files...
</div>
<div id="tape-list-error" class="alert alert-danger" style="display: none;"></div>
<div id="tape-list-empty" class="alert alert-info" style="display: none;">
<strong></strong> No tape files found. Create tapes using the commands from the "Tapes" section.
</div>
<div id="tape-list-container" style="display: none;">
<div style="margin-bottom: 1rem;">
<input type="text" id="tape-search" placeholder="🔍 Search tapes..."
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
onkeyup="filterTapes()">
</div>
<table class="tape-table" id="tape-table">
<thead>
<tr>
<th>Barcode</th>
<th>Size</th>
<th>Modified</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tape-list-body">
</tbody>
</table>
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
<strong>Total Tapes:</strong> <span id="tape-count-display">0</span>
</div>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>Bulk Actions</h3>
</div>
<div class="card-body">
<p>Delete multiple tapes at once. Use with caution!</p>
<div class="form-group">
<label for="bulk-delete-pattern">Delete Pattern (e.g., CLN*)</label>
<input type="text" id="bulk-delete-pattern" placeholder="CLN*">
</div>
<button class="btn btn-danger" onclick="bulkDeleteTapes()">
<span>🗑️</span> Bulk Delete
</button>
<div id="bulk-delete-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</section>
<section id="iscsi" class="section">
<div class="section-header">
<h2>🔌 iSCSI Target Management</h2>
<p>Manage iSCSI targets, initiators, and LUNs</p>
</div>
<div class="card">
<div class="card-header">
<h3>🎯 iSCSI Targets</h3>
<button class="btn btn-primary" onclick="loadTargets()">
<span>🔄</span> Refresh
</button>
</div>
<div class="card-body">
<div id="target-list-empty" class="alert alert-info">
<strong></strong> No targets configured. Create a target below.
</div>
<div id="target-list-container" style="display: none;">
<table class="tape-table" id="target-table">
<thead>
<tr>
<th>TID</th>
<th>Target Name (IQN)</th>
<th>LUNs</th>
<th>ACLs</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="target-list-body">
</tbody>
</table>
</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3> Create New Target</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="target-tid">Target ID (TID)</label>
<input type="number" id="target-tid" value="1" min="1">
<small>Unique target identifier</small>
</div>
<div class="form-group">
<label for="target-name">Target Name</label>
<input type="text" id="target-name" placeholder="vtl.drive0">
<small>Will be: iqn.2024-01.com.vtl-linux:<name></small>
</div>
</div>
<button class="btn btn-success" onclick="createTarget()">
<span></span> Create Target
</button>
<div id="create-target-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>💾 Add LUN (Backing Store)</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="lun-tid">Target ID</label>
<input type="number" id="lun-tid" value="1" min="1">
</div>
<div class="form-group">
<label for="lun-number">LUN Number</label>
<input type="number" id="lun-number" value="1" min="0">
</div>
<div class="form-group">
<label for="lun-device">Device Path</label>
<input type="text" id="lun-device" placeholder="/dev/sg1">
<small>SCSI generic device (e.g., /dev/sg1, /dev/sg2)</small>
</div>
</div>
<button class="btn btn-success" onclick="addLun()">
<span></span> Add LUN
</button>
<div id="add-lun-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>🔐 Manage Initiator ACLs</h3>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label for="acl-tid">Target ID</label>
<input type="number" id="acl-tid" value="1" min="1">
</div>
<div class="form-group">
<label for="acl-address">Initiator Address</label>
<input type="text" id="acl-address" placeholder="192.168.1.100 or ALL">
<small>IP address or "ALL" for any initiator</small>
</div>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="bindInitiator()">
<span></span> Allow Initiator
</button>
<button class="btn btn-danger" onclick="unbindInitiator()">
<span>🚫</span> Block Initiator
</button>
</div>
<div id="acl-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
</section>
<section id="export" class="section">
<div class="section-header">
<h2>📤 Export Configuration</h2>
<p>Generate and download configuration files</p>
</div>
<div class="card">
<div class="card-header">
<h3>Configuration Preview</h3>
</div>
<div class="card-body">
<pre id="config-preview" class="config-preview"></pre>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="generateConfig()">
<span>🔄</span> Generate Config
</button>
<button class="btn btn-success" onclick="applyConfig()">
<span>💾</span> Apply to Server
</button>
<button class="btn btn-success" onclick="downloadConfig()">
<span>⬇️</span> Download device.conf
</button>
<button class="btn btn-secondary" onclick="copyConfig()">
<span>📋</span> Copy to Clipboard
</button>
</div>
<div id="apply-result" class="alert" style="display: none; margin-top: 1rem;"></div>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">
<h3>Service Management</h3>
</div>
<div class="card-body">
<p>After applying configuration, restart the mhvtl service to apply changes.</p>
<button class="btn btn-warning" onclick="restartService()">
<span>🔄</span> Restart mhvtl Service
</button>
<div id="restart-result" class="alert" style="display: none; margin-top: 1rem;"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Installation Command</h3>
</div>
<div class="card-body">
<pre id="install-command" class="config-preview"></pre>
<button class="btn btn-secondary" onclick="copyInstallCommand()">
<span>📋</span> Copy Command
</button>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p>© Adastra Visi Teknologi • <a href="http://adastra.id">adastra.id</a> • mhvtl Configuration Manager</p>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>

914
web-ui/script.js Normal file
View File

@@ -0,0 +1,914 @@
let drives = [];
let driveCounter = 0;
const driveTypes = {
'IBM ULT3580-TD5': { vendor: 'IBM', product: 'ULT3580-TD5', type: 'LTO-5' },
'IBM ULT3580-TD6': { vendor: 'IBM', product: 'ULT3580-TD6', type: 'LTO-6' },
'IBM ULT3580-TD7': { vendor: 'IBM', product: 'ULT3580-TD7', type: 'LTO-7' },
'IBM ULT3580-TD8': { vendor: 'IBM', product: 'ULT3580-TD8', type: 'LTO-8' },
'IBM ULT3580-TD9': { vendor: 'IBM', product: 'ULT3580-TD9', type: 'LTO-9' },
'HP Ultrium 5-SCSI': { vendor: 'HP', product: 'Ultrium 5-SCSI', type: 'LTO-5' },
'HP Ultrium 6-SCSI': { vendor: 'HP', product: 'Ultrium 6-SCSI', type: 'LTO-6' },
};
document.addEventListener('DOMContentLoaded', function() {
initNavigation();
addDefaultDrives();
generateConfig();
});
function initNavigation() {
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href').substring(1);
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
this.classList.add('active');
document.getElementById(targetId).classList.add('active');
});
});
}
function addDefaultDrives() {
for (let i = 0; i < 4; i++) {
addDrive(i < 2 ? 'IBM ULT3580-TD5' : 'IBM ULT3580-TD6');
}
}
function addDrive(driveType = 'IBM ULT3580-TD5') {
const driveId = driveCounter++;
const drive = {
id: driveId,
driveNum: drives.length,
channel: 0,
target: drives.length + 1,
lun: 0,
libraryId: 10,
slot: drives.length + 1,
type: driveType,
serial: `XYZZY_A${drives.length + 1}`,
naa: `10:22:33:44:ab:cd:ef:0${drives.length + 1}`,
compression: 3,
compressionEnabled: 1,
compressionType: 'lzo',
backoff: 400
};
drives.push(drive);
renderDrive(drive);
}
function renderDrive(drive) {
const container = document.getElementById('drives-container');
const driveInfo = driveTypes[drive.type];
const driveCard = document.createElement('div');
driveCard.className = 'drive-card';
driveCard.id = `drive-${drive.id}`;
driveCard.innerHTML = `
<div class="drive-card-header">
<h4>💾 Drive ${drive.driveNum}</h4>
<button class="btn btn-danger" onclick="removeDrive(${drive.id})">
<span>🗑️</span> Remove
</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>Drive Type</label>
<select onchange="updateDriveType(${drive.id}, this.value)">
${Object.keys(driveTypes).map(type =>
`<option value="${type}" ${type === drive.type ? 'selected' : ''}>${type}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Drive Number</label>
<input type="number" value="${drive.driveNum}" min="0" max="99"
onchange="updateDrive(${drive.id}, 'driveNum', parseInt(this.value))">
</div>
<div class="form-group">
<label>Channel</label>
<input type="number" value="${drive.channel}" min="0" max="15"
onchange="updateDrive(${drive.id}, 'channel', parseInt(this.value))">
</div>
<div class="form-group">
<label>Target</label>
<input type="number" value="${drive.target}" min="0" max="15"
onchange="updateDrive(${drive.id}, 'target', parseInt(this.value))">
</div>
<div class="form-group">
<label>LUN</label>
<input type="number" value="${drive.lun}" min="0" max="7"
onchange="updateDrive(${drive.id}, 'lun', parseInt(this.value))">
</div>
<div class="form-group">
<label>Library ID</label>
<input type="number" value="${drive.libraryId}" min="0" max="99"
onchange="updateDrive(${drive.id}, 'libraryId', parseInt(this.value))">
</div>
<div class="form-group">
<label>Slot</label>
<input type="number" value="${drive.slot}" min="1" max="999"
onchange="updateDrive(${drive.id}, 'slot', parseInt(this.value))">
</div>
<div class="form-group">
<label>Serial Number</label>
<input type="text" value="${drive.serial}" maxlength="10"
onchange="updateDrive(${drive.id}, 'serial', this.value)">
</div>
<div class="form-group">
<label>NAA</label>
<input type="text" value="${drive.naa}" pattern="[0-9a-f:]+"
onchange="updateDrive(${drive.id}, 'naa', this.value)">
</div>
<div class="form-group">
<label>Compression Factor</label>
<input type="number" value="${drive.compression}" min="1" max="10"
onchange="updateDrive(${drive.id}, 'compression', parseInt(this.value))">
</div>
<div class="form-group">
<label>Compression Type</label>
<select onchange="updateDrive(${drive.id}, 'compressionType', this.value)">
<option value="lzo" ${drive.compressionType === 'lzo' ? 'selected' : ''}>LZO</option>
<option value="zlib" ${drive.compressionType === 'zlib' ? 'selected' : ''}>ZLIB</option>
</select>
</div>
<div class="form-group">
<label>Backoff (ms)</label>
<input type="number" value="${drive.backoff}" min="0" max="10000"
onchange="updateDrive(${drive.id}, 'backoff', parseInt(this.value))">
</div>
</div>
`;
container.appendChild(driveCard);
}
function updateDrive(driveId, field, value) {
const drive = drives.find(d => d.id === driveId);
if (drive) {
drive[field] = value;
}
}
function updateDriveType(driveId, type) {
const drive = drives.find(d => d.id === driveId);
if (drive) {
drive.type = type;
}
}
function removeDrive(driveId) {
const index = drives.findIndex(d => d.id === driveId);
if (index !== -1) {
drives.splice(index, 1);
document.getElementById(`drive-${driveId}`).remove();
drives.forEach((drive, idx) => {
drive.driveNum = idx;
});
document.getElementById('drives-container').innerHTML = '';
drives.forEach(drive => renderDrive(drive));
}
}
function generateConfig() {
const libId = document.getElementById('lib-id').value;
const libChannel = document.getElementById('lib-channel').value;
const libTarget = document.getElementById('lib-target').value;
const libLun = document.getElementById('lib-lun').value;
const libVendor = document.getElementById('lib-vendor').value;
const libProduct = document.getElementById('lib-product').value;
const libSerial = document.getElementById('lib-serial').value;
const libNaa = document.getElementById('lib-naa').value;
const libHome = document.getElementById('lib-home').value;
const libBackoff = document.getElementById('lib-backoff').value;
let config = `VERSION: 5\n\n`;
config += `Library: ${libId} CHANNEL: ${libChannel.padStart(2, '0')} TARGET: ${libTarget.padStart(2, '0')} LUN: ${libLun.padStart(2, '0')}\n`;
config += ` Vendor identification: ${libVendor}\n`;
config += ` Product identification: ${libProduct}\n`;
config += ` Unit serial number: ${libSerial}\n`;
config += ` NAA: ${libNaa}\n`;
config += ` Home directory: ${libHome}\n`;
config += ` Backoff: ${libBackoff}\n`;
drives.forEach(drive => {
const driveInfo = driveTypes[drive.type];
config += `\nDrive: ${drive.driveNum.toString().padStart(2, '0')} CHANNEL: ${drive.channel.toString().padStart(2, '0')} TARGET: ${drive.target.toString().padStart(2, '0')} LUN: ${drive.lun.toString().padStart(2, '0')}\n`;
config += ` Library ID: ${drive.libraryId} Slot: ${drive.slot.toString().padStart(2, '0')}\n`;
config += ` Vendor identification: ${driveInfo.vendor}\n`;
config += ` Product identification: ${driveInfo.product}\n`;
config += ` Unit serial number: ${drive.serial}\n`;
config += ` NAA: ${drive.naa}\n`;
config += ` Compression: factor ${drive.compression} enabled ${drive.compressionEnabled}\n`;
config += ` Compression type: ${drive.compressionType}\n`;
config += ` Backoff: ${drive.backoff}\n`;
});
document.getElementById('config-preview').textContent = config;
const tapeLibrary = document.getElementById('tape-library').value;
const tapeBarcodePrefix = document.getElementById('tape-barcode-prefix').value;
const tapeStartNum = parseInt(document.getElementById('tape-start-num').value);
const tapeSize = document.getElementById('tape-size').value;
const tapeMediaType = document.getElementById('tape-media-type').value;
const tapeDensity = document.getElementById('tape-density').value;
const tapeCount = parseInt(document.getElementById('tape-count').value);
let installCmds = '#!/bin/bash\n';
installCmds += '# Generate virtual tapes for mhvtl\n';
installCmds += '# Run this script after mhvtl installation\n\n';
for (let i = 0; i < tapeCount; i++) {
const barcodeNum = String(tapeStartNum + i).padStart(6, '0');
const barcode = `${tapeBarcodePrefix}${barcodeNum}`;
installCmds += `mktape -l ${tapeLibrary} -m ${barcode} -s ${tapeSize} -t ${tapeMediaType} -d ${tapeDensity}\n`;
}
document.getElementById('install-command').textContent = installCmds;
}
function downloadConfig() {
generateConfig();
const config = document.getElementById('config-preview').textContent;
const blob = new Blob([config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'device.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Configuration downloaded successfully!', 'success');
}
function copyConfig() {
generateConfig();
const config = document.getElementById('config-preview').textContent;
navigator.clipboard.writeText(config).then(() => {
showNotification('Configuration copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy configuration', 'danger');
});
}
function copyInstallCommand() {
const cmd = document.getElementById('install-command').textContent;
navigator.clipboard.writeText(cmd).then(() => {
showNotification('Command copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy command', 'danger');
});
}
function generateConfigText() {
const libId = document.getElementById('lib-id').value;
const libChannel = document.getElementById('lib-channel').value;
const libTarget = document.getElementById('lib-target').value;
const libLun = document.getElementById('lib-lun').value;
const libVendor = document.getElementById('lib-vendor').value;
const libProduct = document.getElementById('lib-product').value;
const libSerial = document.getElementById('lib-serial').value;
const libNaa = document.getElementById('lib-naa').value;
const libHome = document.getElementById('lib-home').value;
const libBackoff = document.getElementById('lib-backoff').value;
let config = `VERSION: 5\n\n`;
config += `Library: ${libId} CHANNEL: ${libChannel.padStart(2, '0')} TARGET: ${libTarget.padStart(2, '0')} LUN: ${libLun.padStart(2, '0')}\n`;
config += ` Vendor identification: ${libVendor}\n`;
config += ` Product identification: ${libProduct}\n`;
config += ` Unit serial number: ${libSerial}\n`;
config += ` NAA: ${libNaa}\n`;
config += ` Home directory: ${libHome}\n`;
config += ` Backoff: ${libBackoff}\n`;
drives.forEach(drive => {
const driveInfo = driveTypes[drive.type];
config += `\nDrive: ${drive.driveNum.toString().padStart(2, '0')} CHANNEL: ${drive.channel.toString().padStart(2, '0')} TARGET: ${drive.target.toString().padStart(2, '0')} LUN: ${drive.lun.toString().padStart(2, '0')}\n`;
config += ` Library ID: ${drive.libraryId} Slot: ${drive.slot.toString().padStart(2, '0')}\n`;
config += ` Vendor identification: ${driveInfo.vendor}\n`;
config += ` Product identification: ${driveInfo.product}\n`;
config += ` Unit serial number: ${drive.serial}\n`;
config += ` NAA: ${drive.naa}\n`;
config += ` Compression: factor ${drive.compression} enabled ${drive.compressionEnabled}\n`;
config += ` Compression type: ${drive.compressionType}\n`;
config += ` Backoff: ${drive.backoff}\n`;
});
return config;
}
function applyConfig() {
const config = generateConfigText();
const resultDiv = document.getElementById('apply-result');
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Applying configuration to server...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'save_config',
config: config
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `
<strong>✅ Success!</strong> Configuration saved to ${data.file}<br>
<small>Restart mhvtl service to apply changes using the button below.</small>
`;
showNotification('Configuration applied successfully!', 'success');
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
showNotification('Failed to apply configuration', 'danger');
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
showNotification('Failed to apply configuration', 'danger');
});
}
function restartService() {
const resultDiv = document.getElementById('restart-result');
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Restarting mhvtl service...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'restart_service'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Service restarted successfully';
showNotification('Service restarted successfully!', 'success');
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
showNotification('Failed to restart service', 'danger');
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
showNotification('Failed to restart service', 'danger');
});
}
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `alert alert-${type}`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.style.animation = 'slideIn 0.3s ease';
notification.innerHTML = `<strong>${type === 'success' ? '✅' : '❌'}</strong> ${message}`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
let tapeListCache = [];
function loadTapeList() {
const loadingDiv = document.getElementById('tape-list-loading');
const errorDiv = document.getElementById('tape-list-error');
const emptyDiv = document.getElementById('tape-list-empty');
const containerDiv = document.getElementById('tape-list-container');
loadingDiv.style.display = 'block';
errorDiv.style.display = 'none';
emptyDiv.style.display = 'none';
containerDiv.style.display = 'none';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'list_tapes'
})
})
.then(response => response.json())
.then(data => {
loadingDiv.style.display = 'none';
if (data.success) {
tapeListCache = data.tapes;
if (data.tapes.length === 0) {
emptyDiv.style.display = 'block';
} else {
containerDiv.style.display = 'block';
renderTapeList(data.tapes);
}
} else {
errorDiv.style.display = 'block';
errorDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
loadingDiv.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function renderTapeList(tapes) {
const tbody = document.getElementById('tape-list-body');
const countDisplay = document.getElementById('tape-count-display');
tbody.innerHTML = '';
countDisplay.textContent = tapes.length;
tapes.forEach(tape => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="tape-barcode">${tape.name}</td>
<td class="tape-size">${tape.size}</td>
<td class="tape-date">${tape.modified}</td>
<td class="tape-actions">
<button class="btn btn-danger btn-small" onclick="deleteTape('${tape.name}')">
<span>🗑️</span> Delete
</button>
</td>
`;
tbody.appendChild(row);
});
}
function filterTapes() {
const searchTerm = document.getElementById('tape-search').value.toLowerCase();
const filteredTapes = tapeListCache.filter(tape =>
tape.name.toLowerCase().includes(searchTerm)
);
renderTapeList(filteredTapes);
}
function deleteTape(tapeName) {
if (!confirm(`Are you sure you want to delete tape "${tapeName}"?\n\nThis action cannot be undone!`)) {
return;
}
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'delete_tape',
tape_name: tapeName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(`Tape "${tapeName}" deleted successfully!`, 'success');
loadTapeList();
} else {
showNotification(`Failed to delete tape: ${data.error}`, 'danger');
}
})
.catch(error => {
showNotification(`Error: ${error.message}`, 'danger');
});
}
function bulkDeleteTapes() {
const pattern = document.getElementById('bulk-delete-pattern').value.trim();
const resultDiv = document.getElementById('bulk-delete-result');
if (!pattern) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Please enter a delete pattern';
return;
}
if (!confirm(`Are you sure you want to delete all tapes matching "${pattern}"?\n\nThis action cannot be undone!`)) {
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Deleting tapes...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'bulk_delete_tapes',
pattern: pattern
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Deleted ${data.deleted_count} tape(s)`;
showNotification(`Deleted ${data.deleted_count} tape(s)`, 'success');
loadTapeList();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function createTapes() {
const library = document.getElementById('create-library').value;
const barcodePrefix = document.getElementById('create-barcode-prefix').value.trim();
const startNum = parseInt(document.getElementById('create-start-num').value);
const count = parseInt(document.getElementById('create-count').value);
const size = document.getElementById('create-size').value;
const mediaType = document.getElementById('create-media-type').value;
const density = document.getElementById('create-density').value;
const resultDiv = document.getElementById('create-result');
if (!barcodePrefix) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Barcode prefix is required';
return;
}
if (count < 1 || count > 100) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Number of tapes must be between 1 and 100';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Creating tapes...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'create_tapes',
library: library,
barcode_prefix: barcodePrefix,
start_num: startNum,
count: count,
size: size,
media_type: mediaType,
density: density
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Created ${data.created_count} tape(s)`;
if (data.errors && data.errors.length > 0) {
resultDiv.innerHTML += `<br><small>Errors: ${data.errors.join(', ')}</small>`;
}
showNotification(`Created ${data.created_count} tape(s)`, 'success');
loadTapeList();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
// ============================================
// iSCSI Target Management Functions
// ============================================
function loadTargets() {
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'list_targets'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const tbody = document.getElementById('target-list-body');
const emptyDiv = document.getElementById('target-list-empty');
const containerDiv = document.getElementById('target-list-container');
if (data.targets.length === 0) {
emptyDiv.style.display = 'block';
containerDiv.style.display = 'none';
} else {
emptyDiv.style.display = 'none';
containerDiv.style.display = 'block';
tbody.innerHTML = data.targets.map(target => `
<tr>
<td><strong>${target.tid}</strong></td>
<td><code>${target.name}</code></td>
<td>${target.luns || 0}</td>
<td>${target.acls || 0}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteTarget(${target.tid})">
🗑️ Delete
</button>
</td>
</tr>
`).join('');
}
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('Failed to load targets: ' + error.message, 'error');
});
}
function createTarget() {
const tid = document.getElementById('target-tid').value;
const name = document.getElementById('target-name').value.trim();
const resultDiv = document.getElementById('create-target-result');
if (!name) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Target name is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Creating target...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'create_target',
tid: tid,
name: name
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = `<strong>✅ Success!</strong> Target created: ${data.iqn}`;
showNotification('Target created successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function deleteTarget(tid) {
if (!confirm(`Delete target ${tid}? This will remove all LUNs and ACLs.`)) {
return;
}
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'delete_target',
tid: tid
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Target deleted successfully', 'success');
loadTargets();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('Failed to delete target: ' + error.message, 'error');
});
}
function addLun() {
const tid = document.getElementById('lun-tid').value;
const lun = document.getElementById('lun-number').value;
const device = document.getElementById('lun-device').value.trim();
const resultDiv = document.getElementById('add-lun-result');
if (!device) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Device path is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Adding LUN...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'add_lun',
tid: tid,
lun: lun,
device: device
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> LUN added successfully';
showNotification('LUN added successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function bindInitiator() {
const tid = document.getElementById('acl-tid').value;
const address = document.getElementById('acl-address').value.trim();
const resultDiv = document.getElementById('acl-result');
if (!address) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Initiator address is required';
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Binding initiator...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'bind_initiator',
tid: tid,
address: address
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Initiator allowed';
showNotification('Initiator allowed successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}
function unbindInitiator() {
const tid = document.getElementById('acl-tid').value;
const address = document.getElementById('acl-address').value.trim();
const resultDiv = document.getElementById('acl-result');
if (!address) {
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = '<strong>❌ Error:</strong> Initiator address is required';
return;
}
if (!confirm(`Block initiator ${address} from target ${tid}?`)) {
return;
}
resultDiv.style.display = 'block';
resultDiv.className = 'alert alert-info';
resultDiv.innerHTML = '<strong>⏳</strong> Unbinding initiator...';
fetch('api.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'unbind_initiator',
tid: tid,
address: address
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✅ Success!</strong> Initiator blocked';
showNotification('Initiator blocked successfully', 'success');
loadTargets();
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${data.error}`;
}
})
.catch(error => {
resultDiv.className = 'alert alert-danger';
resultDiv.innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
});
}

442
web-ui/style.css Normal file
View File

@@ -0,0 +1,442 @@
:root {
--primary-color: #2563eb;
--secondary-color: #64748b;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--dark-bg: #0f172a;
--card-bg: #1e293b;
--border-color: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--hover-bg: #334155;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.navbar {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand h1 {
font-size: 1.5rem;
color: var(--primary-color);
margin-bottom: 0.25rem;
}
.nav-brand .subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
font-weight: 500;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
.nav-link.active {
color: var(--primary-color);
background: rgba(37, 99, 235, 0.1);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
main.container {
padding: 2rem 1.5rem;
}
.section {
display: none;
animation: fadeIn 0.3s ease;
}
.section.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section-header {
margin-bottom: 2rem;
}
.section-header h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.section-header p {
color: var(--text-secondary);
font-size: 1.125rem;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
margin-bottom: 1.5rem;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.2);
}
.card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: rgba(30, 41, 59, 0.5);
}
.card-header h3 {
font-size: 1.25rem;
color: var(--text-primary);
}
.card-body {
padding: 1.5rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input,
.form-group select {
padding: 0.75rem;
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 1rem;
transition: all 0.3s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group input:hover,
.form-group select:hover {
border-color: var(--primary-color);
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.drives-container {
display: grid;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.drive-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 1.5rem;
position: relative;
}
.drive-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.drive-card-header h4 {
color: var(--primary-color);
font-size: 1.125rem;
}
.config-preview {
background: var(--dark-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem;
color: var(--text-primary);
font-family: 'Courier New', monospace;
font-size: 0.875rem;
overflow-x: auto;
white-space: pre-wrap;
line-height: 1.5;
}
.alert {
padding: 1rem 1.25rem;
border-radius: 0.5rem;
margin-top: 1rem;
border-left: 4px solid;
}
.alert-info {
background: rgba(59, 130, 246, 0.1);
border-color: var(--info-color);
color: var(--text-primary);
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
border-color: var(--success-color);
color: var(--text-primary);
}
.alert-warning {
background: rgba(245, 158, 11, 0.1);
border-color: var(--warning-color);
color: var(--text-primary);
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger-color);
color: var(--text-primary);
}
.footer {
background: rgba(15, 23, 42, 0.95);
border-top: 1px solid var(--border-color);
padding: 2rem 0;
margin-top: 4rem;
text-align: center;
}
.footer p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.3s ease;
}
.footer a:hover {
color: #1d4ed8;
}
.tape-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.tape-table th,
.tape-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.tape-table th {
background: rgba(37, 99, 235, 0.1);
color: var(--primary-color);
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.05em;
}
.tape-table tbody tr {
transition: background-color 0.2s;
}
.tape-table tbody tr:hover {
background: var(--hover-bg);
}
.tape-table .tape-barcode {
font-family: 'Courier New', monospace;
font-weight: 600;
color: var(--info-color);
}
.tape-table .tape-size {
color: var(--text-secondary);
}
.tape-table .tape-date {
color: var(--text-secondary);
font-size: 0.875rem;
}
.tape-actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.navbar .container {
flex-direction: column;
gap: 1rem;
}
.nav-links {
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.form-grid {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
.tape-table {
font-size: 0.875rem;
}
}