Files
backstor-ui/js/app.js
2025-12-08 12:12:07 +07:00

479 lines
22 KiB
JavaScript

import { MockApi } from './mock_api.js';
import { RealApi } from './real_api.js';
// Toggle this to switch modes
const USE_REAL_API = true;
const Api = USE_REAL_API ? RealApi : MockApi;
// Utils
const formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
const formatDate = (dateString) => {
if (!dateString) return 'Never';
const options = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
};
return new Date(dateString).toLocaleDateString(undefined, options);
};
// Component Rendering Functions
const renderDashboard = async () => {
const stats = await Api.getDashboardStats();
const jobs = await Api.getRecentJobs();
return `
<div class="animate-fade-in">
<h2 style="margin-bottom: 1.5rem;">System Overview</h2>
<div class="dashboard-grid">
<div class="card stat-card">
<div class="stat-header">
<div class="stat-icon" style="background: rgba(99, 102, 241, 0.1); color: var(--accent-primary);">
<i class="ph ph-briefcase"></i>
</div>
<div class="stat-trend trend-up">
<i class="ph ph-trend-up"></i> +12%
</div>
</div>
<div class="stat-value">${stats.totalJobs}</div>
<div class="stat-label">Total Jobs Run</div>
</div>
<div class="card stat-card">
<div class="stat-header">
<div class="stat-icon" style="background: rgba(16, 185, 129, 0.1); color: var(--success);">
<i class="ph ph-check-circle"></i>
</div>
</div>
<div class="stat-value">${stats.successRate}%</div>
<div class="stat-label">Success Rate</div>
</div>
<div class="card stat-card">
<div class="stat-header">
<div class="stat-icon" style="background: rgba(236, 72, 153, 0.1); color: var(--accent-secondary);">
<i class="ph ph-hard-drives"></i>
</div>
<div class="stat-trend trend-up">
<i class="ph ph-warning"></i> ${stats.storageUsage}% used
</div>
</div>
<div class="stat-value">${formatBytes(stats.totalBytes)}</div>
<div class="stat-label">Total Backup Size</div>
</div>
<div class="card stat-card">
<div class="stat-header">
<div class="stat-icon" style="background: rgba(245, 158, 11, 0.1); color: var(--warning);">
<i class="ph ph-desktop"></i>
</div>
</div>
<div class="stat-value">${stats.activeClients}</div>
<div class="stat-label">Active Clients</div>
</div>
</div>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3>Recent Jobs</h3>
<button class="btn btn-primary" onclick="window.location.hash='#jobs'">View All</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Job Name</th>
<th>Client</th>
<th>Level</th>
<th>Files</th>
<th>Size</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
${jobs.map(job => `
<tr>
<td>
<span class="status-badge status-${job.status.toLowerCase()}">
${job.status}
</span>
</td>
<td style="font-weight: 500;">${job.name}</td>
<td style="color: var(--text-muted);">${job.client}</td>
<td>${job.level}</td>
<td>${job.files.toLocaleString()}</td>
<td>${formatBytes(job.bytes)}</td>
<td>${job.duration}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
`;
};
// State for Jobs View
const jobsState = {
filter: 'All',
search: ''
};
// Global handlers for filter updates
window.updateJobFilter = (value) => {
jobsState.filter = value;
// Trigger re-render of current page
window.dispatchEvent(new Event('hashchange'));
};
window.updateJobSearch = (value) => {
jobsState.search = value.toLowerCase();
// Debounce re-render could be better, but simple re-render works for now
// We'll just re-render the table body via DOM manipulation ideally,
// but for simplicity with this router, we can re-trigger route or just rely on input keeping focus if we are careful.
// Re-rendering whole page loses focus.
// Better strategy: Store the value, but let the user press enter or wait.
// For now, let's just make the search work on 'onchange' or 'onkeyup' with a small re-render hack
// Actually, to avoid losing focus on every keystroke, let's just use the router re-render
// but we need to manage focus.
// Simplified approach: Just update state. The user has to trigger search?
// Or simpler: Just re-render. To keep input focus, we can select it after render.
const contentArea = document.getElementById('content-area');
// For this prototype, a full re-render is jarring.
// But let's stick to the request pattern.
window.dispatchEvent(new Event('hashchange'));
};
// Helper to keep focus helper (hacky but works for simple vanilla js SPA)
const restoreFocus = () => {
const searchInput = document.querySelector('#job-search-input');
if (searchInput) {
searchInput.focus();
// Move cursor to end
const val = searchInput.value;
searchInput.value = '';
searchInput.value = val;
}
}
const renderJobs = async () => {
const jobs = await Api.getRecentJobs();
const allJobs = [...jobs, ...jobs, ...jobs]; // Demo duplication
// Apply Filters
const filteredJobs = allJobs.filter(job => {
const matchesStatus = jobsState.filter === 'All' || job.status === jobsState.filter;
const matchesSearch = jobsState.search === '' ||
job.name.toLowerCase().includes(jobsState.search) ||
job.client.toLowerCase().includes(jobsState.search);
return matchesStatus && matchesSearch;
});
// Schedule focus restore after render
setTimeout(restoreFocus, 0);
return `
<div class="animate-fade-in">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Backup Jobs</h2>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div style="position: relative;">
<i class="ph ph-magnifying-glass" style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-muted);"></i>
<input type="text"
id="job-search-input"
placeholder="Search jobs..."
value="${jobsState.search}"
oninput="updateJobSearch(this.value)"
style="padding-left: 36px; background: var(--bg-card); border: 1px solid var(--border-color); color: white; border-radius: 6px; height: 40px; width: 250px; outline: none;">
</div>
<select onchange="updateJobFilter(this.value)" style="background: var(--bg-card); border: 1px solid var(--border-color); color: white; border-radius: 6px; height: 40px; padding: 0 12px; outline: none; cursor: pointer;">
<option value="All" ${jobsState.filter === 'All' ? 'selected' : ''}>All Status</option>
<option value="Success" ${jobsState.filter === 'Success' ? 'selected' : ''}>Success</option>
<option value="Running" ${jobsState.filter === 'Running' ? 'selected' : ''}>Running</option>
<option value="Error" ${jobsState.filter === 'Error' ? 'selected' : ''}>Error</option>
</select>
<button class="btn btn-primary" style="height: 40px;"><i class="ph ph-play"></i> Run Job</button>
</div>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Job Name</th>
<th>Client</th>
<th>Level</th>
<th>Start Time</th>
<th>Files</th>
<th>Size</th>
</tr>
</thead>
<tbody>
${filteredJobs.length > 0 ? filteredJobs.map(job => `
<tr>
<td>
<span class="status-badge status-${job.status.toLowerCase()}">
${job.status}
</span>
</td>
<td style="font-weight: 500;">${job.name}</td>
<td style="color: var(--text-muted);">${job.client}</td>
<td>${job.level}</td>
<td>${formatDate(job.startTime)}</td>
<td>${job.files.toLocaleString()}</td>
<td>${formatBytes(job.bytes)}</td>
</tr>
`).join('') : `
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--text-muted);">
No jobs found matching your filters.
</td>
</tr>
`}
</tbody>
</table>
</div>
</div>
</div>
`;
};
const renderClients = async () => {
const clients = await Api.getClients();
return `
<div class="animate-fade-in">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Clients</h2>
<button class="btn btn-primary" onclick="openAddClientModal()"><i class="ph ph-plus"></i> Add Client</button>
</div>
<div class="dashboard-grid">
${clients.map(client => `
<div class="card">
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<div style="width: 48px; height: 48px; background: var(--bg-hover); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem;">
<i class="ph ph-desktop"></i>
</div>
<span class="status-badge status-${client.status === 'Online' ? 'success' : 'error'}">
${client.status}
</span>
</div>
<h3 style="margin-bottom: 0.5rem;">${client.name}</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1rem;">${client.os}</p>
<div style="padding-top: 1rem; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; font-size: 0.85rem;">
<span style="color: var(--text-muted);">Last Backup</span>
<span>${formatDate(client.lastBackup)}</span>
</div>
</div>
`).join('')}
</div>
</div>
`;
};
// Router
const router = async () => {
const contentArea = document.getElementById('content-area');
const hash = window.location.hash || '#dashboard';
const page = hash.substring(1);
// Update Sidebar Active State
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.page === page);
});
// Update Breadcrumbs
document.querySelector('.current-page').textContent = page.charAt(0).toUpperCase() + page.slice(1);
contentArea.innerHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>Loading...</p>
</div>
`;
try {
let content = '';
switch (page) {
case 'dashboard':
content = await renderDashboard();
break;
case 'jobs':
content = await renderJobs();
break;
case 'clients':
content = await renderClients();
break;
case 'storage':
const storageDevices = await Api.getStorage();
content = `
<div class="animate-fade-in">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Storage Daemons</h2>
<button class="btn btn-primary"><i class="ph ph-plus"></i> Add Storage</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th>Capacity</th>
<th>Usage</th>
<th></th>
</tr>
</thead>
<tbody>
${storageDevices.length > 0 ? storageDevices.map(dev => `
<tr>
<td style="font-weight: 500;">${dev.name}</td>
<td>${dev.type}</td>
<td>
<span class="status-badge status-${dev.status === 'Active' ? 'success' : 'running'}">
${dev.status}
</span>
</td>
<td>${dev.capacity}</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>${dev.used}</span>
${dev.percent > 0 ? `
<div style="width: 100px; height: 4px; background: #2d3748; border-radius: 2px;">
<div style="width: ${dev.percent}%; height: 100%; background: ${dev.percent > 75 ? 'var(--warning)' : 'var(--accent-primary)'}; border-radius: 2px;"></div>
</div>
` : ''}
</div>
</td>
<td>
<button class="icon-btn"><i class="ph ph-dots-three-vertical"></i></button>
</td>
</tr>
`).join('') : `
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--text-muted);">
No storage devices found.
</td>
</tr>
`}
</tbody>
</table>
</div>
</div>
</div>
`;
break;
case 'settings':
content = '<div class="animate-fade-in"><h2>Settings</h2><p>Coming soon...</p></div>';
break;
default:
content = '<div class="animate-fade-in"><h2>404 Not Found</h2></div>';
}
contentArea.innerHTML = content;
} catch (error) {
console.error(error);
contentArea.innerHTML = '<div class="animate-fade-in"><h2>Error loading content</h2></div>';
}
};
// Init
window.addEventListener('hashchange', router);
window.addEventListener('DOMContentLoaded', () => {
router();
// Append Modal to Body if not exists
if (!document.getElementById('client-modal')) {
const modal = document.createElement('div');
modal.id = 'client-modal';
modal.innerHTML = `
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 1000;" id="client-modal-overlay">
<div class="card" style="width: 400px; max-width: 90%;">
<h3 style="margin-bottom: 1.5rem;">Add New Client</h3>
<form onsubmit="handleClientSubmit(event)">
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-muted);">Client Name</label>
<input type="text" name="name" required style="width: 100%; padding: 0.5rem; background: var(--bg-hover); border: 1px solid var(--border-color); color: white; border-radius: 6px;">
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-muted);">Address (IP/Host)</label>
<input type="text" name="address" required style="width: 100%; padding: 0.5rem; background: var(--bg-hover); border: 1px solid var(--border-color); color: white; border-radius: 6px;">
</div>
<div style="margin-bottom: 1.5rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-muted);">Password</label>
<input type="password" name="password" required style="width: 100%; padding: 0.5rem; background: var(--bg-hover); border: 1px solid var(--border-color); color: white; border-radius: 6px;">
</div>
<div style="display: flex; justify-content: flex-end; gap: 1rem;">
<button type="button" class="btn" onclick="closeClientModal()" style="background: transparent; border: 1px solid var(--border-color); color: white;">Cancel</button>
<button type="submit" class="btn btn-primary">Save Client</button>
</div>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
}
});
window.openAddClientModal = () => {
document.getElementById('client-modal-overlay').style.display = 'flex';
};
window.closeClientModal = () => {
document.getElementById('client-modal-overlay').style.display = 'none';
};
window.handleClientSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const btn = e.target.querySelector('button[type="submit"]');
const origText = btn.textContent;
btn.textContent = 'Saving...';
btn.disabled = true;
const res = await fetch(`${Api.getBaseUrl()}/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error((await res.json()).error);
alert('Client added successfully!');
closeClientModal();
e.target.reset();
window.dispatchEvent(new Event('hashchange')); // Reload view
} catch (err) {
alert('Error adding client: ' + err.message);
} finally {
const btn = e.target.querySelector('button[type="submit"]');
btn.textContent = 'Save Client';
btn.disabled = false;
}
};