Files
atlas/web/templates/iscsi.html
Othman Hendy Suseo 6202ef8e83
Some checks failed
CI / test-build (push) Has been cancelled
fixing UI and iscsi sync
2025-12-20 19:16:50 +00:00

528 lines
20 KiB
HTML

{{define "iscsi-content"}}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
</div>
</div>
<!-- Tabs for Disk Mode and Tape Mode -->
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
<div class="flex border-b border-slate-700">
<button onclick="switchTab('disk')" id="tab-disk" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-blue-600 text-blue-400 bg-slate-800">
Disk Mode
</button>
<button onclick="switchTab('tape')" id="tab-tape" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-transparent text-slate-400 hover:text-white">
Tape Library Passthrough
</button>
</div>
<!-- Disk Mode Content -->
<div id="content-disk" class="tab-content">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Disk Mode Targets</h2>
<p class="text-xs text-slate-400 mt-1">For ZVOL and block devices</p>
</div>
<div class="flex gap-2">
<button onclick="loadISCSITargets('disk')" class="text-sm text-slate-400 hover:text-white">Refresh</button>
<button onclick="showCreateISCSIModal('disk')" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
Create Disk Target
</button>
</div>
</div>
<div id="iscsi-targets-list-disk" class="p-4">
<p class="text-slate-400 text-sm">Loading...</p>
</div>
</div>
<!-- Tape Mode Content -->
<div id="content-tape" class="tab-content hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Tape Library Passthrough Targets</h2>
<p class="text-xs text-slate-400 mt-1">For tape devices (/dev/st*, /dev/nst*)</p>
</div>
<div class="flex gap-2">
<button onclick="loadISCSITargets('tape')" class="text-sm text-slate-400 hover:text-white">Refresh</button>
<button onclick="showCreateISCSIModal('tape')" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium">
Create Tape Target
</button>
</div>
</div>
<div id="iscsi-targets-list-tape" class="p-4">
<p class="text-slate-400 text-sm">Loading...</p>
</div>
</div>
</div>
</div>
<!-- Create iSCSI Target Modal -->
<div id="create-iscsi-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
<h3 id="create-iscsi-modal-title" class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
<form id="create-iscsi-form" onsubmit="createISCSITarget(event)" class="space-y-4">
<input type="hidden" name="type" id="create-iscsi-type" value="disk">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
<input type="text" name="iqn" id="iqn-input" placeholder="iqn.2025-12.com.atlas:target-1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name (format: iqn.YYYY-MM.domain.subdomain:identifier)</p>
<p class="text-xs text-red-400 mt-1">⚠️ Important: Domain must have at least 2 levels (e.g., com.atlas, org.example)</p>
<p class="text-xs text-slate-500 mt-1">Example: iqn.2025-12.com.atlas:target-1 (correct) | iqn.2025-12.atlas:target-1 (wrong - domain too short)</p>
</div>
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('create-iscsi-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
Create
</button>
</div>
</form>
</div>
</div>
<!-- Add LUN Modal -->
<div id="add-lun-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
<h3 class="text-xl font-semibold text-white mb-4">Add LUN to Target</h3>
<form id="add-lun-form" onsubmit="addLUN(event)" class="space-y-4">
<input type="hidden" name="target_id" id="lun-target-id">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Select Storage Volume</label>
<select name="zvol" id="lun-zvol-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
<option value="">Loading volumes...</option>
</select>
<p class="text-xs text-slate-400 mt-1">Or enter manually below</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Or Enter Volume Name Manually</label>
<input type="text" name="zvol-manual" id="lun-zvol-manual" placeholder="pool/zvol" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
</div>
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('add-lun-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
Add LUN
</button>
</div>
</form>
</div>
</div>
<script>
// Check authentication on page load
(function() {
const token = localStorage.getItem('atlas_token');
if (!token) {
// No token, redirect to login
window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
return;
}
})();
function getAuthHeaders() {
const token = localStorage.getItem('atlas_token');
return {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Tab switching
let currentTab = 'disk';
function switchTab(tab) {
currentTab = tab;
// Update tab buttons
document.getElementById('tab-disk').classList.toggle('border-blue-600', tab === 'disk');
document.getElementById('tab-disk').classList.toggle('text-blue-400', tab === 'disk');
document.getElementById('tab-disk').classList.toggle('border-transparent', tab !== 'disk');
document.getElementById('tab-disk').classList.toggle('text-slate-400', tab !== 'disk');
document.getElementById('tab-tape').classList.toggle('border-green-600', tab === 'tape');
document.getElementById('tab-tape').classList.toggle('text-green-400', tab === 'tape');
document.getElementById('tab-tape').classList.toggle('border-transparent', tab !== 'tape');
document.getElementById('tab-tape').classList.toggle('text-slate-400', tab !== 'tape');
// Update content visibility
document.getElementById('content-disk').classList.toggle('hidden', tab !== 'disk');
document.getElementById('content-tape').classList.toggle('hidden', tab !== 'tape');
// Load targets for active tab
loadISCSITargets(tab);
}
async function loadISCSITargets(type = 'disk') {
try {
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
const listEl = document.getElementById(`iscsi-targets-list-${type}`);
if (listEl) {
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
}
return;
}
const allTargets = await res.json();
// Filter targets by type
const targets = Array.isArray(allTargets) ? allTargets.filter(t => (t.type || 'disk') === type) : [];
const listEl = document.getElementById(`iscsi-targets-list-${type}`);
if (!listEl) return;
if (!Array.isArray(allTargets)) {
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
return;
}
if (targets.length === 0) {
listEl.innerHTML = `<p class="text-slate-400 text-sm">No ${type === 'disk' ? 'disk mode' : 'tape passthrough'} targets found</p>`;
return;
}
listEl.innerHTML = targets.map(target => `
<div class="border-b border-slate-700 last:border-0 py-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-white">${target.iqn}</h3>
${target.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
</div>
<div class="text-sm text-slate-400 space-y-1 mb-3">
<p>LUNs: ${target.luns ? target.luns.length : 0}</p>
${target.initiators && target.initiators.length > 0 ? `<p>Initiators: ${target.initiators.join(', ')}</p>` : ''}
</div>
${target.luns && target.luns.length > 0 ? `
<div class="bg-slate-900 rounded p-3 mt-2">
<h4 class="text-sm font-medium text-slate-300 mb-2">LUNs (${target.luns.length}):</h4>
<div class="space-y-2">
${target.luns.map(lun => `
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="text-slate-400">LUN ${lun.id}:</span>
<span class="text-slate-300 font-mono">${lun.zvol}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-slate-300">${formatBytes(lun.size)}</span>
<button onclick="removeLUN('${target.id}', ${lun.id})" class="ml-2 px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs" title="Remove LUN">
Remove
</button>
</div>
</div>
`).join('')}
</div>
</div>
` : '<p class="text-sm text-slate-500 mt-2">No LUNs attached. Click "Add LUN" to bind a volume.</p>'}
</div>
<div class="flex gap-2 ml-4">
<button onclick="showAddLUNModal('${target.id}', '${target.type || 'disk'}')" class="px-3 py-1.5 ${target.type === 'tape' ? 'bg-green-600 hover:bg-green-700' : 'bg-blue-600 hover:bg-blue-700'} text-white rounded text-sm">
Add LUN
</button>
<button onclick="showConnectionInstructions('${target.id}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
Connection Info
</button>
<button onclick="deleteISCSITarget('${target.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
Delete
</button>
</div>
</div>
</div>
`).join('');
} catch (err) {
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
}
}
function showCreateISCSIModal(type = 'disk') {
// Generate default IQN with current date
// IQN format: iqn.YYYY-MM.domain.subdomain:identifier
// Domain must have at least 2 levels (e.g., com.atlas)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const suffix = type === 'tape' ? 'tape' : 'target';
const defaultIQN = `iqn.${year}-${month}.com.atlas:${suffix}-1`;
// Set target type
document.getElementById('create-iscsi-type').value = type;
document.getElementById('create-iscsi-modal-title').textContent = type === 'tape' ? 'Create Tape Library Passthrough Target' : 'Create Disk Mode Target';
const iqnInput = document.getElementById('iqn-input');
if (iqnInput) {
iqnInput.value = defaultIQN;
// Auto-fix common mistake: replace last dot with colon if needed
iqnInput.addEventListener('blur', function() {
let value = this.value.trim();
// If user typed dot before identifier, replace with colon
// Pattern: iqn.YYYY-MM.domain.identifier -> iqn.YYYY-MM.domain:identifier
if (value.match(/^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z0-9]/) && !value.includes(':')) {
// Replace last dot with colon
const lastDotIndex = value.lastIndexOf('.');
if (lastDotIndex > 0) {
value = value.substring(0, lastDotIndex) + ':' + value.substring(lastDotIndex + 1);
this.value = value;
}
}
});
}
document.getElementById('create-iscsi-modal').classList.remove('hidden');
}
async function showAddLUNModal(targetId) {
document.getElementById('lun-target-id').value = targetId;
document.getElementById('add-lun-modal').classList.remove('hidden');
// Load available storage volumes
await loadZVOLsForLUN();
}
async function loadZVOLsForLUN() {
try {
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
const selectEl = document.getElementById('lun-zvol-select');
if (!res.ok) {
selectEl.innerHTML = '<option value="">Error loading volumes</option>';
return;
}
const zvols = await res.json();
if (!Array.isArray(zvols)) {
selectEl.innerHTML = '<option value="">No volumes available</option>';
return;
}
if (zvols.length === 0) {
selectEl.innerHTML = '<option value="">No volumes found. Create a storage volume first.</option>';
return;
}
// Clear and populate dropdown
selectEl.innerHTML = '<option value="">Select a volume...</option>';
zvols.forEach(zvol => {
const option = document.createElement('option');
option.value = zvol.name;
option.textContent = `${zvol.name} (${formatBytes(zvol.size)})`;
selectEl.appendChild(option);
});
// Update manual input when dropdown changes
selectEl.addEventListener('change', function() {
if (this.value) {
document.getElementById('lun-zvol-manual').value = this.value;
}
});
// Update dropdown when manual input changes
document.getElementById('lun-zvol-manual').addEventListener('input', function() {
if (this.value && !selectEl.value) {
// Allow manual entry even if not in dropdown
}
});
} catch (err) {
console.error('Error loading volumes:', err);
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Error loading volumes</option>';
}
}
async function showConnectionInstructions(targetId) {
try {
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/connection`, { headers: getAuthHeaders() });
const data = await res.json();
const instructions = `
Connection Instructions for ${data.target?.iqn || 'target'}:
Portal: ${data.portal || 'N/A'}
Linux:
iscsiadm -m discovery -t st -p ${data.portal || '127.0.0.1'}
iscsiadm -m node -T ${data.target?.iqn || ''} -p ${data.portal || '127.0.0.1'} --login
Windows:
Use iSCSI Initiator from Control Panel
Add target: ${data.portal || '127.0.0.1'}:${data.port || 3260}
Target: ${data.target?.iqn || ''}
macOS:
Use System Preferences > Network > iSCSI
`;
alert(instructions);
} catch (err) {
alert(`Error: ${err.message}`);
}
}
function closeModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
async function createISCSITarget(e) {
e.preventDefault();
const formData = new FormData(e.target);
const targetType = formData.get('type') || 'disk';
try {
const res = await fetch('/api/v1/iscsi/targets', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
iqn: formData.get('iqn'),
type: targetType
})
});
if (res.ok) {
closeModal('create-iscsi-modal');
e.target.reset();
loadISCSITargets(targetType);
alert(`${targetType === 'tape' ? 'Tape' : 'Disk'} target created successfully`);
} else {
const err = await res.json().catch(() => ({ error: 'Failed to parse error response' }));
let errMsg = 'Failed to create iSCSI target';
if (err) {
if (err.message) {
errMsg = err.message;
if (err.details) errMsg += ': ' + err.details;
} else if (err.error) {
errMsg = err.error;
}
}
alert(`Error: ${errMsg}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
async function addLUN(e) {
e.preventDefault();
const formData = new FormData(e.target);
const targetId = formData.get('target_id');
const targetType = formData.get('target_type') || 'disk';
let requestBody = {};
if (targetType === 'tape') {
// Tape mode: use device
const device = document.getElementById('lun-device-input').value.trim();
if (!device) {
alert('Please enter a tape device path');
return;
}
requestBody = {
device: device,
backstore: 'pscsi'
};
} else {
// Disk mode: use ZVOL
const zvolSelect = document.getElementById('lun-zvol-select').value;
const zvolManual = document.getElementById('lun-zvol-manual').value.trim();
const zvol = zvolSelect || zvolManual;
if (!zvol) {
alert('Please select or enter a volume name');
return;
}
requestBody = {
zvol: zvol
};
}
try {
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(requestBody)
});
if (res.ok) {
closeModal('add-lun-modal');
e.target.reset();
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
document.getElementById('lun-zvol-manual').value = '';
document.getElementById('lun-device-input').value = '';
// Reload targets for the current tab
loadISCSITargets(targetType);
alert('LUN added successfully');
} else {
const err = await res.json();
alert(`Error: ${err.error || 'Failed to add LUN'}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
async function removeLUN(targetId, lunId) {
if (!confirm(`Are you sure you want to remove LUN ${lunId} from this target?`)) return;
try {
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns/remove`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
lun_id: lunId
})
});
if (res.ok) {
// Reload targets for current tab
const activeTab = document.getElementById('content-disk').classList.contains('hidden') ? 'tape' : 'disk';
loadISCSITargets(activeTab);
alert('LUN removed successfully');
} else {
const err = await res.json();
alert(`Error: ${err.error || 'Failed to remove LUN'}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
async function deleteISCSITarget(id) {
if (!confirm('Are you sure you want to delete this iSCSI target? All LUNs will be removed.')) return;
try {
const res = await fetch(`/api/v1/iscsi/targets/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (res.ok) {
// Reload targets for current tab
const activeTab = document.getElementById('content-disk').classList.contains('hidden') ? 'tape' : 'disk';
loadISCSITargets(activeTab);
alert('iSCSI target deleted successfully');
} else {
const err = await res.json();
alert(`Error: ${err.error || 'Failed to delete iSCSI target'}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
// Load initial data - will be called in auth check function
</script>
{{end}}
{{define "iscsi.html"}}
{{template "base" .}}
{{end}}