add installer alpha version
This commit is contained in:
@@ -4,11 +4,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{.Title}} • atlasOS</title>
|
||||
<title>{{.Title}} • AtlasOS</title>
|
||||
|
||||
<!-- v1: Tailwind CDN (later: bundle local) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<!-- Try multiple CDN sources for better reliability -->
|
||||
<script src="https://cdn.tailwindcss.com" onerror="this.onerror=null;this.src='https://unpkg.com/@tailwindcss/browser@4/dist/tailwind.min.js'"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" onerror="this.onerror=null;this.src='https://cdn.jsdelivr.net/npm/htmx.org@1.9.12/dist/htmx.min.js'"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
@@ -17,31 +18,92 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-9 rounded-lg bg-slate-800 flex items-center justify-center font-bold">A</div>
|
||||
<div>
|
||||
<div class="font-semibold leading-tight">atlasOS</div>
|
||||
<div class="font-semibold leading-tight">AtlasOS</div>
|
||||
<div class="text-xs text-slate-400 leading-tight">Storage Controller v1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="text-sm text-slate-300 flex items-center gap-4">
|
||||
<a class="hover:text-white" href="/">Dashboard</a>
|
||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Storage</a>
|
||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Shares</a>
|
||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">iSCSI</a>
|
||||
<a class="hover:text-white" href="/storage">Storage</a>
|
||||
<a class="hover:text-white" href="/shares">Shares</a>
|
||||
<a class="hover:text-white" href="/iscsi">iSCSI</a>
|
||||
<a class="hover:text-white" href="/protection">Data Protection</a>
|
||||
<a class="hover:text-white" href="/management">Management</a>
|
||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Monitoring</a>
|
||||
<span id="auth-status" class="ml-4"></span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||
{{template "content" .}}
|
||||
{{$ct := getContentTemplate .}}
|
||||
{{if eq $ct "storage-content"}}
|
||||
{{template "storage-content" .}}
|
||||
{{else if eq $ct "shares-content"}}
|
||||
{{template "shares-content" .}}
|
||||
{{else if eq $ct "iscsi-content"}}
|
||||
{{template "iscsi-content" .}}
|
||||
{{else if eq $ct "protection-content"}}
|
||||
{{template "protection-content" .}}
|
||||
{{else if eq $ct "management-content"}}
|
||||
{{template "management-content" .}}
|
||||
{{else if eq $ct "login-content"}}
|
||||
{{template "login-content" .}}
|
||||
{{else}}
|
||||
{{template "content" .}}
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto max-w-6xl px-4 pb-10 text-xs text-slate-500">
|
||||
<div class="border-t border-slate-800 pt-4 flex items-center justify-between">
|
||||
<span>atlasOS • {{nowRFC3339}}</span>
|
||||
<span>AtlasOS • {{nowRFC3339}}</span>
|
||||
<span>Build: {{index .Build "version"}}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
// Update auth status in navigation
|
||||
function updateAuthStatus() {
|
||||
const authStatusEl = document.getElementById('auth-status');
|
||||
if (!authStatusEl) return;
|
||||
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const userStr = localStorage.getItem('atlas_user');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
authStatusEl.innerHTML = `
|
||||
<span class="text-slate-400">${user.username || 'User'}</span>
|
||||
<button onclick="handleLogout()" class="ml-2 px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded">
|
||||
Logout
|
||||
</button>
|
||||
`;
|
||||
} catch {
|
||||
authStatusEl.innerHTML = `
|
||||
<a href="/login" class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">Login</a>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
authStatusEl.innerHTML = `
|
||||
<a href="/login" class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">Login</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('atlas_token');
|
||||
localStorage.removeItem('atlas_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Update on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateAuthStatus);
|
||||
} else {
|
||||
updateAuthStatus();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-slate-400">Welcome to atlasOS Storage Controller</p>
|
||||
<p class="text-slate-400">Welcome to AtlasOS Storage Controller</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white mb-1" id="pool-count">-</p>
|
||||
<p class="text-sm text-slate-400">ZFS Pools</p>
|
||||
<p class="text-sm text-slate-400">Storage Pools</p>
|
||||
</div>
|
||||
|
||||
<!-- Storage Capacity Card -->
|
||||
@@ -124,7 +124,12 @@
|
||||
// Fetch dashboard data and update UI
|
||||
function updateDashboard() {
|
||||
fetch('/api/v1/dashboard')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Update storage stats
|
||||
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
||||
|
||||
380
web/templates/iscsi.html
Normal file
380
web/templates/iscsi.html
Normal file
@@ -0,0 +1,380 @@
|
||||
{{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>
|
||||
<button onclick="showCreateISCSIModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">iSCSI Targets</h2>
|
||||
<button onclick="loadISCSITargets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
<div id="iscsi-targets-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</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 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">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
|
||||
<input type="text" name="iqn" placeholder="iqn.2024-12.com.atlas:target1" 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</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>
|
||||
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];
|
||||
}
|
||||
|
||||
async function loadISCSITargets() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
|
||||
return;
|
||||
}
|
||||
const targets = await res.json();
|
||||
const listEl = document.getElementById('iscsi-targets-list');
|
||||
|
||||
if (!Array.isArray(targets)) {
|
||||
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 iSCSI 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}')" class="px-3 py-1.5 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() {
|
||||
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);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/iscsi/targets', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
iqn: formData.get('iqn')
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-iscsi-modal');
|
||||
e.target.reset();
|
||||
loadISCSITargets();
|
||||
alert('iSCSI target created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create iSCSI target'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addLUN(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const targetId = formData.get('target_id');
|
||||
|
||||
// Get volume from either dropdown or manual input
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
zvol: zvol
|
||||
})
|
||||
});
|
||||
|
||||
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 = '';
|
||||
loadISCSITargets();
|
||||
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) {
|
||||
loadISCSITargets();
|
||||
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) {
|
||||
loadISCSITargets();
|
||||
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
|
||||
loadISCSITargets();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "iscsi.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
81
web/templates/login.html
Normal file
81
web/templates/login.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{{define "login-content"}}
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-950">
|
||||
<div class="max-w-md w-full mx-4">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-8 shadow-xl">
|
||||
<div class="text-center mb-8">
|
||||
<div class="h-16 w-16 rounded-lg bg-slate-700 flex items-center justify-center font-bold text-2xl mx-auto mb-4">A</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">AtlasOS</h1>
|
||||
<p class="text-slate-400">Storage Controller</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" onsubmit="handleLogin(event)" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Username</label>
|
||||
<input type="text" name="username" id="username" required autofocus class="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Enter your username">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Password</label>
|
||||
<input type="password" name="password" id="password" required class="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Enter your password">
|
||||
</div>
|
||||
<div id="login-error" class="hidden text-red-400 text-sm"></div>
|
||||
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-slate-400">
|
||||
<p>Default credentials: <span class="font-mono text-slate-300">admin / admin</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const errorEl = document.getElementById('login-error');
|
||||
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password')
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok && data.token) {
|
||||
// Store token in localStorage
|
||||
localStorage.setItem('atlas_token', data.token);
|
||||
|
||||
// Store user info if available
|
||||
if (data.user) {
|
||||
localStorage.setItem('atlas_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
// Redirect to dashboard or return URL
|
||||
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/';
|
||||
window.location.href = returnUrl;
|
||||
} else {
|
||||
const errorMsg = (data && data.error) ? data.error : 'Login failed';
|
||||
errorEl.textContent = errorMsg;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = `Error: ${err.message}`;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "login.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
710
web/templates/management.html
Normal file
710
web/templates/management.html
Normal file
@@ -0,0 +1,710 @@
|
||||
{{define "management-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">System Management</h1>
|
||||
<p class="text-slate-400">Manage services and users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('services')" id="tab-services" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
Services
|
||||
</button>
|
||||
<button onclick="switchTab('users')" id="tab-users" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Users
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Services Tab -->
|
||||
<div id="content-services" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Service Management</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="loadServiceStatus()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Services List -->
|
||||
<div id="services-list" class="space-y-4">
|
||||
<p class="text-slate-400 text-sm">Loading services...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="content-users" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">User Management</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateUserModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create User
|
||||
</button>
|
||||
<button onclick="loadUsers()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="users-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Logs Modal -->
|
||||
<div id="service-logs-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-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-white" id="service-logs-title">Service Logs</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" id="logs-lines" value="50" min="10" max="1000" class="w-20 px-2 py-1 bg-slate-900 border border-slate-700 rounded text-white text-sm">
|
||||
<button onclick="loadServiceLogs()" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">Refresh</button>
|
||||
<button onclick="closeModal('service-logs-modal')" class="px-3 py-1 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="service-logs-content" class="flex-1 overflow-y-auto bg-slate-900 rounded p-4 text-sm text-slate-300 font-mono whitespace-pre-wrap">
|
||||
Loading logs...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="create-user-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">Create User</h3>
|
||||
<form id="create-user-form" onsubmit="createUser(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Username *</label>
|
||||
<input type="text" name="username" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Email</label>
|
||||
<input type="email" name="email" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Password *</label>
|
||||
<input type="password" name="password" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Role *</label>
|
||||
<select name="role" 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="viewer">Viewer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="administrator">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-user-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>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<div id="edit-user-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">Edit User</h3>
|
||||
<form id="edit-user-form" onsubmit="updateUser(event)" class="space-y-4">
|
||||
<input type="hidden" id="edit-user-id" name="id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Username</label>
|
||||
<input type="text" id="edit-username" disabled class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-slate-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Email</label>
|
||||
<input type="email" id="edit-email" name="email" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Role</label>
|
||||
<select id="edit-role" name="role" 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="viewer">Viewer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="administrator">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" id="edit-active" name="active" class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Active</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('edit-user-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">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<div id="change-password-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">Change Password</h3>
|
||||
<form id="change-password-form" onsubmit="changePassword(event)" class="space-y-4">
|
||||
<input type="hidden" id="change-password-user-id" name="id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Current Password *</label>
|
||||
<input type="password" name="old_password" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">New Password *</label>
|
||||
<input type="password" name="new_password" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Confirm New Password *</label>
|
||||
<input type="password" name="confirm_password" 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">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('change-password-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">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
el.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
|
||||
// Load data for the tab
|
||||
if (tab === 'services') loadServiceStatus();
|
||||
else if (tab === 'users') loadUsers();
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
// ===== Service Management =====
|
||||
|
||||
let currentServiceName = null;
|
||||
|
||||
async function loadServiceStatus() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const listEl = document.getElementById('services-list');
|
||||
|
||||
if (!token) {
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<p class="text-slate-400 mb-2">Authentication required</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">Loading services...</p>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/services', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('atlas_token');
|
||||
localStorage.removeItem('atlas_user');
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<p class="text-slate-400 mb-2">Session expired. Please login again.</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const services = (data && data.services) ? data.services : [];
|
||||
|
||||
if (services.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No services found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render all services
|
||||
listEl.innerHTML = services.map(svc => {
|
||||
const statusBadge = getStatusBadge(svc.status);
|
||||
const statusColor = getStatusColor(svc.status);
|
||||
|
||||
return `
|
||||
<div class="bg-slate-900 rounded-lg border border-slate-700 p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-md font-semibold text-white">${escapeHtml(svc.display_name)}</h3>
|
||||
<div class="px-3 py-1 rounded text-xs font-medium ${statusColor}">
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 font-mono mb-4 max-h-24 overflow-y-auto whitespace-pre-wrap">
|
||||
${escapeHtml((svc.output || '').split('\n').slice(-5).join('\n')) || 'No status information'}
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button onclick="serviceAction('${svc.name}', 'start')" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded text-xs">
|
||||
Start
|
||||
</button>
|
||||
<button onclick="serviceAction('${svc.name}', 'stop')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-xs">
|
||||
Stop
|
||||
</button>
|
||||
<button onclick="serviceAction('${svc.name}', 'restart')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-xs">
|
||||
Restart
|
||||
</button>
|
||||
<button onclick="serviceAction('${svc.name}', 'reload')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs">
|
||||
Reload
|
||||
</button>
|
||||
<button onclick="showServiceLogs('${svc.name}', '${escapeHtml(svc.display_name)}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs">
|
||||
View Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status) {
|
||||
switch(status) {
|
||||
case 'running': return 'Running';
|
||||
case 'stopped': return 'Stopped';
|
||||
case 'failed': return 'Failed';
|
||||
case 'not-found': return 'Not Found';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch(status) {
|
||||
case 'running': return 'bg-green-900 text-green-300';
|
||||
case 'stopped': return 'bg-red-900 text-red-300';
|
||||
case 'failed': return 'bg-red-900 text-red-300';
|
||||
case 'not-found': return 'bg-slate-700 text-slate-300';
|
||||
default: return 'bg-slate-700 text-slate-300';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function serviceAction(serviceName, action) {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
if (!token) {
|
||||
alert('Please login first');
|
||||
window.location.href = '/login?return=/management';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} ${serviceName}?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/services/${action}?service=${encodeURIComponent(serviceName)}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
alert(`${serviceName} ${action}ed successfully`);
|
||||
loadServiceStatus();
|
||||
} else {
|
||||
const errorMsg = (data && data.error) ? data.error : `Failed to ${action} service`;
|
||||
alert(`Error: ${errorMsg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showServiceLogs(serviceName, displayName) {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
if (!token) {
|
||||
alert('Please login first');
|
||||
window.location.href = '/login?return=/management';
|
||||
return;
|
||||
}
|
||||
|
||||
currentServiceName = serviceName;
|
||||
document.getElementById('service-logs-title').textContent = `${displayName} Logs`;
|
||||
document.getElementById('service-logs-modal').classList.remove('hidden');
|
||||
loadServiceLogs();
|
||||
}
|
||||
|
||||
async function loadServiceLogs() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
if (!token) {
|
||||
document.getElementById('service-logs-content').textContent = 'Authentication required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentServiceName) {
|
||||
document.getElementById('service-logs-content').textContent = 'No service selected';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = document.getElementById('logs-lines').value || '50';
|
||||
try {
|
||||
const res = await fetch(`/api/v1/services/logs?service=${encodeURIComponent(currentServiceName)}&lines=${lines}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
document.getElementById('service-logs-content').textContent = `Error: ${errorMsg}`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('service-logs-content').textContent = data.logs || 'No logs available';
|
||||
} catch (err) {
|
||||
document.getElementById('service-logs-content').textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User Management =====
|
||||
|
||||
async function loadUsers(forceRefresh = false) {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const listEl = document.getElementById('users-list');
|
||||
|
||||
if (!token) {
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 mb-2">Authentication required to view users</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">Loading users...</p>';
|
||||
|
||||
try {
|
||||
// Add cache busting parameter if force refresh
|
||||
const url = forceRefresh ? `/api/v1/users?_t=${Date.now()}` : '/api/v1/users';
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
cache: forceRefresh ? 'no-cache' : 'default'
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(rawText);
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse JSON response:', parseErr, 'Raw response:', rawText);
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid JSON response from server</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('atlas_token');
|
||||
localStorage.removeItem('atlas_user');
|
||||
listEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 mb-2">Session expired. Please login again.</p>
|
||||
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.error('Invalid response format:', data);
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format (expected array)</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Loaded ${data.length} users:`, data);
|
||||
|
||||
if (data.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No users found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort users by ID to ensure consistent ordering
|
||||
const sortedUsers = data.sort((a, b) => {
|
||||
const idA = (a.id || '').toLowerCase();
|
||||
const idB = (b.id || '').toLowerCase();
|
||||
return idA.localeCompare(idB);
|
||||
});
|
||||
|
||||
listEl.innerHTML = sortedUsers.map(user => {
|
||||
// Escape HTML to prevent XSS
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const username = escapeHtml(user.username || 'N/A');
|
||||
const email = user.email ? escapeHtml(user.email) : '';
|
||||
const userId = escapeHtml(user.id || 'N/A');
|
||||
const role = escapeHtml(user.role || 'N/A');
|
||||
const active = user.active !== false;
|
||||
|
||||
return `
|
||||
<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">${username}</h3>
|
||||
${active ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Active</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Inactive</span>'}
|
||||
<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">${role}</span>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
${email ? `<p>Email: <span class="text-slate-300">${email}</span></p>` : ''}
|
||||
<p>ID: <span class="text-slate-300 font-mono text-xs">${userId}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="showEditUserModal('${userId}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="showChangePasswordModal('${userId}', '${username}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Change Password
|
||||
</button>
|
||||
<button onclick="deleteUser('${userId}', '${username}')" 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('users-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('create-user-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function createUser(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/users', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email') || '',
|
||||
password: formData.get('password'),
|
||||
role: formData.get('role')
|
||||
})
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(rawText);
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse create user response:', parseErr, 'Raw:', rawText);
|
||||
alert('Error: Invalid response from server');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Create user response:', res.status, data);
|
||||
|
||||
if (res.ok || res.status === 201) {
|
||||
console.log('User created successfully, refreshing list...');
|
||||
closeModal('create-user-modal');
|
||||
e.target.reset();
|
||||
// Force reload users list - add cache busting
|
||||
await loadUsers(true);
|
||||
alert('User created successfully');
|
||||
} else {
|
||||
const errorMsg = (data && data.error) ? data.error : 'Failed to create user';
|
||||
console.error('Create user failed:', errorMsg);
|
||||
alert(`Error: ${errorMsg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showEditUserModal(userId) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to load user'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
document.getElementById('edit-user-id').value = user.id;
|
||||
document.getElementById('edit-username').value = user.username || '';
|
||||
document.getElementById('edit-email').value = user.email || '';
|
||||
document.getElementById('edit-role').value = user.role || 'Viewer';
|
||||
document.getElementById('edit-active').checked = user.active !== false;
|
||||
|
||||
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const userId = formData.get('id');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
email: formData.get('email') || '',
|
||||
role: formData.get('role'),
|
||||
active: formData.get('active') === 'on'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('edit-user-modal');
|
||||
await loadUsers(true);
|
||||
alert('User updated successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to update user'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await loadUsers(true);
|
||||
alert('User deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete user'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showChangePasswordModal(userId, username) {
|
||||
document.getElementById('change-password-user-id').value = userId;
|
||||
document.getElementById('change-password-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function changePassword(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const userId = document.getElementById('change-password-user-id').value;
|
||||
const newPassword = formData.get('new_password');
|
||||
const confirmPassword = formData.get('confirm_password');
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${userId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
old_password: formData.get('old_password'),
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('change-password-modal');
|
||||
e.target.reset();
|
||||
alert('Password changed successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to change password'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data when page loads
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadServiceStatus();
|
||||
// Load users if on users tab
|
||||
const usersTab = document.getElementById('tab-users');
|
||||
if (usersTab && usersTab.classList.contains('border-blue-600')) {
|
||||
loadUsers();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
loadServiceStatus();
|
||||
// Load users if on users tab
|
||||
const usersTab = document.getElementById('tab-users');
|
||||
if (usersTab && usersTab.classList.contains('border-blue-600')) {
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "management.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
704
web/templates/protection.html
Normal file
704
web/templates/protection.html
Normal file
@@ -0,0 +1,704 @@
|
||||
{{define "protection-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Data Protection</h1>
|
||||
<p class="text-slate-400">Manage snapshots, scheduling, and replication</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('snapshots')" id="tab-snapshots" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
Snapshots
|
||||
</button>
|
||||
<button onclick="switchTab('policies')" id="tab-policies" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Snapshot Policies
|
||||
</button>
|
||||
<button onclick="switchTab('volume-snapshots')" id="tab-volume-snapshots" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Volume Snapshots
|
||||
</button>
|
||||
<button onclick="switchTab('replication')" id="tab-replication" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Replication
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots Tab -->
|
||||
<div id="content-snapshots" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Snapshots</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateSnapshotModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Snapshot
|
||||
</button>
|
||||
<button onclick="loadSnapshots()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snapshots-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot Policies Tab -->
|
||||
<div id="content-policies" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Snapshot Policies</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreatePolicyModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Policy
|
||||
</button>
|
||||
<button onclick="loadPolicies()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="policies-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume Snapshots Tab -->
|
||||
<div id="content-volume-snapshots" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Volume Snapshots</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateVolumeSnapshotModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Volume Snapshot
|
||||
</button>
|
||||
<button onclick="loadVolumeSnapshots()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="volume-snapshots-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replication Tab -->
|
||||
<div id="content-replication" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Replication</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateReplicationModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Replication Task
|
||||
</button>
|
||||
<button onclick="loadReplications()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="replications-list" class="p-4">
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 text-sm mb-2">Replication feature coming soon</p>
|
||||
<p class="text-slate-500 text-xs">This feature will allow you to replicate snapshots to remote systems</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Snapshot Modal -->
|
||||
<div id="create-snapshot-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">Create Snapshot</h3>
|
||||
<form id="create-snapshot-form" onsubmit="createSnapshot(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<select name="dataset" id="snapshot-dataset-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 datasets...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Snapshot Name</label>
|
||||
<input type="text" name="name" placeholder="snapshot-2024-12-15" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" name="recursive" class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Recursive (include child datasets)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-snapshot-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>
|
||||
|
||||
<!-- Create Snapshot Policy Modal -->
|
||||
<div id="create-policy-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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create Snapshot Policy</h3>
|
||||
<form id="create-policy-form" onsubmit="createPolicy(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<select name="dataset" id="policy-dataset-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 datasets...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Frequent (15min)</label>
|
||||
<input type="number" name="frequent" min="0" value="0" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Hourly</label>
|
||||
<input type="number" name="hourly" min="0" value="0" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Daily</label>
|
||||
<input type="number" name="daily" min="0" value="0" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Weekly</label>
|
||||
<input type="number" name="weekly" min="0" value="0" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Monthly</label>
|
||||
<input type="number" name="monthly" min="0" value="0" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Yearly</label>
|
||||
<input type="number" name="yearly" min="0" value="0" 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>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" name="autosnap" checked class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Enable automatic snapshots</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" name="autoprune" checked class="rounded bg-slate-900 border-slate-700">
|
||||
<span>Enable automatic pruning</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-policy-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>
|
||||
|
||||
<!-- Create Volume Snapshot Modal -->
|
||||
<div id="create-volume-snapshot-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">Create Volume Snapshot</h3>
|
||||
<form id="create-volume-snapshot-form" onsubmit="createVolumeSnapshot(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Volume</label>
|
||||
<select name="volume" id="volume-snapshot-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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Snapshot Name</label>
|
||||
<input type="text" name="name" placeholder="volume-snapshot-2024-12-15" 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">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-volume-snapshot-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>
|
||||
|
||||
<script>
|
||||
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];
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
el.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400', 'font-medium');
|
||||
|
||||
// Load data for the tab
|
||||
if (tab === 'snapshots') loadSnapshots();
|
||||
else if (tab === 'policies') loadPolicies();
|
||||
else if (tab === 'volume-snapshots') loadVolumeSnapshots();
|
||||
else if (tab === 'replication') loadReplications();
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
// Snapshot Management
|
||||
async function loadSnapshots() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('snapshots-list');
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No snapshots found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = data.map(snap => `
|
||||
<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 font-mono">${snap.name}</h3>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Dataset: <span class="text-slate-300">${snap.dataset}</span></p>
|
||||
<p>Size: <span class="text-slate-300">${formatBytes(snap.size || 0)}</span></p>
|
||||
<p>Created: <span class="text-slate-300">${formatDate(snap.created_at)}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="deleteSnapshot('${snap.name}')" 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('snapshots-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreateSnapshotModal() {
|
||||
await loadDatasetsForSnapshot();
|
||||
document.getElementById('create-snapshot-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadDatasetsForSnapshot() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('snapshot-dataset-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading datasets</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets = await res.json();
|
||||
|
||||
if (!Array.isArray(datasets) || datasets.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No datasets found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.innerHTML = '<option value="">Select a dataset...</option>';
|
||||
datasets.forEach(ds => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ds.name;
|
||||
option.textContent = `${ds.name} (${ds.type})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading datasets:', err);
|
||||
document.getElementById('snapshot-dataset-select').innerHTML = '<option value="">Error loading datasets</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
dataset: formData.get('dataset'),
|
||||
name: formData.get('name'),
|
||||
recursive: formData.get('recursive') === 'on'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-snapshot-modal');
|
||||
e.target.reset();
|
||||
loadSnapshots();
|
||||
alert('Snapshot created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create snapshot'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSnapshot(name) {
|
||||
if (!confirm(`Are you sure you want to delete snapshot "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/snapshots/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadSnapshots();
|
||||
alert('Snapshot deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete snapshot'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot Policy Management
|
||||
async function loadPolicies() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshot-policies', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('policies-list');
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No snapshot policies found. Create a policy to enable automatic snapshots.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = data.map(policy => `
|
||||
<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">${policy.dataset}</h3>
|
||||
${policy.autosnap ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Auto-snap Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Auto-snap Disabled</span>'}
|
||||
${policy.autoprune ? '<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">Auto-prune Enabled</span>' : ''}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Retention:
|
||||
${policy.frequent > 0 ? `<span class="text-slate-300">${policy.frequent} frequent</span>` : ''}
|
||||
${policy.hourly > 0 ? `<span class="text-slate-300">${policy.hourly} hourly</span>` : ''}
|
||||
${policy.daily > 0 ? `<span class="text-slate-300">${policy.daily} daily</span>` : ''}
|
||||
${policy.weekly > 0 ? `<span class="text-slate-300">${policy.weekly} weekly</span>` : ''}
|
||||
${policy.monthly > 0 ? `<span class="text-slate-300">${policy.monthly} monthly</span>` : ''}
|
||||
${policy.yearly > 0 ? `<span class="text-slate-300">${policy.yearly} yearly</span>` : ''}
|
||||
${!policy.frequent && !policy.hourly && !policy.daily && !policy.weekly && !policy.monthly && !policy.yearly ? '<span class="text-slate-500">No retention configured</span>' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="editPolicy('${policy.dataset}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="deletePolicy('${policy.dataset}')" 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('policies-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreatePolicyModal() {
|
||||
await loadDatasetsForPolicy();
|
||||
document.getElementById('create-policy-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadDatasetsForPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('policy-dataset-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading datasets</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets = await res.json();
|
||||
|
||||
if (!Array.isArray(datasets) || datasets.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No datasets found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.innerHTML = '<option value="">Select a dataset...</option>';
|
||||
datasets.forEach(ds => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ds.name;
|
||||
option.textContent = `${ds.name} (${ds.type})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading datasets:', err);
|
||||
document.getElementById('policy-dataset-select').innerHTML = '<option value="">Error loading datasets</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createPolicy(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshot-policies', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
dataset: formData.get('dataset'),
|
||||
frequent: parseInt(formData.get('frequent')) || 0,
|
||||
hourly: parseInt(formData.get('hourly')) || 0,
|
||||
daily: parseInt(formData.get('daily')) || 0,
|
||||
weekly: parseInt(formData.get('weekly')) || 0,
|
||||
monthly: parseInt(formData.get('monthly')) || 0,
|
||||
yearly: parseInt(formData.get('yearly')) || 0,
|
||||
autosnap: formData.get('autosnap') === 'on',
|
||||
autoprune: formData.get('autoprune') === 'on'
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-policy-modal');
|
||||
e.target.reset();
|
||||
loadPolicies();
|
||||
alert('Snapshot policy created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create snapshot policy'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function editPolicy(dataset) {
|
||||
// Load policy and populate form (similar to create but with PUT)
|
||||
alert('Edit policy feature - coming soon');
|
||||
}
|
||||
|
||||
async function deletePolicy(dataset) {
|
||||
if (!confirm(`Are you sure you want to delete snapshot policy for "${dataset}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/snapshot-policies/${encodeURIComponent(dataset)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadPolicies();
|
||||
alert('Snapshot policy deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete snapshot policy'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume Snapshots (same as regular snapshots but filtered for volumes)
|
||||
async function loadVolumeSnapshots() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('volume-snapshots-list');
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter for volume snapshots (ZVOLs)
|
||||
const volumeSnapshots = data.filter(snap => {
|
||||
// Check if dataset is a volume (starts with pool/ and might be a zvol)
|
||||
return snap.dataset && snap.dataset.includes('/');
|
||||
});
|
||||
|
||||
if (volumeSnapshots.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No volume snapshots found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = volumeSnapshots.map(snap => `
|
||||
<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 font-mono">${snap.name}</h3>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
<p>Volume: <span class="text-slate-300">${snap.dataset}</span></p>
|
||||
<p>Size: <span class="text-slate-300">${formatBytes(snap.size || 0)}</span></p>
|
||||
<p>Created: <span class="text-slate-300">${formatDate(snap.created_at)}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="deleteSnapshot('${snap.name}')" 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('volume-snapshots-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreateVolumeSnapshotModal() {
|
||||
await loadVolumesForSnapshot();
|
||||
document.getElementById('create-volume-snapshot-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadVolumesForSnapshot() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||
const selectEl = document.getElementById('volume-snapshot-select');
|
||||
|
||||
if (!res.ok) {
|
||||
selectEl.innerHTML = '<option value="">Error loading volumes</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const volumes = await res.json();
|
||||
|
||||
if (!Array.isArray(volumes) || volumes.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No volumes found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.innerHTML = '<option value="">Select a volume...</option>';
|
||||
volumes.forEach(vol => {
|
||||
const option = document.createElement('option');
|
||||
option.value = vol.name;
|
||||
option.textContent = `${vol.name} (${formatBytes(vol.size)})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading volumes:', err);
|
||||
document.getElementById('volume-snapshot-select').innerHTML = '<option value="">Error loading volumes</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createVolumeSnapshot(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/snapshots', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
dataset: formData.get('volume'),
|
||||
name: formData.get('name'),
|
||||
recursive: false
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-volume-snapshot-modal');
|
||||
e.target.reset();
|
||||
loadVolumeSnapshots();
|
||||
alert('Volume snapshot created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create volume snapshot'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replication (placeholder)
|
||||
async function loadReplications() {
|
||||
// Placeholder - replication not yet implemented
|
||||
document.getElementById('replications-list').innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-slate-400 text-sm mb-2">Replication feature coming soon</p>
|
||||
<p class="text-slate-500 text-xs">This feature will allow you to replicate snapshots to remote systems</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function showCreateReplicationModal() {
|
||||
alert('Replication feature coming soon');
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadSnapshots();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "protection.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
379
web/templates/shares.html
Normal file
379
web/templates/shares.html
Normal file
@@ -0,0 +1,379 @@
|
||||
{{define "shares-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Storage Shares</h1>
|
||||
<p class="text-slate-400">Manage SMB and NFS shares</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('smb')" id="tab-smb" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
SMB Shares
|
||||
</button>
|
||||
<button onclick="switchTab('nfs')" id="tab-nfs" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
NFS Exports
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- SMB Shares Tab -->
|
||||
<div id="content-smb" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">SMB/CIFS Shares</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateSMBModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Share
|
||||
</button>
|
||||
<button onclick="loadSMBShares()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="smb-shares-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NFS Exports Tab -->
|
||||
<div id="content-nfs" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">NFS Exports</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateNFSModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Export
|
||||
</button>
|
||||
<button onclick="loadNFSExports()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nfs-exports-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create SMB Share Modal -->
|
||||
<div id="create-smb-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">Create SMB Share</h3>
|
||||
<form id="create-smb-form" onsubmit="createSMBShare(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Share Name</label>
|
||||
<input type="text" name="name" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<input type="text" name="dataset" placeholder="pool/dataset" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Description (optional)</label>
|
||||
<input type="text" name="description" 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 items-center gap-2">
|
||||
<input type="checkbox" name="readonly" id="smb-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="smb-readonly" class="text-sm text-slate-300">Read-only</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="guest_ok" id="smb-guest" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="smb-guest" class="text-sm text-slate-300">Allow guest access</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-smb-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>
|
||||
|
||||
<!-- Create NFS Export Modal -->
|
||||
<div id="create-nfs-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">Create NFS Export</h3>
|
||||
<form id="create-nfs-form" onsubmit="createNFSExport(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||
<input type="text" name="dataset" placeholder="pool/dataset" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Clients (comma-separated)</label>
|
||||
<input type="text" name="clients" placeholder="192.168.1.0/24,*" 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">Leave empty or use * for all clients</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="readonly" id="nfs-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="nfs-readonly" class="text-sm text-slate-300">Read-only</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="root_squash" id="nfs-rootsquash" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded" checked>
|
||||
<label for="nfs-rootsquash" class="text-sm text-slate-300">Root squash</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-nfs-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>
|
||||
|
||||
<script>
|
||||
let currentTab = 'smb';
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('border-blue-600', 'text-blue-400');
|
||||
btn.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400');
|
||||
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
|
||||
if (tab === 'smb') loadSMBShares();
|
||||
else if (tab === 'nfs') loadNFSExports();
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSMBShares() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/shares/smb', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('smb-shares-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load SMB shares'}</p>`;
|
||||
return;
|
||||
}
|
||||
const shares = await res.json();
|
||||
const listEl = document.getElementById('smb-shares-list');
|
||||
|
||||
if (!Array.isArray(shares)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (shares.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No SMB shares found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = shares.map(share => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${share.name}</h3>
|
||||
${share.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">
|
||||
<p>Path: ${share.path || 'N/A'}</p>
|
||||
<p>Dataset: ${share.dataset || 'N/A'}</p>
|
||||
${share.description ? `<p>Description: ${share.description}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="deleteSMBShare('${share.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('smb-shares-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNFSExports() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/exports/nfs', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('nfs-exports-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load NFS exports'}</p>`;
|
||||
return;
|
||||
}
|
||||
const exports = await res.json();
|
||||
const listEl = document.getElementById('nfs-exports-list');
|
||||
|
||||
if (!Array.isArray(exports)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (exports.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No NFS exports found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = exports.map(exp => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${exp.path || 'N/A'}</h3>
|
||||
${exp.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">
|
||||
<p>Dataset: ${exp.dataset || 'N/A'}</p>
|
||||
<p>Clients: ${exp.clients && exp.clients.length > 0 ? exp.clients.join(', ') : '*'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="deleteNFSExport('${exp.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('nfs-exports-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateSMBModal() {
|
||||
document.getElementById('create-smb-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCreateNFSModal() {
|
||||
document.getElementById('create-nfs-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
async function createSMBShare(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
dataset: formData.get('dataset'),
|
||||
read_only: formData.get('readonly') === 'on',
|
||||
guest_ok: formData.get('guest_ok') === 'on'
|
||||
};
|
||||
if (formData.get('description')) data.description = formData.get('description');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/shares/smb', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-smb-modal');
|
||||
e.target.reset();
|
||||
loadSMBShares();
|
||||
alert('SMB share created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create SMB share'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNFSExport(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const clients = formData.get('clients') ? formData.get('clients').split(',').map(c => c.trim()).filter(c => c) : ['*'];
|
||||
const data = {
|
||||
dataset: formData.get('dataset'),
|
||||
clients: clients,
|
||||
read_only: formData.get('readonly') === 'on',
|
||||
root_squash: formData.get('root_squash') === 'on'
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/exports/nfs', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-nfs-modal');
|
||||
e.target.reset();
|
||||
loadNFSExports();
|
||||
alert('NFS export created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create NFS export'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSMBShare(id) {
|
||||
if (!confirm('Are you sure you want to delete this SMB share?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shares/smb/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadSMBShares();
|
||||
alert('SMB share deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete SMB share'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNFSExport(id) {
|
||||
if (!confirm('Are you sure you want to delete this NFS export?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/exports/nfs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadNFSExports();
|
||||
alert('NFS export deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete NFS export'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadSMBShares();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "shares.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
725
web/templates/storage.html
Normal file
725
web/templates/storage.html
Normal file
@@ -0,0 +1,725 @@
|
||||
{{define "storage-content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Storage Management</h1>
|
||||
<p class="text-slate-400">Manage storage pools, datasets, and volumes</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreatePoolModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Pool
|
||||
</button>
|
||||
<button onclick="showImportPoolModal()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm font-medium">
|
||||
Import Pool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800">
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="switchTab('pools')" id="tab-pools" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||
Pools
|
||||
</button>
|
||||
<button onclick="switchTab('datasets')" id="tab-datasets" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Datasets
|
||||
</button>
|
||||
<button onclick="switchTab('zvols')" id="tab-zvols" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Volumes
|
||||
</button>
|
||||
<button onclick="switchTab('disks')" id="tab-disks" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||
Disks
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Pools Tab -->
|
||||
<div id="content-pools" class="tab-content">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage Pools</h2>
|
||||
<button onclick="loadPools()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
<div id="pools-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Datasets Tab -->
|
||||
<div id="content-datasets" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Datasets</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateDatasetModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Dataset
|
||||
</button>
|
||||
<button onclick="loadDatasets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="datasets-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Volumes Tab -->
|
||||
<div id="content-zvols" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage Volumes</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showCreateZVOLModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Create Volume
|
||||
</button>
|
||||
<button onclick="loadZVOLs()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="zvols-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disks Tab -->
|
||||
<div id="content-disks" class="tab-content hidden">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Available Disks</h2>
|
||||
<button onclick="loadDisks()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
</div>
|
||||
<div id="disks-list" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Pool Modal -->
|
||||
<div id="create-pool-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">Create Storage Pool</h3>
|
||||
<form id="create-pool-form" onsubmit="createPool(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||
<input type="text" name="name" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">VDEVs (comma-separated)</label>
|
||||
<input type="text" name="vdevs" placeholder="/dev/sdb,/dev/sdc" 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">Enter device paths separated by commas</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-pool-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>
|
||||
|
||||
<!-- Import Pool Modal -->
|
||||
<div id="import-pool-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">Import Storage Pool</h3>
|
||||
<form id="import-pool-form" onsubmit="importPool(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||
<select name="name" id="import-pool-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 available pools...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="readonly" id="import-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||
<label for="import-readonly" class="text-sm text-slate-300">Import as read-only</label>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('import-pool-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">
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Dataset Modal -->
|
||||
<div id="create-dataset-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">Create Dataset</h3>
|
||||
<form id="create-dataset-form" onsubmit="createDataset(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset Name</label>
|
||||
<input type="text" name="name" placeholder="pool/dataset" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Quota (optional)</label>
|
||||
<input type="text" name="quota" placeholder="10G" 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>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Compression (optional)</label>
|
||||
<select name="compression" 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="">None</option>
|
||||
<option value="lz4">lz4</option>
|
||||
<option value="zstd">zstd</option>
|
||||
<option value="gzip">gzip</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-dataset-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>
|
||||
|
||||
<!-- Create Storage Volume Modal -->
|
||||
<div id="create-zvol-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">Create Storage Volume</h3>
|
||||
<form id="create-zvol-form" onsubmit="createZVOL(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Volume Name</label>
|
||||
<input type="text" name="name" placeholder="pool/zvol" 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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Size</label>
|
||||
<input type="text" name="size" placeholder="10G" 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">
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-zvol-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>
|
||||
|
||||
<script>
|
||||
let currentTab = 'pools';
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('border-blue-600', 'text-blue-400');
|
||||
btn.classList.add('border-transparent', 'text-slate-400');
|
||||
});
|
||||
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||
|
||||
// Load data for the tab
|
||||
if (tab === 'pools') loadPools();
|
||||
else if (tab === 'datasets') loadDatasets();
|
||||
else if (tab === 'zvols') loadZVOLs();
|
||||
else if (tab === 'disks') loadDisks();
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('atlas_token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
// Only add Authorization header if token exists (for mutations)
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function loadPools() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools', { headers: getAuthHeaders() });
|
||||
const data = await res.json().catch(() => null);
|
||||
const listEl = document.getElementById('pools-list');
|
||||
|
||||
// Handle HTTP errors
|
||||
if (!res.ok) {
|
||||
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}: Failed to load pools`;
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle invalid response format
|
||||
if (!data) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response (no data)</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if data is an array
|
||||
if (!Array.isArray(data)) {
|
||||
// Log the actual response for debugging
|
||||
console.error('Invalid response format:', data);
|
||||
const errorMsg = (data.error) ? data.error : 'Invalid response format: expected array';
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const pools = data;
|
||||
|
||||
if (pools.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No pools found. Create a pool to get started.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = pools.map(pool => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-white">${pool.name}</h3>
|
||||
<span class="px-2 py-1 rounded text-xs font-medium ${
|
||||
pool.health === 'ONLINE' ? 'bg-green-900 text-green-300' :
|
||||
pool.health === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
|
||||
'bg-red-900 text-red-300'
|
||||
}">${pool.health}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-slate-400">Size:</span>
|
||||
<span class="text-white ml-2">${formatBytes(pool.size)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">Used:</span>
|
||||
<span class="text-white ml-2">${formatBytes(pool.allocated)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">Free:</span>
|
||||
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="startScrub('${pool.name}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Scrub
|
||||
</button>
|
||||
<button onclick="exportPool('${pool.name}')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm">
|
||||
Export
|
||||
</button>
|
||||
<button onclick="deletePool('${pool.name}')" 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('pools-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatasets() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('datasets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load datasets'}</p>`;
|
||||
return;
|
||||
}
|
||||
const datasets = await res.json();
|
||||
const listEl = document.getElementById('datasets-list');
|
||||
|
||||
if (!Array.isArray(datasets)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (datasets.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No datasets found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = datasets.map(ds => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">${ds.name}</h3>
|
||||
${ds.mountpoint ? `<p class="text-sm text-slate-400">${ds.mountpoint}</p>` : ''}
|
||||
</div>
|
||||
<button onclick="deleteDataset('${ds.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('datasets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadZVOLs() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('zvols-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load volumes'}</p>`;
|
||||
return;
|
||||
}
|
||||
const zvols = await res.json();
|
||||
const listEl = document.getElementById('zvols-list');
|
||||
|
||||
if (!Array.isArray(zvols)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (zvols.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No volumes found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = zvols.map(zvol => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">${zvol.name}</h3>
|
||||
<p class="text-sm text-slate-400">Size: ${formatBytes(zvol.size)}</p>
|
||||
</div>
|
||||
<button onclick="deleteZVOL('${zvol.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('zvols-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDisks() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/disks', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('disks-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load disks'}</p>`;
|
||||
return;
|
||||
}
|
||||
const disks = await res.json();
|
||||
const listEl = document.getElementById('disks-list');
|
||||
|
||||
if (!Array.isArray(disks)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (disks.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No disks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = disks.map(disk => `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">${disk.name}</h3>
|
||||
<div class="text-sm text-slate-400 space-y-1">
|
||||
${disk.size ? `<p>Size: ${disk.size}</p>` : ''}
|
||||
${disk.model ? `<p>Model: ${disk.model}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('disks-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreatePoolModal() {
|
||||
document.getElementById('create-pool-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showImportPoolModal() {
|
||||
document.getElementById('import-pool-modal').classList.remove('hidden');
|
||||
loadAvailablePools();
|
||||
}
|
||||
|
||||
function showCreateDatasetModal() {
|
||||
document.getElementById('create-dataset-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showCreateZVOLModal() {
|
||||
document.getElementById('create-zvol-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadAvailablePools() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools/available', { headers: getAuthHeaders() });
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('import-pool-select');
|
||||
select.innerHTML = '<option value="">Select a pool...</option>';
|
||||
if (data.pools && data.pools.length > 0) {
|
||||
data.pools.forEach(pool => {
|
||||
const option = document.createElement('option');
|
||||
option.value = pool;
|
||||
option.textContent = pool;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No pools available for import</option>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading available pools:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createPool(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const vdevs = formData.get('vdevs').split(',').map(v => v.trim()).filter(v => v);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
vdevs: vdevs
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-pool-modal');
|
||||
e.target.reset();
|
||||
loadPools();
|
||||
alert('Pool created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importPool(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const options = {};
|
||||
if (formData.get('readonly')) {
|
||||
options.readonly = 'on';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/pools/import', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
options: options
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('import-pool-modal');
|
||||
e.target.reset();
|
||||
loadPools();
|
||||
alert('Pool imported successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to import pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDataset(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = { name: formData.get('name') };
|
||||
if (formData.get('quota')) data.quota = formData.get('quota');
|
||||
if (formData.get('compression')) data.compression = formData.get('compression');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/datasets', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-dataset-modal');
|
||||
e.target.reset();
|
||||
loadDatasets();
|
||||
alert('Dataset created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create dataset'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZVOL(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/zvols', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
size: formData.get('size')
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-zvol-modal');
|
||||
e.target.reset();
|
||||
loadZVOLs();
|
||||
alert('Storage volume created successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create storage volume'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePool(name) {
|
||||
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${name}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadPools();
|
||||
alert('Pool deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDataset(name) {
|
||||
if (!confirm(`Are you sure you want to delete dataset "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/datasets/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadDatasets();
|
||||
alert('Dataset deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete dataset'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteZVOL(name) {
|
||||
if (!confirm(`Are you sure you want to delete storage volume "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/zvols/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadZVOLs();
|
||||
alert('Storage volume deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to delete storage volume'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startScrub(name) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${name}/scrub`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Scrub started successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to start scrub'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPool(name) {
|
||||
if (!confirm(`Are you sure you want to export pool "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${name}/export`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ force: false })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadPools();
|
||||
alert('Pool exported successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to export pool'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadPools();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "storage.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user