wrolpi/controller/templates/index.html

1638 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Controller - {{ host }} WROLPi</title>
<style>
/* ===== INLINE CSS - No external files ===== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
line-height: 1.6;
padding: 1rem;
min-height: 100vh;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #16213e;
border-radius: 8px;
margin-bottom: 1rem;
}
header h1 {
font-size: 1.5rem;
color: #4ecca3;
}
.version {
font-size: 0.8rem;
color: #666;
}
.warning-banner {
background: #ff6b6b;
color: #fff;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.info-banner {
background: #0f3460;
color: #fff;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.fallback-banner {
background: #f39c12;
color: #000;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
display: none;
}
.fallback-banner.show {
display: block;
}
.fallback-banner .loading {
border-color: #333;
border-top-color: #000;
}
.upgrade-banner {
background: linear-gradient(135deg, #f39c12, #e67e22);
color: #000;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
display: none;
}
.upgrade-banner.show {
display: block;
}
.upgrade-banner h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.upgrade-banner .loading {
width: 2rem;
height: 2rem;
border-color: #333;
border-top-color: #000;
margin: 0.5rem auto;
}
.upgrade-banner .elapsed {
font-size: 1.25rem;
font-weight: bold;
margin: 0.5rem 0;
}
.upgrade-banner .warning {
font-size: 0.9rem;
margin-top: 0.5rem;
opacity: 0.8;
}
.upgrade-banner .success {
color: #27ae60;
font-size: 2rem;
}
.upgrade-banner.complete {
background: linear-gradient(135deg, #27ae60, #2ecc71);
color: #fff;
}
.upgrade-banner.complete .warning {
display: none;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.status-card {
background: #16213e;
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.status-card h3 {
font-size: 0.85rem;
color: #888;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-card .value {
font-size: 2rem;
font-weight: bold;
color: #4ecca3;
}
.status-card .sub {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
background: #16213e;
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #0f3460;
}
th {
background: #0f3460;
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: #1a3a5c;
}
/* Status indicators */
.status-running {
color: #4ecca3;
}
.status-stopped {
color: #ff6b6b;
}
.status-unknown {
color: #ffd93d;
}
/* SMART health status */
.health-pass {
color: #4ecca3;
font-weight: bold;
}
.health-warn {
color: #ffd93d;
font-weight: bold;
}
.health-fail {
color: #ff6b6b;
font-weight: bold;
}
.health-unknown {
color: #888;
}
/* Drive health cards */
.health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.health-card {
background: #16213e;
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.health-card h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: #ccc;
}
.health-card .assessment {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.health-card .temp {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.5rem;
}
/* Buttons */
.btn {
display: inline-block;
padding: 0.4rem 0.8rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
margin-right: 0.25rem;
text-decoration: none;
transition: opacity 0.2s, transform 0.1s;
}
.btn:hover {
opacity: 0.85;
}
.btn:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #4ecca3;
color: #000;
}
.btn-danger {
background: #ff6b6b;
color: #fff;
}
.btn-warning {
background: #ffd93d;
color: #000;
}
.btn-secondary {
background: #0f3460;
color: #fff;
}
.btn-info {
background: #3498db;
color: #fff;
}
.btn-violet {
background: #9b59b6;
color: #fff;
}
.btn-success {
background: #27ae60;
color: #fff;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: #16213e;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #0f3460;
}
.modal-header h3 {
margin: 0;
color: #4ecca3;
}
.modal-close {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #fff;
}
.modal-body {
padding: 1rem;
overflow: auto;
flex: 1;
}
.modal-body pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 0.85rem;
line-height: 1.4;
color: #ccc;
}
/* Mount modal form */
.modal-form label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
}
.modal-form input[type="text"] {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
background: #1a1a2e;
border: 1px solid #333;
border-radius: 4px;
color: #fff;
font-size: 1rem;
}
.modal-form input[type="text"]:focus {
outline: none;
border-color: #4ecca3;
}
.modal-form .checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
cursor: pointer;
}
.modal-form input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
accent-color: #4ecca3;
}
.modal-footer {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid #0f3460;
margin-top: 0.5rem;
}
.logs-select {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 4px;
color: #fff;
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
/* Sections */
section {
margin-bottom: 2rem;
}
section h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #333;
color: #ccc;
}
/* Action buttons section */
.action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Loading spinner */
.loading {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid #333;
border-top-color: #4ecca3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Toggle Switch */
.toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #555;
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: #fff;
transition: 0.3s;
border-radius: 50%;
}
.toggle input:checked + .toggle-slider {
background-color: #4ecca3;
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.toggle input:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-label {
margin-left: 0.5rem;
font-size: 0.85rem;
color: #888;
}
/* Responsive */
@media (max-width: 600px) {
header {
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
nav {
justify-content: center;
}
.btn {
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
}
.status-card .value {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
{% if docker_mode %}
<div class="warning-banner">
Running in Docker mode. Disk mounting, hotspot, and shutdown are not available.
</div>
{% endif %}
{% if not drive_mounted %}
<div class="info-banner">
Primary drive not mounted. Some features may be limited.
</div>
{% endif %}
<div id="fallback-banner" class="fallback-banner">
<span class="loading"></span>
Main UI is unavailable. Using Controller fallback. Will redirect when ready...
</div>
<div id="upgrade-banner" class="upgrade-banner">
<h2>Upgrade in Progress</h2>
<span id="upgrade-spinner" class="loading"></span>
<span id="upgrade-success" class="success" style="display: none;">✓</span>
<p id="upgrade-message">Please wait while WROLPi upgrades...</p>
<p class="elapsed">Elapsed: <span id="upgrade-elapsed">0:00</span></p>
<p class="warning">⚠ Do not power off your device or close this window during the upgrade.</p>
</div>
<header>
<div>
<h1>Controller - {{ host }} WROLPi</h1>
<span class="version">v{{ version }}</span>
</div>
</header>
<main>
<section id="status">
<h2>System Status</h2>
<div class="status-grid">
<div class="status-card">
<h3>CPU</h3>
<p class="value">{{ cpu.percent if cpu.percent is not none else '--' }}%</p>
<p class="sub">{% if cpu.temperature_c %}{{ cpu.temperature_c }}&deg;C{% else %}--{% endif %}</p>
</div>
<div class="status-card">
<h3>Memory</h3>
<p class="value">{{ memory.percent|default('--') }}%</p>
<p class="sub">{{ memory.used_gb|default('--') }} GB used</p>
</div>
<div class="status-card">
<h3>Load</h3>
<p class="value">{{ load.load_1min|default('--') }}</p>
<p class="sub">1 min avg</p>
</div>
<div class="status-card">
<h3>Storage</h3>
<p class="value">{{ storage.percent|default('--') }}%</p>
<p class="sub">{{ storage.free_gb|default('--') }} GB free</p>
</div>
<div class="status-card">
<h3>IO Wait</h3>
<p class="value" id="iowait-value">{% if iostat and iostat.percent_iowait is not none %}{{ iostat.percent_iowait }}{% else %}--{% endif %}%</p>
<p class="sub">disk waiting</p>
</div>
</div>
</section>
<section id="actions">
<h2>System Actions</h2>
<div class="action-buttons">
<button class="btn btn-warning" onclick="restartServices()">Restart All Services</button>
{% if not docker_mode %}
<button class="btn btn-danger" onclick="rebootSystem()">Reboot System</button>
<button class="btn btn-danger" onclick="shutdownSystem()">Shutdown System</button>
{% endif %}
</div>
</section>
<section id="services">
<h2>Services</h2>
<table>
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Actions</th>
{% if not docker_mode %}
<th>Boot</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for service in services %}
<tr>
<td>
{{ service.name }}
{% if service.port %}<span class="sub">:{{ service.port }}</span>{% endif %}
</td>
<td class="status-{{ service.status }}">{{ service.status }}</td>
<td>
{% if service.status == 'running' %}
<button class="btn btn-danger" onclick="serviceAction('{{ service.name }}', 'stop')">Stop</button>
{% else %}
<button class="btn btn-primary" onclick="serviceAction('{{ service.name }}', 'start')">Start
</button>
{% endif %}
<button class="btn btn-warning" onclick="serviceAction('{{ service.name }}', 'restart')">Restart
</button>
<button class="btn btn-info" onclick="showLogs('{{ service.name }}', true)">Logs</button>
{% if service.viewable and service.status == 'running' and service.port %}
<a href="{{ 'https' if service.use_https else 'http' }}://{{ host }}:{{ service.port }}{{ service.view_path|default('') }}"
target="_blank" class="btn btn-violet">View</a>
{% endif %}
</td>
{% if not docker_mode %}
<td>
{% if service.name == 'wrolpi-upgrade' %}
<span style="color: #666;">--</span>
{% else %}
<label class="toggle">
<input type="checkbox"
id="boot-{{ service.name }}"
{% if service.enabled %}checked{% endif %}
onchange="toggleBoot('{{ service.name }}', this.checked)">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label" id="boot-label-{{ service.name }}">
{{ 'Enabled' if service.enabled else 'Disabled' }}
</span>
{% endif %}
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{% if docker_mode %}3{% else %}4{% endif %}" style="text-align: center; color: #666;">
Service status not yet implemented
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section id="disks">
<h2>Disks</h2>
<div id="disks-content">
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
</div>
</section>
<section id="drive-health">
<h2>Drive Health (SMART)</h2>
<div id="smart-content">
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
</div>
</section>
</main>
<!-- Logs Modal -->
<div id="logs-modal" class="modal" onclick="if(event.target===this)closeLogsModal()">
<div class="modal-content" style="width: 80%; max-width: none;">
<div class="modal-header">
<h3 id="logs-title">Service Logs</h3>
<button class="modal-close" onclick="closeLogsModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="logs-content"><span class="loading"></span> Loading logs...</pre>
</div>
<div class="modal-footer">
<span id="logs-countdown" style="color: #888; margin-right: 0.5rem;">10s</span>
<select id="logs-lines-select" class="logs-select" onchange="changeLogLines()">
<option value="100">100 lines</option>
<option value="250" selected>250 lines</option>
<option value="500">500 lines</option>
<option value="1000">1000 lines</option>
<option value="5000">5000 lines</option>
</select>
<button class="btn btn-info" onclick="refreshLogs()">Refresh</button>
<button class="btn btn-warning" onclick="downloadLogs()">Download</button>
<button class="btn btn-secondary" onclick="closeLogsModal()">Close</button>
</div>
</div>
</div>
<!-- Mount Modal -->
<div id="mount-modal" class="modal" onclick="if(event.target===this)closeMountModal()">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h3>Mount Disk</h3>
<button class="modal-close" onclick="closeMountModal()">&times;</button>
</div>
<div class="modal-body">
<div class="modal-form">
<label for="mount-point-input">Mount point:</label>
<input type="text" id="mount-point-input" placeholder="/media/..."/>
<label class="checkbox-label">
<input type="checkbox" id="mount-persist-checkbox"/>
Persistent (survive reboots)
</label>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeMountModal()">Cancel</button>
<button class="btn btn-primary" onclick="confirmMount()">Mount</button>
</div>
</div>
</div>
</div>
</div>
<!-- SMART Details Modal -->
<div id="smart-modal" class="modal" onclick="if(event.target===this)closeSmartModal()">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3 id="smart-modal-title">Drive Health Details</h3>
<button class="modal-close" onclick="closeSmartModal()">&times;</button>
</div>
<div class="modal-body">
<div id="smart-modal-content">
<span class="loading"></span> Loading...
</div>
</div>
</div>
</div>
<script>
/* ===== INLINE JAVASCRIPT - No external files ===== */
async function apiCall(url, method = 'GET', body = null) {
try {
const options = {method};
if (body) {
options.headers = {'Content-Type': 'application/json'};
options.body = JSON.stringify(body);
}
const resp = await fetch(url, options);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${resp.status}`);
}
return await resp.json();
} catch (e) {
throw e;
}
}
async function serviceAction(name, action) {
const btn = event.target;
const originalText = btn.textContent;
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>';
try {
await apiCall(`api/services/${name}/${action}`, 'POST');
// Reload page to show updated status
setTimeout(() => location.reload(), 1000);
} catch (e) {
alert(`Failed to ${action} ${name}: ${e.message}`);
btn.disabled = false;
btn.textContent = originalText;
}
}
async function toggleBoot(name, enabled) {
const checkbox = document.getElementById(`boot-${name}`);
const label = document.getElementById(`boot-label-${name}`);
const action = enabled ? 'enable' : 'disable';
// Disable checkbox while processing
checkbox.disabled = true;
try {
await apiCall(`api/services/${name}/${action}`, 'POST');
// Update label text
label.textContent = enabled ? 'Enabled' : 'Disabled';
} catch (e) {
// Revert checkbox on failure
checkbox.checked = !enabled;
alert(`Failed to ${action} ${name}: ${e.message}`);
} finally {
checkbox.disabled = false;
}
}
async function restartServices() {
if (!confirm('This will restart all WROLPi services. Continue?')) return;
document.body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;">
<span class="loading" style="width:3rem;height:3rem;margin-bottom:1rem;"></span>
<h1 style="color:#4ecca3;">Restarting services...</h1>
<p style="color:#888;">Please wait, this page will reload automatically.</p>
</div>
`;
try {
await apiCall('api/restart', 'POST');
} catch (e) {
// Expected - connection may drop
}
// Poll until Controller is back
await waitForController();
location.reload();
}
async function rebootSystem() {
if (!confirm('This will REBOOT the entire system. Continue?')) return;
try {
await apiCall('api/reboot', 'POST');
document.body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;">
<h1 style="color:#ffd93d;">Rebooting...</h1>
<p style="color:#888;">The system is rebooting. This page will reload when ready.</p>
</div>
`;
// Wait then poll
setTimeout(async () => {
await waitForController(120000);
location.reload();
}, 10000);
} catch (e) {
alert(`Failed to reboot: ${e.message}`);
}
}
async function shutdownSystem() {
if (!confirm('This will SHUT DOWN the system. You will need physical access to turn it back on. Continue?')) return;
try {
await apiCall('api/shutdown', 'POST');
document.body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;">
<h1 style="color:#ff6b6b;">Shutting down...</h1>
<p style="color:#888;">The system is shutting down.</p>
</div>
`;
} catch (e) {
alert(`Failed to shutdown: ${e.message}`);
}
}
async function waitForController(maxWaitMs = 60000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const resp = await fetch('api/health');
if (resp.ok) return;
} catch (e) {
// Still waiting
}
await new Promise(r => setTimeout(r, 2000));
}
throw new Error('Controller did not respond in time');
}
// ===== LOGS MODAL =====
let currentLogsService = null;
let logsCountdown = 10;
let logsCountdownInterval = null;
function startLogsCountdown() {
if (logsCountdownInterval) clearInterval(logsCountdownInterval);
logsCountdown = 10;
document.getElementById('logs-countdown').textContent = '10s';
logsCountdownInterval = setInterval(() => {
logsCountdown--;
document.getElementById('logs-countdown').textContent = logsCountdown + 's';
if (logsCountdown <= 0) {
// Check if scrolled to bottom (within 50px threshold)
const modalBody = document.getElementById('logs-content').closest('.modal-body');
const isAtBottom = modalBody.scrollHeight - modalBody.scrollTop - modalBody.clientHeight < 50;
if (isAtBottom && currentLogsService) {
showLogs(currentLogsService);
}
logsCountdown = 10;
document.getElementById('logs-countdown').textContent = '10s';
}
}, 1000);
}
function stopLogsCountdown() {
if (logsCountdownInterval) {
clearInterval(logsCountdownInterval);
logsCountdownInterval = null;
}
}
async function showLogs(serviceName, showLoading = false) {
const modal = document.getElementById('logs-modal');
const title = document.getElementById('logs-title');
const content = document.getElementById('logs-content');
const modalBody = content.closest('.modal-body');
const linesSelect = document.getElementById('logs-lines-select');
currentLogsService = serviceName;
const lines = linesSelect.value;
title.textContent = `${serviceName} Logs`;
if (showLoading) {
content.innerHTML = '<span class="loading"></span> Loading logs...';
}
modal.classList.add('show');
try {
const data = await apiCall(`api/services/${serviceName}/logs?lines=${lines}`);
content.textContent = data.logs || 'No logs available';
// Scroll modal-body to bottom of logs
modalBody.scrollTop = modalBody.scrollHeight;
// Start countdown after logs loaded
startLogsCountdown();
} catch (e) {
content.textContent = `Error loading logs: ${e.message}`;
}
}
function changeLogLines() {
if (currentLogsService) {
showLogs(currentLogsService);
}
}
function refreshLogs() {
if (currentLogsService) {
showLogs(currentLogsService);
}
}
function downloadLogs() {
const content = document.getElementById('logs-content').textContent;
const datetime = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `${currentLogsService}_${datetime}.txt`;
const blob = new Blob([content], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function closeLogsModal() {
stopLogsCountdown();
document.getElementById('logs-modal').classList.remove('show');
}
// Close modals on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeLogsModal();
closeMountModal();
closeSmartModal();
}
});
// ===== DISK MANAGEMENT =====
function formatSize(bytes) {
if (!bytes) return '--';
// If already a string (e.g., "59.5G" from lsblk), return as-is
if (typeof bytes === 'string') return bytes;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return `${bytes.toFixed(1)} ${units[i]}`;
}
async function loadDisks() {
const container = document.getElementById('disks-content');
try {
// Fetch disks and fstab entries in parallel
const [disks, fstabEntries] = await Promise.all([
apiCall('api/disks'),
apiCall('api/disks/fstab').catch(() => []) // fstab may fail, that's ok
]);
if (disks.length === 0) {
container.innerHTML = '<p style="color: #888; text-align: center;">No disks detected</p>';
return;
}
// Build set of persistent mount points for quick lookup
const persistentMounts = new Set(fstabEntries.map(e => e.mount_point));
// Build map of device (UUID) to fstab entry for unmounted drive lookup
const persistentByDevice = new Map();
fstabEntries.forEach(e => {
// e.device is like "UUID=1234-5678" or "/dev/sda1"
persistentByDevice.set(e.device, e);
});
let html = `
<table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Label</th>
<th>Mount Point</th>
<th>Persist</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
for (const disk of disks) {
const mounted = disk.mountpoint && disk.mountpoint !== '';
const mountClass = mounted ? 'status-running' : 'status-stopped';
const mountText = mounted ? disk.mountpoint : '--';
html += `
<tr>
<td>${disk.name}</td>
<td>${formatSize(disk.size)}</td>
<td>${disk.fstype || '--'}</td>
<td>${disk.label || '--'}</td>
<td class="${mountClass}">${mountText}</td>
<td>
`;
const protectedMounts = ['/', '/boot', '/boot/firmware'];
const isProtected = protectedMounts.includes(disk.mountpoint);
// Check if this disk has a persistent entry (by mount point or UUID)
let isPersistent = false;
let persistentEntry = null;
if (mounted) {
isPersistent = persistentMounts.has(disk.mountpoint);
} else if (disk.uuid) {
// Check if unmounted drive has fstab entry by UUID
persistentEntry = persistentByDevice.get(`UUID=${disk.uuid}`);
isPersistent = !!persistentEntry;
}
// Persist column - show for mounted drives OR unmounted persistent drives
if ((mounted || isPersistent) && !isProtected) {
const mountPoint = mounted ? disk.mountpoint : persistentEntry.mount_point;
html += `
<label class="toggle">
<input type="checkbox"
${isPersistent ? 'checked' : ''}
onchange="togglePersist('${disk.path}', '${mountPoint}', '${disk.fstype}', this.checked)">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">${isPersistent ? 'Enabled' : 'Disabled'}</span>
`;
} else {
html += '<span style="color: #666;">--</span>';
}
html += `</td><td>`;
// Actions column
if (mounted && !isProtected) {
html += `<button class="btn btn-danger" onclick="unmountDisk('${disk.mountpoint}')">Unmount</button>`;
} else if (mounted && isProtected) {
html += `<span style="color: #888;">System</span>`;
} else {
const defaultMount = disk.label ? `/media/${disk.label}` : `/media/${disk.name}`;
html += `<button class="btn btn-primary" onclick="mountDisk('${disk.path}', '${defaultMount}', '${disk.fstype || ''}')">Mount</button>`;
}
html += `
</td>
</tr>
`;
}
html += '</tbody></table>';
container.innerHTML = html;
} catch (e) {
if (e.message.includes('501')) {
container.innerHTML = '<p style="color: #888; text-align: center;">Disk management not available in Docker mode</p>';
} else {
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading disks: ${e.message}</p>`;
}
}
}
// Mount modal state
let pendingMountDevice = null;
let pendingMountFstype = null;
function mountDisk(device, defaultMount, fstype) {
pendingMountDevice = device;
pendingMountFstype = fstype;
document.getElementById('mount-point-input').value = defaultMount;
document.getElementById('mount-persist-checkbox').checked = false;
document.getElementById('mount-modal').classList.add('show');
document.getElementById('mount-point-input').focus();
}
function closeMountModal() {
document.getElementById('mount-modal').classList.remove('show');
pendingMountDevice = null;
pendingMountFstype = null;
}
async function confirmMount() {
const mountPoint = document.getElementById('mount-point-input').value.trim();
const persist = document.getElementById('mount-persist-checkbox').checked;
if (!mountPoint) {
alert('Mount point is required');
return;
}
const btn = document.querySelector('#mount-modal .btn-primary');
const originalText = btn.textContent;
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>';
try {
await apiCall('api/disks/mount', 'POST', {
device: pendingMountDevice,
mount_point: mountPoint,
fstype: pendingMountFstype || null,
persist: persist
});
closeMountModal();
setTimeout(() => location.reload(), 500);
} catch (e) {
alert(`Failed to mount: ${e.message}`);
btn.disabled = false;
btn.textContent = originalText;
}
}
async function unmountDisk(mountPoint) {
if (!confirm(`Unmount ${mountPoint}?`)) return;
try {
await apiCall('api/disks/unmount', 'POST', {
mount_point: mountPoint
});
setTimeout(() => location.reload(), 500);
} catch (e) {
alert(`Failed to unmount: ${e.message}`);
}
}
async function togglePersist(device, mountPoint, fstype, enablePersist) {
const checkbox = event.target;
const label = checkbox.closest('td').querySelector('.toggle-label');
checkbox.disabled = true;
try {
if (enablePersist) {
// Add to fstab
await apiCall('api/disks/fstab', 'POST', {
device: device,
mount_point: mountPoint,
fstype: fstype || 'auto'
});
if (label) label.textContent = 'Enabled';
} else {
// Remove from fstab
const encodedPath = encodeURIComponent(mountPoint.slice(1));
await apiCall(`api/disks/fstab/${encodedPath}`, 'DELETE');
if (label) label.textContent = 'Disabled';
}
checkbox.disabled = false;
} catch (e) {
alert(`Failed to update persistence: ${e.message}`);
// Revert checkbox state on failure
checkbox.checked = !enablePersist;
checkbox.disabled = false;
}
}
// Load disks on page load
loadDisks();
// ===== SMART DRIVE HEALTH =====
// Store SMART data for modal display
let smartData = [];
async function loadSmart() {
const container = document.getElementById('smart-content');
try {
const response = await apiCall('api/disks/smart');
if (!response.available) {
container.innerHTML = `<p style="color: #888; text-align: center;">${response.reason || 'SMART monitoring not available'}</p>`;
return;
}
const drives = response.drives || [];
smartData = drives;
if (drives.length === 0) {
container.innerHTML = '<p style="color: #888; text-align: center;">No SMART-capable drives detected</p>';
return;
}
let html = '<div class="health-grid">';
for (const drive of drives) {
const assessmentClass = getHealthClass(drive.assessment);
const assessmentText = drive.assessment || 'Unknown';
const tempText = drive.temperature !== null ? `${drive.temperature}°C` : '--';
html += `
<div class="health-card">
<h4>${drive.device}</h4>
<p class="assessment ${assessmentClass}">${assessmentText}</p>
<p class="temp">${tempText}</p>
<button class="btn btn-sm btn-secondary" onclick="showSmartDetails('${drive.device}')">Details</button>
</div>
`;
}
html += '</div>';
container.innerHTML = html;
} catch (e) {
if (e.message.includes('501')) {
container.innerHTML = '<p style="color: #888; text-align: center;">SMART monitoring not available in Docker mode</p>';
} else {
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading SMART data: ${e.message}</p>`;
}
}
}
function getHealthClass(assessment) {
if (!assessment) return 'health-unknown';
const upper = assessment.toUpperCase();
if (upper === 'PASS') return 'health-pass';
if (upper === 'WARN') return 'health-warn';
if (upper === 'FAIL') return 'health-fail';
return 'health-unknown';
}
function showSmartDetails(deviceName) {
const drive = smartData.find(d => d.device === deviceName);
if (!drive) {
alert('Drive data not found');
return;
}
const modal = document.getElementById('smart-modal');
const title = document.getElementById('smart-modal-title');
const content = document.getElementById('smart-modal-content');
title.textContent = `${drive.device} - Health Details`;
const assessmentClass = getHealthClass(drive.assessment);
const formatHours = (hours) => {
if (hours === null || hours === undefined) return '--';
const days = Math.floor(hours / 24);
const years = Math.floor(days / 365);
if (years > 0) return `${hours.toLocaleString()} hrs (${years}y ${days % 365}d)`;
if (days > 0) return `${hours.toLocaleString()} hrs (${days}d)`;
return `${hours} hrs`;
};
content.innerHTML = `
<table style="width: 100%; margin: 0;">
<tr>
<td style="color: #888; width: 40%;">Model</td>
<td>${drive.model || '--'}</td>
</tr>
<tr>
<td style="color: #888;">Serial</td>
<td>${drive.serial || '--'}</td>
</tr>
<tr>
<td style="color: #888;">Capacity</td>
<td>${drive.capacity || '--'}</td>
</tr>
<tr>
<td style="color: #888;">Assessment</td>
<td class="${assessmentClass}">${drive.assessment || 'Unknown'}</td>
</tr>
<tr>
<td style="color: #888;">Temperature</td>
<td>${drive.temperature !== null ? drive.temperature + '°C' : '--'}</td>
</tr>
<tr>
<td style="color: #888;">Power-On Hours</td>
<td>${formatHours(drive.power_on_hours)}</td>
</tr>
<tr>
<td style="color: #888;">Reallocated Sectors</td>
<td class="${drive.reallocated_sectors > 0 ? 'health-warn' : ''}">${drive.reallocated_sectors !== null ? drive.reallocated_sectors : '--'}</td>
</tr>
<tr>
<td style="color: #888;">Pending Sectors</td>
<td class="${drive.pending_sectors > 0 ? 'health-warn' : ''}">${drive.pending_sectors !== null ? drive.pending_sectors : '--'}</td>
</tr>
<tr>
<td style="color: #888;">SMART Enabled</td>
<td>${drive.smart_enabled ? 'Yes' : 'No'}</td>
</tr>
</table>
`;
modal.classList.add('show');
}
function closeSmartModal() {
document.getElementById('smart-modal').classList.remove('show');
}
// Load SMART data on page load
loadSmart();
// ===== FALLBACK / UPGRADE MODE =====
// Check URL parameters for special modes
const urlParams = new URLSearchParams(window.location.search);
const isFallback = urlParams.get('fallback') === 'true';
const isUpgrade = urlParams.get('upgrade') === 'true';
if (isUpgrade) {
// ===== UPGRADE MODE =====
// Show upgrade banner
document.getElementById('upgrade-banner').classList.add('show');
// Track elapsed time
const startTime = Date.now();
const elapsedEl = document.getElementById('upgrade-elapsed');
const updateElapsed = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
elapsedEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
setInterval(updateElapsed, 1000);
// Poll main API to check if upgrade is complete
const checkUpgradeComplete = async () => {
try {
const resp = await fetch('/api/status', {
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
// Upgrade complete!
document.getElementById('upgrade-spinner').style.display = 'none';
document.getElementById('upgrade-success').style.display = 'inline';
document.getElementById('upgrade-message').textContent = 'Upgrade complete! Redirecting...';
document.getElementById('upgrade-banner').classList.add('complete');
// Redirect to home after 2 seconds
setTimeout(() => {
window.location.href = '/';
}, 2000);
}
} catch (e) {
// Still upgrading, continue polling
}
};
// Check every 3 seconds (same as old maintenance page)
setInterval(checkUpgradeComplete, 3000);
// Also check immediately
checkUpgradeComplete();
} else if (isFallback) {
// ===== FALLBACK MODE =====
// Show fallback banner
document.getElementById('fallback-banner').classList.add('show');
// Poll main API to check if it's back up
const checkMainApp = async () => {
try {
// Check main API status endpoint (not Controller)
const resp = await fetch('/api/status', {method: 'HEAD'});
if (resp.ok) {
// Main app is back, redirect to home
window.location.href = '/';
}
} catch (e) {
// Still down, continue polling
}
};
// Check every 5 seconds
setInterval(checkMainApp, 5000);
// Also check immediately
checkMainApp();
}
// Always fetch stats - Controller is independent and can provide stats during upgrade/fallback
startPeriodicUpdates();
// ===== PERIODIC UPDATES =====
async function updateSystemStatus() {
try {
// Fetch all stats endpoints in parallel
const [cpu, memory, load, primaryDrive, iostat] = await Promise.all([
apiCall('api/stats/cpu'),
apiCall('api/stats/memory'),
apiCall('api/stats/load'),
apiCall('api/stats/drives/primary'),
apiCall('api/stats/iostat'),
]);
// Update CPU card
const cpuValue = document.querySelector('.status-card:nth-child(1) .value');
const cpuSub = document.querySelector('.status-card:nth-child(1) .sub');
if (cpuValue) cpuValue.textContent = (cpu.percent !== null ? cpu.percent : '--') + '%';
if (cpuSub) cpuSub.innerHTML = cpu.temperature ? cpu.temperature + '&deg;C' : '--';
// Update Memory card
const memValue = document.querySelector('.status-card:nth-child(2) .value');
const memSub = document.querySelector('.status-card:nth-child(2) .sub');
if (memValue && memory.total > 0) {
const memPercent = ((memory.used / memory.total) * 100).toFixed(1);
memValue.textContent = memPercent + '%';
}
if (memSub) {
const usedGb = (memory.used / (1024 ** 3)).toFixed(1);
memSub.textContent = usedGb + ' GB used';
}
// Update Load card
const loadValue = document.querySelector('.status-card:nth-child(3) .value');
if (loadValue) loadValue.textContent = load.minute_1 !== undefined ? load.minute_1 : '--';
// Update Storage card
const storageValue = document.querySelector('.status-card:nth-child(4) .value');
const storageSub = document.querySelector('.status-card:nth-child(4) .sub');
if (storageValue) {
storageValue.textContent = (primaryDrive.mounted && primaryDrive.percent !== undefined)
? primaryDrive.percent + '%'
: '--';
}
if (storageSub && primaryDrive.mounted && primaryDrive.size && primaryDrive.used) {
const freeBytes = primaryDrive.size - primaryDrive.used;
const freeGb = (freeBytes / (1024 ** 3)).toFixed(1);
storageSub.textContent = freeGb + ' GB free';
}
// Update IO Wait card
const iowaitValue = document.getElementById('iowait-value');
if (iowaitValue) {
const iowait = iostat.percent_iowait;
if (iowait !== null && iowait !== undefined) {
iowaitValue.textContent = iowait.toFixed(1) + '%';
// Color based on IO wait severity
if (iowait >= 30) {
iowaitValue.style.color = '#ff6b6b'; // Red
} else if (iowait >= 10) {
iowaitValue.style.color = '#ffd93d'; // Yellow
} else {
iowaitValue.style.color = '#4ecca3'; // Green (default)
}
} else {
iowaitValue.textContent = '--%';
iowaitValue.style.color = '#4ecca3';
}
}
} catch (e) {
console.error('Failed to update system status:', e);
}
}
async function updateServicesTable() {
try {
const services = await apiCall('api/services');
const tbody = document.querySelector('#services table tbody');
if (!tbody || !Array.isArray(services)) return;
// Check if we're in docker mode by looking for Boot column
const isDockerMode = !document.querySelector('th:last-child')?.textContent?.includes('Boot');
let html = '';
for (const service of services) {
html += `<tr>
<td>
${service.name}
${service.port ? `<span class="sub">:${service.port}</span>` : ''}
</td>
<td class="status-${service.status}">${service.status}</td>
<td>`;
if (service.status === 'running') {
html += `<button class="btn btn-danger" onclick="serviceAction('${service.name}', 'stop')">Stop</button>`;
} else {
html += `<button class="btn btn-primary" onclick="serviceAction('${service.name}', 'start')">Start</button>`;
}
html += `<button class="btn btn-warning" onclick="serviceAction('${service.name}', 'restart')">Restart</button>
<button class="btn btn-info" onclick="showLogs('${service.name}', true)">Logs</button>`;
if (service.viewable && service.status === 'running' && service.port) {
const protocol = service.use_https ? 'https' : 'http';
const host = window.location.hostname;
const viewPath = service.view_path || '';
html += `<a href="${protocol}://${host}:${service.port}${viewPath}" target="_blank" class="btn btn-violet">View</a>`;
}
html += '</td>';
if (!isDockerMode) {
if (service.name === 'wrolpi-upgrade') {
html += `<td><span style="color: #666;">--</span></td>`;
} else {
html += `<td>
<label class="toggle">
<input type="checkbox"
id="boot-${service.name}"
${service.enabled ? 'checked' : ''}
onchange="toggleBoot('${service.name}', this.checked)">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label" id="boot-label-${service.name}">
${service.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>`;
}
}
html += '</tr>';
}
if (services.length === 0) {
const colspan = isDockerMode ? 3 : 4;
html = `<tr><td colspan="${colspan}" style="text-align: center; color: #666;">No services</td></tr>`;
}
tbody.innerHTML = html;
} catch (e) {
console.error('Failed to update services:', e);
}
}
function startPeriodicUpdates() {
// Update every 10 seconds
const updateInterval = 10000;
// Initial update after a short delay (to not overlap with initial load)
setTimeout(() => {
updateSystemStatus();
updateServicesTable();
loadDisks();
loadSmart();
}, updateInterval);
// Set up periodic updates
setInterval(() => {
updateSystemStatus();
updateServicesTable();
loadDisks();
loadSmart();
}, updateInterval);
}
</script>
</body>
</html>