wrolpi/controller/templates/index.html
lrnselfreliance dac849f9f6
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
Surface coarse SMART health for USB-bridge drives (#498)
* Surface coarse SMART health for USB-bridge drives

USB enclosures that reject ATA pass-through (e.g. Seagate Expansion) are
invisible to pySMART/DeviceList, so the drive shows no SMART at all on the
Controller page. Fall back to smartctl over the SCSI transport, which many
such bridges still answer, to report a coarse PASS/FAIL health plus drive
identity (model/serial/capacity).

The health (-d scsi -H) and identity (-d scsi -i) reads are issued as
separate calls: combining them makes smartctl suppress the health line when
the identity read reports "SMART support is: Unavailable" (which these
bridges do). The entry is marked smart_limited (labelled "(limited)" in both
UIs) so a green PASS is not mistaken for full monitoring — these drives
expose no attributes or temperature, and some bridges report "OK" statically.

* Harden SCSI health fallback (Greptile review)

- _scsi_health: treat any unrecognised status (e.g. "UNKNOWN", "N/A") as
  unreadable (None) instead of a false FAIL. Only "OK" -> PASS and "FAIL*"
  -> FAIL; anything else omits the drive rather than showing a red FAIL that
  contradicts the smart_limited disclaimer.
- get_all_smart_status: isolate the SCSI supplement loop in its own
  try/except so an error there cannot discard the pySMART results already
  collected.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:05:06 -06:00

2783 lines
102 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;
}
/* Onboarding */
.onboarding-banner {
background: linear-gradient(135deg, #4ecca3, #2ecc71);
color: #000;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.onboarding-banner h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.onboarding-banner p {
margin: 0.5rem 0;
}
.drive-cards {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
margin: 1rem 0;
}
.drive-card {
background: rgba(0, 0, 0, 0.15);
border: 2px solid rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem 1.5rem;
cursor: pointer;
text-align: left;
min-width: 220px;
max-width: 300px;
transition: border-color 0.2s, background 0.2s;
}
.drive-card:hover {
border-color: #16213e;
background: rgba(0, 0, 0, 0.25);
}
.drive-card .drive-name {
font-weight: bold;
font-size: 1.1rem;
}
.drive-card .drive-detail {
font-size: 0.85rem;
opacity: 0.8;
margin-top: 0.25rem;
}
.onboarding-banner .btn {
display: inline-block;
padding: 0.5rem 1.5rem;
border-radius: 4px;
border: none;
font-size: 1rem;
cursor: pointer;
margin: 0.25rem;
}
.onboarding-banner .btn-primary {
background: #16213e;
color: #fff;
}
.onboarding-banner .btn-primary:hover {
background: #1a2a4e;
}
.onboarding-banner .btn-secondary {
background: rgba(0, 0, 0, 0.15);
color: #000;
border: 1px solid rgba(0, 0, 0, 0.3);
}
.onboarding-banner .btn-secondary:hover {
background: rgba(0, 0, 0, 0.25);
}
.onboarding-banner .error-msg {
color: #c0392b;
font-weight: bold;
}
.onboarding-banner .warning-msg {
background: rgba(0, 0, 0, 0.1);
padding: 0.75rem;
border-radius: 4px;
margin: 0.75rem 0;
}
.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;
}
/* 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;
}
/* Scripts section */
.scripts-list {
background: #16213e;
border-radius: 8px;
overflow: hidden;
}
.script-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #0f3460;
}
.script-row:last-child {
border-bottom: none;
}
.script-row:hover {
background: #1a3a5c;
}
.script-info h4 {
margin: 0 0 0.25rem 0;
color: #4ecca3;
font-size: 1rem;
}
.script-info p {
color: #aaa;
font-size: 0.85rem;
margin: 0;
}
.script-row.unavailable {
opacity: 0.6;
}
.script-row.unavailable .btn {
cursor: not-allowed;
}
.script-running-banner {
background: linear-gradient(135deg, #3498db, #2980b9);
color: #fff;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
display: none;
}
.script-running-banner.show {
display: block;
}
.script-running-banner h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.script-running-banner .loading {
width: 2rem;
height: 2rem;
border-color: rgba(255,255,255,0.3);
border-top-color: #fff;
margin: 0.5rem auto;
}
.script-running-banner .actions {
margin-top: 1rem;
}
.warning-list {
background: #1a1a2e;
border: 1px solid #ff6b6b;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
}
.warning-list h4 {
color: #ff6b6b;
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
}
.warning-list ul {
margin: 0;
padding-left: 1.25rem;
color: #ccc;
}
.warning-list li {
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.warning-list li:last-child {
margin-bottom: 0;
}
/* 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, Bluetooth, Samba, and shutdown are not available.
</div>
{% endif %}
<header>
<div>
<h1>Controller - {{ host }} WROLPi</h1>
</div>
</header>
<!-- Certificate trust banner — shown by JS when HTTPS cert is not trusted -->
<div id="cert-banner" class="card" style="display: none; background: #16213e; border: 1px solid #1a3a5c; border-radius: 12px; padding: 1.5rem; margin-bottom: 1rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 0.75rem; color: #4cc9f0;">HTTPS Certificate Setup</h2>
<p style="line-height: 1.6; color: #ccc; margin-bottom: 1rem;">
Install the WROLPi Root CA certificate so your browser trusts all WROLPi HTTPS services.
</p>
<a class="btn btn-info" href="/ca.crt" download="wrolpi-ca.crt" style="margin-bottom: 1rem; display: inline-block;">Download WROLPi Root CA</a>
<details style="margin-bottom: 0.75rem;">
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">iOS / iPadOS</summary>
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
<ol style="padding-left: 1.25rem;">
<li>Tap the download button above, then tap <strong>Allow</strong>.</li>
<li>Open <strong>Settings</strong> &rarr; <strong>General</strong> &rarr; <strong>VPN &amp; Device Management</strong>.</li>
<li>Tap <strong>WROLPi Root CA</strong> &rarr; <strong>Install</strong> &rarr; enter your passcode.</li>
<li>Go to <strong>Settings</strong> &rarr; <strong>General</strong> &rarr; <strong>About</strong> &rarr; <strong>Certificate Trust Settings</strong>.</li>
<li>Enable full trust for <strong>WROLPi Root CA</strong>.</li>
</ol>
</div>
</details>
<details style="margin-bottom: 0.75rem;">
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">Android</summary>
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
<ol style="padding-left: 1.25rem;">
<li>Tap the download button above.</li>
<li>Open <strong>Settings</strong> &rarr; <strong>Security &amp; privacy</strong> &rarr; <strong>More security settings</strong> &rarr; <strong>Encryption &amp; credentials</strong> &rarr; <strong>Install a certificate</strong> &rarr; <strong>CA certificate</strong>.</li>
<li>Select the downloaded <code style="background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px;">wrolpi-ca.crt</code> file and confirm.</li>
</ol>
<p style="margin-top:0.5rem;font-size:0.9em;color:#999;">Menu paths vary by manufacturer. Search for <em>"certificate"</em> in Settings if needed.</p>
</div>
</details>
<details style="margin-bottom: 0.75rem;">
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">macOS</summary>
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
<ol style="padding-left: 1.25rem;">
<li>Double-click the downloaded <code style="background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px;">wrolpi-ca.crt</code> to open Keychain Access.</li>
<li>Add it to the <strong>System</strong> or <strong>login</strong> keychain.</li>
<li>Find <strong>WROLPi Root CA</strong>, double-click it.</li>
<li>Expand <strong>Trust</strong>, set to <strong>Always Trust</strong>.</li>
<li>Close and enter your password.</li>
</ol>
</div>
</details>
<details style="margin-bottom: 0.75rem;">
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">Windows</summary>
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
<ol style="padding-left: 1.25rem;">
<li>Double-click the downloaded <code style="background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px;">wrolpi-ca.crt</code>.</li>
<li>Click <strong>Install Certificate</strong> &rarr; <strong>Local Machine</strong> &rarr; <strong>Next</strong>.</li>
<li>Select <strong>Place all certificates in the following store</strong> &rarr; <strong>Browse</strong> &rarr; <strong>Trusted Root Certification Authorities</strong>.</li>
<li>Click <strong>Finish</strong>.</li>
</ol>
</div>
</details>
<details style="margin-bottom: 0.75rem;">
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">Linux (Debian / Ubuntu)</summary>
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
<ol style="padding-left: 1.25rem;">
<li>Copy the certificate: <code style="background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px;">sudo cp wrolpi-ca.crt /usr/local/share/ca-certificates/</code></li>
<li>Update the trust store: <code style="background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px;">sudo update-ca-certificates</code></li>
<li>Restart your browser.</li>
</ol>
</div>
</details>
<details style="margin-bottom: 0.75rem;">
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">Firefox (all platforms)</summary>
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
<p style="margin-bottom:0.5rem;">Since Firefox 120, Firefox <strong>automatically trusts</strong> root certificates from the OS store on <strong>Windows, macOS, and Android</strong>.</p>
<p style="margin-bottom:0.5rem;">On <strong>Linux</strong> (or if auto-trust is disabled), import manually:</p>
<ol style="padding-left: 1.25rem;">
<li>Open Firefox &rarr; <strong>Settings</strong> &rarr; <strong>Privacy &amp; Security</strong>.</li>
<li>Scroll to <strong>Certificates</strong> &rarr; <strong>View Certificates</strong>.</li>
<li>In <strong>Authorities</strong> tab, click <strong>Import</strong>.</li>
<li>Select <code style="background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px;">wrolpi-ca.crt</code>, check <strong>Trust this CA to identify websites</strong>.</li>
</ol>
</div>
</details>
<div style="text-align: center; margin-top: 1rem;">
<a class="btn btn-success" id="cert-continue-btn" href="#">Continue to WROLPi (HTTPS)</a>
<button class="btn btn-secondary" onclick="fetch('api/hide-cert-banner',{method:'POST'}).then(function(){document.getElementById('cert-banner').style.display='none';})" style="margin-left: 0.5rem;">Never ask again</button>
</div>
</div>
{% if not drive_mounted and not docker_mode %}
<div id="onboarding-banner" class="onboarding-banner">
<!-- Step 1: Select drive -->
<div id="onboarding-select" style="display: block;">
<h2>Welcome to WROLPi</h2>
<p>Select your primary WROLPi drive to get started.</p>
<div id="onboarding-drives" class="drive-cards">
<span class="loading"></span>
</div>
<p id="onboarding-no-drives" style="display: none;">No unmounted drives detected. Plug in a USB drive and refresh.</p>
<div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.2);">
<p style="margin-bottom: 0.5rem; font-size: 0.9em; opacity: 0.8;">No external drive? You can still set up WROLPi using internal storage.</p>
<button class="btn btn-secondary" onclick="repairWithoutDrive()">Run repair without external drive</button>
</div>
</div>
<!-- Step 2: Confirm -->
<div id="onboarding-confirm" style="display: none;">
<h2>Confirm Drive Setup</h2>
<div id="onboarding-config-found" style="display: none;">
<p>Found WROLPi configuration on this drive.</p>
<p id="onboarding-mounts-info"></p>
<button class="btn btn-primary" onclick="commitOnboarding({})">Set Up This Drive</button>
<button class="btn btn-secondary" onclick="cancelOnboarding()">Choose Different Drive</button>
</div>
<div id="onboarding-no-config" style="display: none;">
<div class="warning-msg">
<p><strong>No WROLPi configuration found on this drive.</strong></p>
<p>This may be a new drive or the wrong drive. You can still use it as a fresh WROLPi drive.</p>
</div>
<button class="btn btn-primary" onclick="commitOnboarding({})">Use Anyway</button>
<button class="btn btn-secondary" onclick="cancelOnboarding()">Choose Different Drive</button>
</div>
</div>
<!-- Step 2b: Shadowed-data warning -->
<div id="onboarding-shadowed" style="display: none;">
<h2>Existing Files Detected</h2>
<div class="warning-msg">
<p><strong>Mounting this drive would hide existing files at the mount point.</strong></p>
<p id="onboarding-shadowed-msg"></p>
<p>If you mount anyway, those files will continue to consume space on the underlying filesystem (typically the SD card on a Raspberry Pi) and can fill it up.</p>
<p>Recommended: cancel, move or delete the existing files, then mount.</p>
</div>
<button class="btn btn-primary" onclick="confirmShadowedMount()">Mount Anyway</button>
<button class="btn btn-secondary" onclick="cancelOnboarding()">Cancel</button>
</div>
<!-- Step 3: Progress -->
<div id="onboarding-progress" style="display: none;">
<h2>Setting Up Drives...</h2>
<span class="loading"></span>
<p>Mounting drives and starting repair. Please wait...</p>
</div>
<!-- Step 4: Complete -->
<div id="onboarding-complete" style="display: none;">
<h2>Setup Complete!</h2>
<p id="onboarding-complete-msg">Repair is running. The main UI will be available shortly.</p>
<p>You will be redirected automatically, or you can continue using this page.</p>
</div>
<!-- Step 5: Error -->
<div id="onboarding-error" style="display: none;">
<h2>Setup Failed</h2>
<p class="error-msg" id="onboarding-error-msg"></p>
<button class="btn btn-primary" onclick="resetOnboarding()">Try Again</button>
</div>
</div>
{% elif not drive_mounted %}
<div class="info-banner">
Primary drive not mounted. Some features may be limited.
</div>
{% endif %}
{% if not docker_mode %}
<div id="script-running-banner" class="script-running-banner">
<h2 id="script-running-name">Script Running</h2>
<span class="loading"></span>
<div class="actions">
<button class="btn btn-info" onclick="showScriptOutput()">View Output</button>
</div>
</div>
{% endif %}
{% set app_ready = services | selectattr('name', 'in', ['caddy', 'web']) | selectattr('status', 'equalto', 'running') | list | length > 0 %}
<div style="margin-bottom: 1rem;">
<a id="proceed-btn" class="btn btn-success" style="{% if not app_ready %}display: none;{% endif %} font-size: 1.1rem; padding: 0.6rem 1.5rem;" href="#">Proceed to WROLPi</a>
<a id="view-files-btn" class="btn btn-info" style="{% if not app_ready %}display: none;{% endif %} font-size: 1.1rem; padding: 0.6rem 1.5rem; margin-left: 0.5rem;" href="#">View Files</a>
</div>
<div id="fallback-banner" class="fallback-banner">
<span class="loading"></span>
Main UI is unavailable. Using Controller fallback. Will redirect when ready...
</div>
<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 class="status-card">
<h3>Uptime</h3>
<p class="value" id="uptime-value">{{ uptime.formatted|default('--') }}</p>
<p class="sub">since boot</p>
</div>
</div>
</section>
<section id="actions">
<h2>System Actions</h2>
{% if not docker_mode %}
<div class="action-buttons" style="margin-bottom: 1rem;">
<button id="hotspot-btn" class="btn" onclick="toggleHotspot()" disabled>Loading...</button>
<button id="bluetooth-btn" class="btn" onclick="toggleBluetooth()" disabled>Loading...</button>
</div>
{% endif %}
<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 %}
{% set is_default_port = (service.use_https and service.port == 443) or (not service.use_https and service.port == 80) %}
<a href="{{ 'https' if service.use_https else 'http' }}://{{ host }}{% if not is_default_port %}:{{ service.port }}{% endif %}{{ 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>
{% if not docker_mode %}
<section id="scripts">
<h2>Maintenance Scripts</h2>
<div id="scripts-content">
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
</div>
</section>
{% endif %}
</main>
<!-- Logs Modal -->
<div id="logs-modal" class="modal" onclick="if(event.target===this)closeLogsModal()">
<div class="modal-content" style="width: 90%; 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 Confirmation Modal -->
<div id="script-confirm-modal" class="modal" onclick="if(event.target===this)closeScriptConfirmModal()">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3 id="script-confirm-title">Run Script</h3>
<button class="modal-close" onclick="closeScriptConfirmModal()">&times;</button>
</div>
<div class="modal-body">
<p id="script-confirm-description" style="color: #ccc; margin-bottom: 1rem;"></p>
<div id="script-params-container" class="modal-form">
<!-- Parameter inputs are dynamically generated here -->
</div>
<div id="script-confirm-warnings" class="warning-list">
<h4>Warning: This script will:</h4>
<ul id="script-warnings-list"></ul>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeScriptConfirmModal()">Cancel</button>
<button id="script-confirm-btn" class="btn btn-danger" onclick="confirmRunScript()">Run Script</button>
</div>
</div>
</div>
</div>
<!-- Script Output Modal -->
<div id="script-output-modal" class="modal" onclick="if(event.target===this)closeScriptOutputModal()">
<div class="modal-content" style="width: 90%; max-width: none;">
<div class="modal-header">
<h3 id="script-output-title">Script Output</h3>
<button class="modal-close" onclick="closeScriptOutputModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="script-output-content"><span class="loading"></span> Loading output...</pre>
</div>
<div class="modal-footer">
<span id="script-output-countdown" style="color: #888; margin-right: 0.5rem;">10s</span>
<select id="script-output-lines-select" class="logs-select" onchange="changeScriptOutputLines()">
<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>
</select>
<button class="btn btn-info" onclick="refreshScriptOutput()">Refresh</button>
<button class="btn btn-secondary" onclick="closeScriptOutputModal()">Close</button>
</div>
</div>
</div>
<script>
/* ===== INLINE JAVASCRIPT - No external files ===== */
// Certificate trust detection.
(function() {
var host = window.location.hostname;
var port = window.location.port;
// Docker: 8080 → 8443; native: 80/empty → 443 (default).
var httpsPort = (port === '8080') ? ':8443' : '';
var httpsBase = 'https://' + host + httpsPort;
// Set the "Continue to WROLPi" link.
var continueBtn = document.getElementById('cert-continue-btn');
if (continueBtn) continueBtn.href = httpsBase;
// Set the "Proceed to WROLPi" and "View Files" links.
var proceedBtn = document.getElementById('proceed-btn');
if (proceedBtn) proceedBtn.href = httpsBase;
var viewFilesBtn = document.getElementById('view-files-btn');
if (viewFilesBtn) viewFilesBtn.href = httpsBase + '/media/';
// Skip cert check if user has permanently hidden the banner.
{% if hide_cert_banner %}return;{% endif %}
// Probe the HTTPS endpoint; show the cert banner if it fails.
var controller = new AbortController();
var timeout = setTimeout(function() { controller.abort(); }, 3000);
fetch(httpsBase + '/api/echo', { signal: controller.signal })
.then(function(r) {
// Cert is trusted — keep banner hidden.
})
.catch(function() {
// Cert not trusted or HTTPS unreachable — show the banner.
var banner = document.getElementById('cert-banner');
if (banner) banner.style.display = 'block';
})
.finally(function() { clearTimeout(timeout); });
})();
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}`);
}
}
// --- Hotspot ---
let hotspotEnabled = null;
async function updateHotspotButton() {
const btn = document.getElementById('hotspot-btn');
if (!btn) return;
try {
const data = await apiCall('api/hotspot/status');
hotspotEnabled = data.enabled;
if (!data.available) {
btn.textContent = 'Hotspot Unavailable';
btn.disabled = true;
btn.className = 'btn';
} else if (data.enabled) {
btn.textContent = 'Disable Hotspot';
btn.disabled = false;
btn.className = 'btn btn-warning';
} else {
btn.textContent = 'Enable Hotspot';
btn.disabled = false;
btn.className = 'btn btn-primary';
}
} catch (e) {
btn.textContent = 'Hotspot Unavailable';
btn.disabled = true;
}
}
async function toggleHotspot() {
const btn = document.getElementById('hotspot-btn');
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>';
try {
if (hotspotEnabled) {
await apiCall('api/hotspot/disable', 'POST');
} else {
await apiCall('api/hotspot/enable', 'POST');
}
} catch (e) {
alert(`Hotspot error: ${e.message}`);
}
await updateHotspotButton();
}
// --- Bluetooth ---
let bluetoothEnabled = null;
async function updateBluetoothButton() {
const btn = document.getElementById('bluetooth-btn');
if (!btn) return;
try {
const data = await apiCall('api/bluetooth/status');
bluetoothEnabled = data.enabled;
if (!data.available) {
btn.textContent = 'Bluetooth Unavailable';
btn.disabled = true;
btn.className = 'btn';
} else if (data.enabled) {
btn.textContent = 'Disable Bluetooth';
btn.disabled = false;
btn.className = 'btn btn-warning';
} else {
btn.textContent = 'Enable Bluetooth';
btn.disabled = false;
btn.className = 'btn btn-primary';
}
} catch (e) {
btn.textContent = 'Bluetooth Unavailable';
btn.disabled = true;
}
}
async function toggleBluetooth() {
const btn = document.getElementById('bluetooth-btn');
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>';
try {
if (bluetoothEnabled) {
await apiCall('api/bluetooth/disable', 'POST');
} else {
await apiCall('api/bluetooth/enable', 'POST');
}
} catch (e) {
alert(`Bluetooth error: ${e.message}`);
}
await updateBluetoothButton();
}
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();
closeScriptConfirmModal();
closeScriptOutputModal();
}
});
// ===== 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', '/media/wrolpi'];
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.health || drive.assessment);
const assessmentText = (drive.health || drive.assessment || 'Unknown')
+ (drive.smart_limited ? ' (limited)' : '');
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.health || 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 || drive.health || 'Unknown'}${drive.smart_limited ? ' (limited — USB enclosure reports health only)' : ''}</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;">Uncorrectable Sectors</td>
<td class="${drive.offline_uncorrectable > 0 ? 'health-warn' : ''}">${drive.offline_uncorrectable !== null && drive.offline_uncorrectable !== undefined ? drive.offline_uncorrectable : '--'}</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();
// ===== SCRIPTS MANAGEMENT =====
let scriptsData = [];
let pendingScriptName = null;
let scriptStatusInterval = null;
let scriptOutputCountdown = 10;
let scriptOutputInterval = null;
let currentScriptOutput = null;
async function loadScripts() {
const container = document.getElementById('scripts-content');
if (!container) return; // Docker mode - section doesn't exist
try {
const scripts = await apiCall('api/scripts');
scriptsData = scripts;
if (scripts.length === 0) {
container.innerHTML = '<p style="color: #888; text-align: center;">No scripts available</p>';
return;
}
let html = '<div class="scripts-list">';
for (const script of scripts) {
const unavailableClass = script.available ? '' : 'unavailable';
const btnDisabled = script.available ? '' : 'disabled';
html += `
<div class="script-row ${unavailableClass}">
<div class="script-info">
<h4>${script.display_name}</h4>
<p>${script.description}</p>
</div>
<button class="btn btn-warning" ${btnDisabled} onclick="showScriptConfirmation('${script.name}')">Run</button>
</div>
`;
}
html += '</div>';
container.innerHTML = html;
} catch (e) {
if (e.message.includes('501')) {
container.innerHTML = '<p style="color: #888; text-align: center;">Scripts not available in Docker mode</p>';
} else {
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading scripts: ${e.message}</p>`;
}
}
}
async function checkScriptStatus() {
const banner = document.getElementById('script-running-banner');
if (!banner) return; // Docker mode
try {
const status = await apiCall('api/scripts/status');
if (status.running) {
// Show banner
banner.classList.add('show');
const script = scriptsData.find(s => s.name === status.script_name);
document.getElementById('script-running-name').textContent =
script ? `${script.display_name} Running` : 'Script Running';
currentScriptOutput = status.script_name;
// Hide onboarding while a script is running
const onboardingEl = document.getElementById('onboarding-banner');
if (onboardingEl) {
onboardingEl.style.display = 'none';
}
// Disable run buttons while script is running
document.querySelectorAll('.script-row .btn').forEach(btn => {
btn.disabled = true;
});
} else {
// Hide banner
banner.classList.remove('show');
currentScriptOutput = null;
// Re-check if onboarding is still needed (e.g. after repair finishes)
const onboardingEl = document.getElementById('onboarding-banner');
if (onboardingEl) {
loadOnboardingCandidates();
}
// Re-enable run buttons
document.querySelectorAll('.script-row').forEach(row => {
if (!row.classList.contains('unavailable')) {
row.querySelector('.btn').disabled = false;
}
});
}
} catch (e) {
console.error('Failed to check script status:', e);
}
}
async function showScriptConfirmation(name) {
const script = scriptsData.find(s => s.name === name);
if (!script) return;
pendingScriptName = name;
document.getElementById('script-confirm-title').textContent = `Run ${script.display_name}?`;
document.getElementById('script-confirm-description').textContent = script.description;
const warningsList = document.getElementById('script-warnings-list');
warningsList.innerHTML = script.warnings.map(w => `<li>${w}</li>`).join('');
// Generate parameter inputs dynamically
const paramsContainer = document.getElementById('script-params-container');
paramsContainer.innerHTML = '';
const parameters = script.parameters || [];
for (const param of parameters) {
const inputId = `script-param-${param.name}`;
let defaultValue = '';
// For branch type, fetch current branch
if (param.type === 'branch') {
try {
const data = await apiCall('api/scripts/branch');
if (data.branch) {
defaultValue = data.branch;
}
} catch (e) {
defaultValue = 'release';
}
}
const html = `
<label for="${inputId}">${param.label}:</label>
<input type="text" id="${inputId}" data-param-name="${param.name}"
value="${defaultValue}" placeholder="${param.label.toLowerCase()}" />
`;
paramsContainer.innerHTML += html;
}
document.getElementById('script-confirm-modal').classList.add('show');
}
function closeScriptConfirmModal() {
document.getElementById('script-confirm-modal').classList.remove('show');
pendingScriptName = null;
}
async function confirmRunScript() {
if (!pendingScriptName) return;
const btn = document.getElementById('script-confirm-btn');
const originalText = btn.textContent;
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span>';
const script = scriptsData.find(s => s.name === pendingScriptName);
const isUpgrade = pendingScriptName === 'upgrade';
const isRepair = pendingScriptName === 'repair';
// Collect all parameter values from dynamic inputs
let body = null;
const paramsContainer = document.getElementById('script-params-container');
const paramInputs = paramsContainer.querySelectorAll('input[data-param-name]');
if (paramInputs.length > 0) {
const params = {};
paramInputs.forEach(input => {
const value = input.value.trim();
if (value) {
params[input.dataset.paramName] = value;
}
});
if (Object.keys(params).length > 0) {
body = { params: params };
}
}
try {
await apiCall(`api/scripts/${pendingScriptName}/start`, 'POST', body);
closeScriptConfirmModal();
// For upgrade script, redirect to upgrade mode
if (isUpgrade) {
window.location.href = `?upgrade=true`;
return;
}
// For repair script, redirect to fallback mode
if (isRepair) {
window.location.href = `?fallback=true`;
return;
}
// Immediately check status to show banner
await checkScriptStatus();
// Start polling for status updates
if (!scriptStatusInterval) {
scriptStatusInterval = setInterval(checkScriptStatus, 3000);
}
} catch (e) {
alert(`Failed to start script: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
function showScriptOutput() {
const name = currentScriptOutput || (scriptsData[0] && scriptsData[0].name);
if (!name) return;
const script = scriptsData.find(s => s.name === name);
document.getElementById('script-output-title').textContent =
script ? `${script.display_name} Output` : 'Script Output';
document.getElementById('script-output-content').innerHTML =
'<span class="loading"></span> Loading output...';
document.getElementById('script-output-modal').classList.add('show');
refreshScriptOutput();
startScriptOutputCountdown();
}
function startScriptOutputCountdown() {
if (scriptOutputInterval) clearInterval(scriptOutputInterval);
scriptOutputCountdown = 10;
document.getElementById('script-output-countdown').textContent = '10s';
scriptOutputInterval = setInterval(() => {
scriptOutputCountdown--;
document.getElementById('script-output-countdown').textContent = scriptOutputCountdown + 's';
if (scriptOutputCountdown <= 0) {
// Check if scrolled to bottom
const modalBody = document.getElementById('script-output-content').closest('.modal-body');
const isAtBottom = modalBody.scrollHeight - modalBody.scrollTop - modalBody.clientHeight < 50;
if (isAtBottom) {
refreshScriptOutput();
}
scriptOutputCountdown = 10;
document.getElementById('script-output-countdown').textContent = '10s';
}
}, 1000);
}
function stopScriptOutputCountdown() {
if (scriptOutputInterval) {
clearInterval(scriptOutputInterval);
scriptOutputInterval = null;
}
}
async function refreshScriptOutput() {
const name = currentScriptOutput || (scriptsData[0] && scriptsData[0].name);
if (!name) return;
const lines = document.getElementById('script-output-lines-select').value;
const content = document.getElementById('script-output-content');
const modalBody = content.closest('.modal-body');
try {
const data = await apiCall(`api/scripts/${name}/output?lines=${lines}`);
content.textContent = data.output || 'No output available';
// Scroll to bottom
modalBody.scrollTop = modalBody.scrollHeight;
} catch (e) {
content.textContent = `Error loading output: ${e.message}`;
}
}
function changeScriptOutputLines() {
refreshScriptOutput();
}
function closeScriptOutputModal() {
stopScriptOutputCountdown();
document.getElementById('script-output-modal').classList.remove('show');
}
// ===== ONBOARDING =====
let onboardingDevicePath = null;
let onboardingFstype = null;
function showOnboardingStep(step) {
['select', 'confirm', 'shadowed', 'progress', 'complete', 'error'].forEach(s => {
const el = document.getElementById('onboarding-' + s);
if (el) el.style.display = (s === step) ? 'block' : 'none';
});
}
function formatBytes(bytes) {
if (!bytes || bytes < 1) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n >= 10 ? 0 : 1)} ${units[i]}`;
}
async function loadOnboardingCandidates() {
const container = document.getElementById('onboarding-drives');
const noDrives = document.getElementById('onboarding-no-drives');
if (!container) return;
try {
const resp = await fetch('api/onboarding/candidates', {
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(10000)
});
if (!resp.ok) {
// Drive already mounted or other precondition — hide wizard
document.getElementById('onboarding-banner').style.display = 'none';
return;
}
const candidates = await resp.json();
container.innerHTML = '';
// Re-show the onboarding banner (may have been hidden while a script was running)
document.getElementById('onboarding-banner').style.display = '';
if (candidates.length === 0) {
noDrives.style.display = 'block';
return;
}
noDrives.style.display = 'none';
candidates.forEach(drive => {
const card = document.createElement('div');
card.className = 'drive-card';
card.onclick = () => probeDrive(drive.path, drive.fstype);
let details = '';
if (drive.size) details += `<div class="drive-detail">Size: ${drive.size}</div>`;
if (drive.label) details += `<div class="drive-detail">Label: ${drive.label}</div>`;
if (drive.model) details += `<div class="drive-detail">Model: ${drive.model}</div>`;
if (drive.fstype) details += `<div class="drive-detail">Type: ${drive.fstype}</div>`;
if (drive.uuid) details += `<div class="drive-detail">UUID: ${drive.uuid}</div>`;
if (drive.mountpoint) details += `<div class="drive-detail">Mounted at: ${drive.mountpoint}</div>`;
card.innerHTML = `<div class="drive-name">${drive.path}</div>${details}`;
container.appendChild(card);
});
} catch (e) {
console.error('Failed to load onboarding candidates:', e);
}
}
async function probeDrive(path, fstype) {
onboardingDevicePath = path;
onboardingFstype = fstype;
showOnboardingStep('progress');
document.querySelector('#onboarding-progress p').textContent = 'Checking drive for configuration...';
try {
const resp = await fetch('api/onboarding/probe', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify({device_path: path, fstype: fstype}),
signal: AbortSignal.timeout(30000)
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || 'Probe failed');
}
const data = await resp.json();
showOnboardingStep('confirm');
if (data.config_found) {
document.getElementById('onboarding-config-found').style.display = 'block';
document.getElementById('onboarding-no-config').style.display = 'none';
const mountCount = (data.mounts || []).length;
document.getElementById('onboarding-mounts-info').textContent =
mountCount > 0
? `Configuration includes ${mountCount} mount(s).`
: '';
} else {
document.getElementById('onboarding-config-found').style.display = 'none';
document.getElementById('onboarding-no-config').style.display = 'block';
}
} catch (e) {
document.getElementById('onboarding-error-msg').textContent = e.message;
showOnboardingStep('error');
}
}
async function commitOnboarding(opts) {
opts = opts || {};
const forceShadowed = !!opts.force_shadowed;
showOnboardingStep('progress');
document.querySelector('#onboarding-progress p').textContent = 'Mounting drives and starting repair. Please wait...';
try {
const resp = await fetch('api/onboarding/commit', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify({
device_path: onboardingDevicePath,
fstype: onboardingFstype,
force_shadowed: forceShadowed,
}),
signal: AbortSignal.timeout(60000)
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || 'Setup failed');
}
const data = await resp.json();
// Soft-block: target contains existing data that would be shadowed.
if (data.success === false && data.needs_force === 'shadowed') {
const shadowed = data.shadowed_data || {entries: [], size_bytes: 0};
const entries = (shadowed.entries || []).join(', ') || '(unknown)';
document.getElementById('onboarding-shadowed-msg').textContent =
`Found ${entries} (${formatBytes(shadowed.size_bytes)}) at the mount target.`;
showOnboardingStep('shadowed');
return;
}
showOnboardingStep('complete');
if (data.repair_started) {
document.getElementById('onboarding-complete-msg').textContent =
'Drives mounted and repair started. The main UI will be available shortly.';
// 10s delay so user sees "Setup Complete!" before redirect
setTimeout(() => {
window.location.href = '?fallback=true';
}, 10000);
} else {
document.getElementById('onboarding-complete-msg').textContent =
'Drives mounted successfully. You may need to start repair manually.';
}
} catch (e) {
document.getElementById('onboarding-error-msg').textContent = e.message;
showOnboardingStep('error');
}
}
function confirmShadowedMount() {
commitOnboarding({force_shadowed: true});
}
async function cancelOnboarding() {
try {
await fetch('api/onboarding/cancel', {
method: 'POST',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(10000)
});
} catch (e) {
// Ignore cancel errors
}
onboardingDevicePath = null;
onboardingFstype = null;
showOnboardingStep('select');
loadOnboardingCandidates();
}
async function repairWithoutDrive() {
showOnboardingStep('progress');
document.querySelector('#onboarding-progress h2').textContent = 'Running Repair...';
document.querySelector('#onboarding-progress p').textContent = 'Starting repair without external drive. Please wait...';
try {
const resp = await fetch('api/scripts/repair/start', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
signal: AbortSignal.timeout(60000)
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to start repair');
}
showOnboardingStep('complete');
document.getElementById('onboarding-complete-msg').textContent =
'Repair started without external drive. The main UI will be available shortly.';
setTimeout(() => {
window.location.href = '?fallback=true';
}, 10000);
} catch (e) {
document.getElementById('onboarding-error-msg').textContent = e.message;
showOnboardingStep('error');
}
}
function resetOnboarding() {
cancelOnboarding();
}
// ===== INITIALIZATION =====
// Each section is wrapped in try-catch to prevent one failure from breaking the entire page
// Check URL parameters for special modes
let isFallback = false;
let isUpgradeMode = false;
let urlParams = null;
try {
urlParams = new URLSearchParams(window.location.search);
isFallback = urlParams.get('fallback') === 'true';
isUpgradeMode = urlParams.get('upgrade') === 'true';
} catch (e) {
console.error('Failed to parse URL parameters:', e);
}
// ===== ONBOARDING INIT =====
try {
const onboardingEl = document.getElementById('onboarding-banner');
if (onboardingEl && !isUpgradeMode) {
loadOnboardingCandidates();
} else if (onboardingEl && isUpgradeMode) {
onboardingEl.style.display = 'none';
}
} catch (e) {
console.error('Failed to initialize onboarding:', e);
}
// ===== SCRIPTS SECTION =====
try {
// Always load scripts list (shows available scripts)
if (document.getElementById('scripts-content')) {
loadScripts();
}
// Only poll status when NOT in upgrade mode (upgrade banner handles status display)
if (document.getElementById('scripts-content') && !isUpgradeMode) {
checkScriptStatus();
// Poll for script status every 5 seconds
scriptStatusInterval = setInterval(checkScriptStatus, 5000);
}
} catch (e) {
console.error('Failed to initialize scripts section:', e);
}
// ===== UPGRADE MODE =====
// When ?upgrade=true, poll for completion and redirect to main UI when ready.
try {
if (isUpgradeMode) {
// Show the script-running banner with upgrade-specific text
const upgradeBanner = document.getElementById('script-running-banner');
if (upgradeBanner) {
upgradeBanner.classList.add('show');
document.getElementById('script-running-name').textContent = 'WROLPi Upgrade Running';
currentScriptOutput = 'upgrade';
}
const upgradeIsDocker = {{ 'true' if docker_mode else 'false' }};
let upgradeHttpsBase;
if (window.location.protocol === 'https:') {
upgradeHttpsBase = `https://${window.location.hostname}`;
} else if (upgradeIsDocker) {
upgradeHttpsBase = `https://${window.location.hostname}:8443`;
} else {
upgradeHttpsBase = `https://${window.location.hostname}`;
}
// Two-phase upgrade detection:
// Phase 1: Wait for services to go DOWN (confirms upgrade actually started).
// Phase 2: Wait for services to come back UP (upgrade complete).
let upgradeServicesWentDown = sessionStorage.getItem('upgradeServicesWentDown') === 'true';
const checkUpgradeComplete = async () => {
try {
const resp = await fetch('api/ready', {
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
const data = await resp.json();
if (!upgradeServicesWentDown) {
if (!data.api || !data.app) {
upgradeServicesWentDown = true;
sessionStorage.setItem('upgradeServicesWentDown', 'true');
}
} else if (data.api && data.app) {
sessionStorage.removeItem('upgradeServicesWentDown');
setTimeout(() => {
window.location.href = `${upgradeHttpsBase}/`;
}, 2000);
}
} else {
upgradeServicesWentDown = true;
sessionStorage.setItem('upgradeServicesWentDown', 'true');
}
} catch (e) {
upgradeServicesWentDown = true;
sessionStorage.setItem('upgradeServicesWentDown', 'true');
}
};
setInterval(checkUpgradeComplete, 3000);
checkUpgradeComplete();
}
} catch (e) {
console.error('Failed to initialize upgrade mode:', e);
}
// ===== FALLBACK MODE =====
try {
if (isFallback && !isUpgradeMode) {
// Show fallback banner
document.getElementById('fallback-banner').classList.add('show');
// Poll controller's readiness endpoint (same-origin, avoids CORS/cert issues).
const checkMainApp = async () => {
try {
// WARNING: This must be a relative URL (no leading slash) so it resolves to
// /controller/api/ready behind Caddy, or /api/ready when accessed directly.
// An absolute '/api/ready' would be routed to Sanic by Caddy, not the Controller.
const resp = await fetch('api/ready', {
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
const data = await resp.json();
if (data.api && data.app) {
const isDocker = {{ 'true' if docker_mode else 'false' }};
let httpsBase;
if (window.location.protocol === 'https:') {
httpsBase = `https://${window.location.hostname}`;
} else if (isDocker) {
httpsBase = `https://${window.location.hostname}:8443`;
} else {
httpsBase = `https://${window.location.hostname}`;
}
window.location.href = `${httpsBase}/`;
}
}
} catch (e) {
// Still down, continue polling
}
};
// Check every 5 seconds
setInterval(checkMainApp, 5000);
// Also check immediately
checkMainApp();
}
} catch (e) {
console.error('Failed to initialize fallback mode:', e);
}
// ===== PERIODIC UPDATES =====
// Always fetch stats - Controller is independent and can provide stats during upgrade/fallback
try {
startPeriodicUpdates();
} catch (e) {
console.error('Failed to start periodic updates:', e);
}
function formatUptime(seconds) {
if (!seconds || seconds < 0) return '--';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
async function updateSystemStatus() {
try {
// Fetch all stats endpoints in parallel
const [cpu, memory, load, primaryDrive, iostat, uptime] = await Promise.all([
apiCall('api/stats/cpu'),
apiCall('api/stats/memory'),
apiCall('api/stats/load'),
apiCall('api/stats/drives/primary'),
apiCall('api/stats/iostat'),
apiCall('api/stats/uptime'),
]);
// 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';
}
}
// Update Uptime card
const uptimeValue = document.getElementById('uptime-value');
if (uptimeValue) {
uptimeValue.textContent = formatUptime(uptime.uptime_seconds);
}
} 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 || '';
const isDefaultPort = (protocol === 'https' && service.port === 443) || (protocol === 'http' && service.port === 80);
const portSuffix = isDefaultPort ? '' : ':' + service.port;
html += `<a href="${protocol}://${host}${portSuffix}${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;
// Show "Proceed to WROLPi" when the main UI is reachable.
var proceedBtn = document.getElementById('proceed-btn');
if (proceedBtn) {
// Check if caddy/web is running (native or Docker).
var appReady = services.some(function(s) {
return (s.name === 'caddy' || s.name === 'web') && s.status === 'running';
});
if (appReady) {
var host = window.location.hostname;
var port = window.location.port;
// Docker: 8080 → 8443; native: 80/empty → 443 (default).
var httpsPort = (port === '8080') ? ':8443' : '';
var httpsBase = 'https://' + host + httpsPort;
proceedBtn.href = httpsBase;
proceedBtn.style.display = 'inline-block';
var viewFilesBtn = document.getElementById('view-files-btn');
if (viewFilesBtn) {
viewFilesBtn.href = httpsBase + '/media/';
viewFilesBtn.style.display = 'inline-block';
}
} else {
proceedBtn.style.display = 'none';
var viewFilesBtn2 = document.getElementById('view-files-btn');
if (viewFilesBtn2) viewFilesBtn2.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to update services:', e);
}
}
function startPeriodicUpdates() {
// Update every 10 seconds
const updateInterval = 10000;
// Fetch hardware button status immediately
updateHotspotButton();
updateBluetoothButton();
// 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();
updateHotspotButton();
updateBluetoothButton();
}, updateInterval);
}
</script>
</body>
</html>