2025-12-11 18:32:55 -07:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2025-12-24 16:43:37 -07:00
< title > Controller - {{ host }} WROLPi< / title >
2025-12-11 18:32:55 -07:00
< 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;
}
2026-03-14 14:56:32 -06:00
/* 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;
}
2025-12-11 18:32:55 -07:00
.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;
}
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
/* Service group separators (Core vs Optional & Maintenance) */
tr.service-group-label td {
background: #0f3460;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: #a9b7d0;
padding-top: 0.9rem;
}
tr.service-group-label:hover {
background: transparent;
}
2025-12-11 18:32:55 -07:00
/* 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;
}
2025-12-15 16:53:24 -07:00
.logs-select {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 4px;
color: #fff;
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
2025-12-11 18:32:55 -07:00
/* 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;
}
2026-02-25 15:32:43 -07:00
/* 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;
}
2025-12-11 18:32:55 -07:00
/* 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" >
2026-03-28 16:28:47 -06:00
Running in Docker mode. Disk mounting, hotspot, Bluetooth, Samba, and shutdown are not available.
2025-12-11 18:32:55 -07:00
< / div >
{% endif %}
2026-04-07 20:01:49 -06:00
< header >
< div >
< h1 > Controller - {{ host }} WROLPi< / h1 >
< / div >
< / header >
2026-07-18 13:40:23 -06:00
<!-- Certificate trust banner — shown by JS when HTTPS cert is not trusted and Root CA is ready -->
2026-04-07 20:01:49 -06:00
< 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 >
2026-07-18 13:40:23 -06:00
{% if cert_download_ready %}
2026-04-07 20:01:49 -06:00
< a class = "btn btn-info" href = "/ca.crt" download = "wrolpi-ca.crt" style = "margin-bottom: 1rem; display: inline-block;" > Download WROLPi Root CA< / a >
2026-07-18 13:40:23 -06:00
{% endif %}
2026-04-07 20:01:49 -06:00
< 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 > → < strong > General< / strong > → < strong > VPN & Device Management< / strong > .< / li >
< li > Tap < strong > WROLPi Root CA< / strong > → < strong > Install< / strong > → enter your passcode.< / li >
< li > Go to < strong > Settings< / strong > → < strong > General< / strong > → < strong > About< / strong > → < 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 > → < strong > Security & privacy< / strong > → < strong > More security settings< / strong > → < strong > Encryption & credentials< / strong > → < strong > Install a certificate< / strong > → < 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 > → < strong > Local Machine< / strong > → < strong > Next< / strong > .< / li >
< li > Select < strong > Place all certificates in the following store< / strong > → < strong > Browse< / strong > → < 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 → < strong > Settings< / strong > → < strong > Privacy & Security< / strong > .< / li >
< li > Scroll to < strong > Certificates< / strong > → < 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 >
2026-03-14 14:56:32 -06:00
{% 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 >
2026-03-29 18:58:31 -06:00
< 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 >
2026-03-14 14:56:32 -06:00
< / 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 >
2026-05-14 16:38:02 -06:00
< button class = "btn btn-primary" onclick = "commitOnboarding({})" > Set Up This Drive< / button >
2026-03-14 14:56:32 -06:00
< 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 >
2026-05-14 16:38:02 -06:00
< button class = "btn btn-primary" onclick = "commitOnboarding({})" > Use Anyway< / button >
2026-03-14 14:56:32 -06:00
< button class = "btn btn-secondary" onclick = "cancelOnboarding()" > Choose Different Drive< / button >
< / div >
< / div >
2026-05-14 16:38:02 -06:00
<!-- 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 >
2026-03-14 14:56:32 -06:00
<!-- 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 %}
2025-12-11 18:32:55 -07:00
< div class = "info-banner" >
Primary drive not mounted. Some features may be limited.
< / div >
{% endif %}
2026-04-11 20:09:30 -06:00
{% 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 %}
2026-04-07 20:01:49 -06:00
{% 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 >
2025-12-11 18:32:55 -07:00
< 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 >
2026-07-23 19:47:51 -06:00
< p class = "sub" > {% if cpu.temperature_c %}{{ cpu.temperature_c }}° C{% else %}--{% endif %}{% if cpu.fan_rpm is not none %} · {{ cpu.fan_rpm }} RPM{% endif %}< / p >
2025-12-11 18:32:55 -07:00
< / 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 >
2025-12-24 15:45:53 -07:00
< div class = "status-card" >
< h3 > IO Wait< / h3 >
2025-12-24 16:43:37 -07:00
< p class = "value" id = "iowait-value" > {% if iostat and iostat.percent_iowait is not none %}{{ iostat.percent_iowait }}{% else %}--{% endif %}%< / p >
2025-12-24 15:45:53 -07:00
< p class = "sub" > disk waiting< / p >
< / div >
2025-12-26 10:05:13 -07:00
< div class = "status-card" >
< h3 > Uptime< / h3 >
< p class = "value" id = "uptime-value" > {{ uptime.formatted|default('--') }}< / p >
< p class = "sub" > since boot< / p >
< / div >
2025-12-11 18:32:55 -07:00
< / div >
< / section >
< section id = "actions" >
< h2 > System Actions< / h2 >
2026-03-28 10:09:58 -06:00
{% if not docker_mode %}
< div class = "action-buttons" style = "margin-bottom: 1rem;" >
2026-07-29 18:21:03 -06:00
< button id = "hotspot-btn" class = "btn" onclick = "toggleSubsystem('hotspot')" disabled > Loading...< / button >
< button id = "bluetooth-btn" class = "btn" onclick = "toggleSubsystem('bluetooth')" disabled > Loading...< / button >
< button id = "desktop-btn" class = "btn" onclick = "toggleSubsystem('desktop')" disabled > Loading...< / button >
< button id = "vnc-btn" class = "btn" onclick = "toggleSubsystem('vnc')" disabled > Loading...< / button >
2026-03-28 10:09:58 -06:00
< / div >
2026-07-04 08:22:24 -06:00
< details style = "margin-bottom: 1rem;" >
< summary style = "cursor: pointer;" > Hotspot Settings< / summary >
< div class = "modal-form" style = "max-width: 24rem; margin-top: 0.5rem;" >
< label > SSID
< input type = "text" id = "hotspot-ssid" >
< / label >
< label > Password
< input type = "text" id = "hotspot-password" >
< / label >
< label > Device
2026-07-28 19:52:39 -06:00
< select id = "hotspot-device" onchange = "loadHotspotProtocols()"
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
< / select >
< / label >
< label > Protocol
< select id = "hotspot-protocol"
2026-07-04 08:22:24 -06:00
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
< / select >
< / label >
< button class = "btn btn-primary" onclick = "saveHotspotSettings()" > Save Hotspot Settings< / button >
< / div >
< / details >
2026-03-28 10:09:58 -06:00
{% endif %}
2025-12-11 18:32:55 -07:00
< 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 >
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
{% macro service_row(service, docker_mode, host) %}
< 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 >
{% endmacro %}
2025-12-11 18:32:55 -07:00
< section id = "services" >
< h2 > Services< / h2 >
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
{% set core = services | rejectattr('group', 'equalto', 'optional') | list %}
{% set optional = services | selectattr('group', 'equalto', 'optional') | list %}
{% set span = 3 if docker_mode else 4 %}
2025-12-11 18:32:55 -07:00
< table >
< thead >
< tr >
< th > Service< / th >
< th > Status< / th >
< th > Actions< / th >
{% if not docker_mode %}
< th > Boot< / th >
{% endif %}
< / tr >
< / thead >
< tbody >
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
{% if services %}
{% if core and optional %}
< tr class = "service-group-label" > < td colspan = "{{ span }}" > < strong > Core Services< / strong > < / td > < / tr >
{% endif %}
{% for service in core %}{{ service_row(service, docker_mode, host) }}{% endfor %}
{% if optional %}
< tr class = "service-group-label" > < td colspan = "{{ span }}" > < strong > Optional & Maintenance< / strong > < / td > < / tr >
{% for service in optional %}{{ service_row(service, docker_mode, host) }}{% endfor %}
2025-12-11 18:32:55 -07:00
{% endif %}
{% else %}
< tr >
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
< td colspan = "{{ span }}" style = "text-align: center; color: #666;" >
2025-12-11 18:32:55 -07:00
Service status not yet implemented
< / td >
< / tr >
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
{% endif %}
2025-12-11 18:32:55 -07:00
< / 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 >
2026-02-25 15:32:43 -07:00
{% 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 %}
2025-12-11 18:32:55 -07:00
< / main >
<!-- Logs Modal -->
< div id = "logs-modal" class = "modal" onclick = "if(event.target===this)closeLogsModal()" >
2025-12-25 09:29:01 -07:00
< div class = "modal-content" style = "width: 90%; max-width: none;" >
2025-12-11 18:32:55 -07:00
< div class = "modal-header" >
< h3 id = "logs-title" > Service Logs< / h3 >
< button class = "modal-close" onclick = "closeLogsModal()" > × < / button >
< / div >
< div class = "modal-body" >
< pre id = "logs-content" > < span class = "loading" > < / span > Loading logs...< / pre >
< / div >
2025-12-15 16:53:24 -07:00
< 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 >
2025-12-11 18:32:55 -07:00
< / 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()" > × < / 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()" > × < / button >
< / div >
< div class = "modal-body" >
< div id = "smart-modal-content" >
< span class = "loading" > < / span > Loading...
< / div >
< / div >
< / div >
< / div >
2026-02-25 15:32:43 -07:00
<!-- 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()" > × < / 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()" > × < / 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 >
2025-12-11 18:32:55 -07:00
< script >
/* ===== INLINE JAVASCRIPT - No external files ===== */
2026-04-07 20:01:49 -06:00
// 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/';
2026-07-18 13:40:23 -06:00
// Skip cert check if user permanently hid the banner, or if the Root CA
// is not ready yet (drive not mounted / repair still running / ca.crt missing).
// Advertising download before then 404s and can serve the wrong state.
{% if hide_cert_banner or not cert_download_ready %}return;{% endif %}
2026-04-07 20:01:49 -06:00
// 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); });
})();
2025-12-11 18:32:55 -07:00
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}`);
}
}
2026-07-29 18:21:03 -06:00
// --- Hardware toggle buttons ---
// The Hotspot, Bluetooth, Desktop and VNC buttons all behave the same way: read a
// status endpoint, label the button from it, and POST start/stop when clicked.
const TOGGLE_BUTTONS = {
hotspot: {
id: 'hotspot-btn', label: 'Hotspot',
statusUrl: 'api/hotspot/status', startUrl: 'api/hotspot/start', stopUrl: 'api/hotspot/stop',
isOn: data => data.enabled,
},
bluetooth: {
id: 'bluetooth-btn', label: 'Bluetooth',
statusUrl: 'api/bluetooth/status', startUrl: 'api/bluetooth/unblock', stopUrl: 'api/bluetooth/block',
isOn: data => data.enabled,
startText: 'Enable Bluetooth', stopText: 'Disable Bluetooth',
},
desktop: {
id: 'desktop-btn', label: 'Desktop',
statusUrl: 'api/desktop/status', startUrl: 'api/desktop/start', stopUrl: 'api/desktop/stop',
isOn: data => data.running,
confirmStop: 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session'
+ ' and the display will drop to a terminal. The desktop will return on the next reboot.',
// Starting or stopping the desktop changes whether VNC can run.
alsoRefresh: ['vnc'],
},
vnc: {
id: 'vnc-btn', label: 'VNC',
statusUrl: 'api/vnc/status', startUrl: 'api/vnc/start', stopUrl: 'api/vnc/stop',
isOn: data => data.running,
// VNC serves the desktop session, so it cannot be started without one.
canStart: data => data.can_start,
onTitle: data => data.desktop_running ? '' : 'The Desktop is stopped, so VNC clients see nothing',
},
};
const toggleStates = {};
async function updateToggleButton(name) {
const config = TOGGLE_BUTTONS[name];
const btn = document.getElementById(config.id);
2026-03-28 10:09:58 -06:00
if (!btn) return;
2026-07-29 18:21:03 -06:00
const startText = config.startText || `Start ${config.label}`;
const stopText = config.stopText || `Stop ${config.label}`;
2026-03-28 10:09:58 -06:00
try {
2026-07-29 18:21:03 -06:00
const data = await apiCall(config.statusUrl);
toggleStates[name] = config.isOn(data);
btn.title = '';
2026-03-28 10:09:58 -06:00
if (!data.available) {
2026-07-29 18:21:03 -06:00
btn.textContent = `${config.label} Unavailable`;
2026-03-28 10:09:58 -06:00
btn.disabled = true;
btn.className = 'btn';
2026-07-29 18:21:03 -06:00
btn.title = data.reason || '';
} else if (toggleStates[name]) {
btn.textContent = stopText;
2026-03-28 10:09:58 -06:00
btn.disabled = false;
btn.className = 'btn btn-warning';
2026-07-29 18:21:03 -06:00
if (config.onTitle) btn.title = config.onTitle(data);
} else if (config.canStart & & !config.canStart(data)) {
// Installed and stopped, but something else must happen first.
btn.textContent = startText;
btn.disabled = true;
btn.className = 'btn';
btn.title = data.reason || '';
2026-03-28 10:09:58 -06:00
} else {
2026-07-29 18:21:03 -06:00
btn.textContent = startText;
2026-03-28 10:09:58 -06:00
btn.disabled = false;
btn.className = 'btn btn-primary';
}
} catch (e) {
2026-07-29 18:21:03 -06:00
btn.textContent = `${config.label} Unavailable`;
2026-03-28 10:09:58 -06:00
btn.disabled = true;
}
}
2026-07-29 18:21:03 -06:00
async function refreshToggleButtons() {
await Promise.all(Object.keys(TOGGLE_BUTTONS).map(name => updateToggleButton(name)));
}
async function toggleSubsystem(name) {
const config = TOGGLE_BUTTONS[name];
const btn = document.getElementById(config.id);
const on = toggleStates[name];
if (on & & config.confirmStop & & !confirm(config.confirmStop)) {
return;
}
2026-03-28 10:09:58 -06:00
btn.disabled = true;
btn.innerHTML = '< span class = "loading" > < / span > ';
try {
2026-07-29 18:21:03 -06:00
await apiCall(on ? config.stopUrl : config.startUrl, 'POST');
2026-03-28 10:09:58 -06:00
} catch (e) {
2026-07-29 18:21:03 -06:00
alert(`${config.label} error: ${e.message}`);
}
await updateToggleButton(name);
for (const other of config.alsoRefresh || []) {
await updateToggleButton(other);
2026-03-28 10:09:58 -06:00
}
}
2026-07-04 08:22:24 -06:00
async function loadHotspotSettings() {
const ssidInput = document.getElementById('hotspot-ssid');
if (!ssidInput) return;
try {
const [settings, devices] = await Promise.all([
apiCall('api/hotspot/settings'),
apiCall('api/hotspot/devices'),
]);
ssidInput.value = settings.ssid;
document.getElementById('hotspot-password').value = settings.password;
const select = document.getElementById('hotspot-device');
select.innerHTML = '';
// Keep the saved device selectable even if it is not currently present.
const names = [...new Set([...(devices.devices || []), settings.device])].filter(Boolean);
for (const name of names) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
option.selected = name === settings.device;
select.appendChild(option);
}
2026-07-28 19:52:39 -06:00
await loadHotspotProtocols(settings.protocol);
2026-07-04 08:22:24 -06:00
} catch (e) {
console.error('Failed to load hotspot settings', e);
}
}
2026-07-28 19:52:39 -06:00
// Human labels for the hotspot protocols reported by the Controller.
const hotspotProtocolLabels = {wpa2: 'WPA2 (most compatible)', wpa3: 'WPA3'};
async function loadHotspotProtocols(savedProtocol) {
// The supported protocols depend on the selected device's hardware.
const select = document.getElementById('hotspot-protocol');
const device = document.getElementById('hotspot-device').value;
if (!select || !device) return;
savedProtocol = savedProtocol || select.value;
try {
const response = await apiCall(`api/hotspot/protocols?device=${encodeURIComponent(device)}`);
2026-07-28 20:59:10 -06:00
// Ignore out-of-order responses when the user switches devices quickly.
if (document.getElementById('hotspot-device').value !== device) return;
2026-07-28 19:52:39 -06:00
select.innerHTML = '';
2026-07-28 20:59:10 -06:00
// Keep the saved protocol only when probing found nothing (e.g. the saved
// device is unplugged); a successful probe is authoritative.
const supported = (response.protocols || []).filter(Boolean);
const protocols = supported.length ? supported : [savedProtocol].filter(Boolean);
const selected = protocols.includes(savedProtocol) ? savedProtocol : protocols[0];
2026-07-28 19:52:39 -06:00
for (const protocol of protocols) {
const option = document.createElement('option');
option.value = protocol;
option.textContent = hotspotProtocolLabels[protocol] || protocol;
2026-07-28 20:59:10 -06:00
option.selected = protocol === selected;
2026-07-28 19:52:39 -06:00
select.appendChild(option);
}
} catch (e) {
console.error('Failed to load hotspot protocols', e);
}
}
2026-07-04 08:22:24 -06:00
async function saveHotspotSettings() {
const btn = event.target;
btn.disabled = true;
try {
await apiCall('api/hotspot/settings', 'POST', {
ssid: document.getElementById('hotspot-ssid').value,
password: document.getElementById('hotspot-password').value,
device: document.getElementById('hotspot-device').value,
2026-07-28 19:52:39 -06:00
protocol: document.getElementById('hotspot-protocol').value || null,
2026-07-04 08:22:24 -06:00
});
2026-07-29 18:21:03 -06:00
await updateToggleButton('hotspot');
2026-07-04 08:22:24 -06:00
} catch (e) {
alert(`Failed to save hotspot settings: ${e.message}`);
}
btn.disabled = false;
}
2025-12-11 18:32:55 -07:00
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 =====
2025-12-15 16:53:24 -07:00
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) {
2025-12-11 18:32:55 -07:00
const modal = document.getElementById('logs-modal');
const title = document.getElementById('logs-title');
const content = document.getElementById('logs-content');
2025-12-15 16:53:24 -07:00
const modalBody = content.closest('.modal-body');
const linesSelect = document.getElementById('logs-lines-select');
currentLogsService = serviceName;
const lines = linesSelect.value;
2025-12-11 18:32:55 -07:00
title.textContent = `${serviceName} Logs`;
2025-12-15 16:53:24 -07:00
if (showLoading) {
content.innerHTML = '< span class = "loading" > < / span > Loading logs...';
}
2025-12-11 18:32:55 -07:00
modal.classList.add('show');
try {
2025-12-15 16:53:24 -07:00
const data = await apiCall(`api/services/${serviceName}/logs?lines=${lines}`);
2025-12-11 18:32:55 -07:00
content.textContent = data.logs || 'No logs available';
2025-12-15 16:53:24 -07:00
// Scroll modal-body to bottom of logs
modalBody.scrollTop = modalBody.scrollHeight;
// Start countdown after logs loaded
startLogsCountdown();
2025-12-11 18:32:55 -07:00
} catch (e) {
content.textContent = `Error loading logs: ${e.message}`;
}
}
2025-12-15 16:53:24 -07:00
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);
}
2025-12-11 18:32:55 -07:00
function closeLogsModal() {
2025-12-15 16:53:24 -07:00
stopLogsCountdown();
2025-12-11 18:32:55 -07:00
document.getElementById('logs-modal').classList.remove('show');
}
// Close modals on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeLogsModal();
closeMountModal();
closeSmartModal();
2026-02-25 15:32:43 -07:00
closeScriptConfirmModal();
closeScriptOutputModal();
2025-12-11 18:32:55 -07:00
}
});
// ===== 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));
2025-12-16 15:06:41 -07:00
// 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);
});
2025-12-11 18:32:55 -07:00
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 >
`;
2026-07-23 19:09:23 -06:00
// The primary mount can be unmounted (to swap in a backup
// drive); doing so stops the API service. Mounts outside
// /media (/, /boot/efi, ...) are system mounts: untouchable.
2026-07-05 14:50:56 -06:00
const isPrimary = disk.mountpoint === '/media/wrolpi';
const isSystemMount = mounted & & !isPrimary & & !disk.mountpoint.startsWith('/media/');
2025-12-11 18:32:55 -07:00
2025-12-16 15:06:41 -07:00
// 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
2026-07-05 14:50:56 -06:00
if ((mounted || isPersistent) & & !isSystemMount) {
2025-12-16 15:06:41 -07:00
const mountPoint = mounted ? disk.mountpoint : persistentEntry.mount_point;
2025-12-11 18:32:55 -07:00
html += `
< label class = "toggle" >
< input type = "checkbox"
${isPersistent ? 'checked' : ''}
2025-12-16 15:06:41 -07:00
onchange="togglePersist('${disk.path}', '${mountPoint}', '${disk.fstype}', this.checked)">
2025-12-11 18:32:55 -07:00
< 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
2026-07-23 19:09:23 -06:00
if (mounted & & isSystemMount) {
2025-12-11 18:32:55 -07:00
html += `< span style = "color: #888;" > System< / span > `;
2026-07-05 14:50:56 -06:00
} else if (mounted) {
html += `< button class = "btn btn-danger" onclick = "unmountDisk('${disk.mountpoint}')" > Unmount< / button > `;
2025-12-11 18:32:55 -07:00
} 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) {
2026-07-23 19:09:23 -06:00
const message = mountPoint === '/media/wrolpi' ?
`Unmount ${mountPoint}?\n\nWARNING: This is the primary WROLPi drive. ` +
`The WROLPi API service will be STOPPED before unmounting, and your media ` +
`will be unavailable until a drive is mounted at /media/wrolpi and the API ` +
`service is started again.` :
`Unmount ${mountPoint}?`;
if (!confirm(message)) return;
2025-12-11 18:32:55 -07:00
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) {
2026-06-24 15:59:34 -06:00
const assessmentClass = getHealthClass(drive.health || drive.assessment);
2026-06-26 07:23:15 -06:00
const assessmentText = (drive.health || drive.assessment || 'Unknown')
+ (drive.smart_limited ? ' (limited)' : '');
2025-12-11 18:32:55 -07:00
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`;
2026-06-24 15:59:34 -06:00
const assessmentClass = getHealthClass(drive.health || drive.assessment);
2025-12-11 18:32:55 -07:00
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 >
2026-06-26 07:23:15 -06:00
< td class = "${assessmentClass}" > ${drive.assessment || drive.health || 'Unknown'}${drive.smart_limited ? ' (limited — USB enclosure reports health only)' : ''}< / td >
2025-12-11 18:32:55 -07:00
< / 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 >
2026-06-24 15:59:34 -06:00
< 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 >
2025-12-11 18:32:55 -07:00
< 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();
2026-02-25 15:32:43 -07:00
// ===== SCRIPTS MANAGEMENT =====
2025-12-11 18:32:55 -07:00
2026-02-25 15:32:43 -07:00
let scriptsData = [];
let pendingScriptName = null;
let scriptStatusInterval = null;
let scriptOutputCountdown = 10;
let scriptOutputInterval = null;
let currentScriptOutput = null;
2025-12-11 18:32:55 -07:00
2026-02-25 15:32:43 -07:00
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;
2026-03-29 18:58:31 -06:00
// Hide onboarding while a script is running
const onboardingEl = document.getElementById('onboarding-banner');
if (onboardingEl) {
onboardingEl.style.display = 'none';
}
2026-02-25 15:32:43 -07:00
// Disable run buttons while script is running
document.querySelectorAll('.script-row .btn').forEach(btn => {
btn.disabled = true;
2025-12-11 18:32:55 -07:00
});
2026-02-25 15:32:43 -07:00
} else {
// Hide banner
banner.classList.remove('show');
currentScriptOutput = null;
2026-03-29 18:58:31 -06:00
// Re-check if onboarding is still needed (e.g. after repair finishes)
const onboardingEl = document.getElementById('onboarding-banner');
if (onboardingEl) {
loadOnboardingCandidates();
}
2026-02-25 15:32:43 -07:00
// 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('');
2026-07-09 15:29:01 -06:00
document.getElementById('script-confirm-warnings').style.display = script.warnings.length ? '' : 'none';
2026-02-25 15:32:43 -07:00
// 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';
2025-12-11 18:32:55 -07:00
}
}
2026-02-25 15:32:43 -07:00
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');
}
2025-12-11 18:32:55 -07:00
2026-02-25 15:32:43 -07:00
function closeScriptConfirmModal() {
document.getElementById('script-confirm-modal').classList.remove('show');
pendingScriptName = null;
}
2025-12-11 18:32:55 -07:00
2026-02-25 15:32:43 -07:00
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';
2026-03-01 12:40:56 -07:00
const isRepair = pendingScriptName === 'repair';
2026-02-25 15:32:43 -07:00
// 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;
2025-12-11 18:32:55 -07:00
}
2026-02-25 15:32:43 -07:00
});
if (Object.keys(params).length > 0) {
body = { params: params };
2025-12-11 18:32:55 -07:00
}
2026-02-25 15:32:43 -07:00
}
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;
}
2026-02-28 15:01:55 -07:00
// For repair script, redirect to fallback mode
2026-03-01 12:40:56 -07:00
if (isRepair) {
2026-02-28 15:01:55 -07:00
window.location.href = `?fallback=true`;
return;
}
2026-02-25 15:32:43 -07:00
// Immediately check status to show banner
await checkScriptStatus();
2025-12-11 18:32:55 -07:00
2026-02-25 15:32:43 -07:00
// 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;
}
2025-12-11 18:32:55 -07:00
}
2026-02-25 15:32:43 -07:00
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');
}
2026-03-14 14:56:32 -06:00
// ===== ONBOARDING =====
let onboardingDevicePath = null;
let onboardingFstype = null;
function showOnboardingStep(step) {
2026-05-14 16:38:02 -06:00
['select', 'confirm', 'shadowed', 'progress', 'complete', 'error'].forEach(s => {
2026-03-14 14:56:32 -06:00
const el = document.getElementById('onboarding-' + s);
if (el) el.style.display = (s === step) ? 'block' : 'none';
});
}
2026-05-14 16:38:02 -06:00
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]}`;
}
2026-03-14 14:56:32 -06:00
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 = '';
2026-03-29 18:58:31 -06:00
// Re-show the onboarding banner (may have been hidden while a script was running)
document.getElementById('onboarding-banner').style.display = '';
2026-03-14 14:56:32 -06:00
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');
}
}
2026-05-14 16:38:02 -06:00
async function commitOnboarding(opts) {
opts = opts || {};
const forceShadowed = !!opts.force_shadowed;
2026-03-14 14:56:32 -06:00
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,
2026-05-14 16:38:02 -06:00
force_shadowed: forceShadowed,
2026-03-14 14:56:32 -06:00
}),
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();
2026-05-14 16:38:02 -06:00
// 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;
}
2026-03-14 14:56:32 -06:00
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');
}
}
2026-05-14 16:38:02 -06:00
function confirmShadowedMount() {
commitOnboarding({force_shadowed: true});
}
2026-03-14 14:56:32 -06:00
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();
}
2026-03-29 18:58:31 -06:00
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');
}
}
2026-03-14 14:56:32 -06:00
function resetOnboarding() {
cancelOnboarding();
}
2026-02-25 15:32:43 -07:00
// ===== 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);
}
2026-03-14 14:56:32 -06:00
// ===== 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);
}
2026-02-25 15:32:43 -07:00
// ===== 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 =====
2026-04-07 20:01:49 -06:00
// When ?upgrade=true, poll for completion and redirect to main UI when ready.
2026-02-25 15:32:43 -07:00
try {
if (isUpgradeMode) {
2026-04-09 15:22:27 -06:00
// 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';
2026-04-13 15:14:54 -06:00
currentScriptOutput = 'upgrade';
2026-04-09 15:22:27 -06:00
}
2026-03-07 19:40:35 -07:00
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}`;
}
2026-04-07 20:01:49 -06:00
2026-03-28 10:16:55 -06:00
// 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';
2026-02-25 15:32:43 -07:00
const checkUpgradeComplete = async () => {
try {
2026-03-09 16:11:44 -06:00
const resp = await fetch('api/ready', {
2026-02-25 15:32:43 -07:00
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
2026-03-08 16:05:56 -06:00
const data = await resp.json();
2026-03-28 10:16:55 -06:00
if (!upgradeServicesWentDown) {
if (!data.api || !data.app) {
2026-04-07 20:01:49 -06:00
upgradeServicesWentDown = true;
sessionStorage.setItem('upgradeServicesWentDown', 'true');
2026-03-28 10:16:55 -06:00
}
} else if (data.api & & data.app) {
sessionStorage.removeItem('upgradeServicesWentDown');
2026-03-08 16:05:56 -06:00
setTimeout(() => {
window.location.href = `${upgradeHttpsBase}/`;
}, 2000);
}
2026-03-28 10:16:55 -06:00
} else {
2026-04-07 20:01:49 -06:00
upgradeServicesWentDown = true;
sessionStorage.setItem('upgradeServicesWentDown', 'true');
2026-02-25 15:32:43 -07:00
}
} catch (e) {
2026-04-07 20:01:49 -06:00
upgradeServicesWentDown = true;
sessionStorage.setItem('upgradeServicesWentDown', 'true');
2026-02-25 15:32:43 -07:00
}
};
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');
2026-03-08 16:05:56 -06:00
// Poll controller's readiness endpoint (same-origin, avoids CORS/cert issues).
2026-02-25 15:32:43 -07:00
const checkMainApp = async () => {
try {
2026-03-09 16:11:44 -06:00
// 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', {
2026-03-08 16:05:56 -06:00
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
2026-03-07 19:17:38 -07:00
});
2026-03-08 16:05:56 -06:00
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}/`;
}
2026-02-25 15:32:43 -07:00
}
} 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);
}
2025-12-11 18:32:55 -07:00
// ===== PERIODIC UPDATES =====
2026-02-25 15:32:43 -07:00
// 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);
}
2025-12-11 18:32:55 -07:00
2025-12-26 10:05:13 -07:00
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`;
}
}
2025-12-11 18:32:55 -07:00
async function updateSystemStatus() {
try {
// Fetch all stats endpoints in parallel
2025-12-26 10:05:13 -07:00
const [cpu, memory, load, primaryDrive, iostat, uptime] = await Promise.all([
2025-12-11 18:32:55 -07:00
apiCall('api/stats/cpu'),
apiCall('api/stats/memory'),
apiCall('api/stats/load'),
apiCall('api/stats/drives/primary'),
2025-12-24 15:45:53 -07:00
apiCall('api/stats/iostat'),
2025-12-26 10:05:13 -07:00
apiCall('api/stats/uptime'),
2025-12-11 18:32:55 -07:00
]);
// 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 : '--') + '%';
2026-07-23 19:42:52 -06:00
if (cpuSub) {
let cpuSubText = cpu.temperature ? cpu.temperature + '° C' : '--';
2026-07-23 19:47:51 -06:00
if (cpu.fan_rpm !== null & & cpu.fan_rpm !== undefined) {
2026-07-23 19:42:52 -06:00
cpuSubText += ' · ' + cpu.fan_rpm + ' RPM';
}
cpuSub.innerHTML = cpuSubText;
}
2025-12-11 18:32:55 -07:00
// 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';
}
2025-12-24 15:45:53 -07:00
// 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';
}
}
2025-12-26 10:05:13 -07:00
// Update Uptime card
const uptimeValue = document.getElementById('uptime-value');
if (uptimeValue) {
uptimeValue.textContent = formatUptime(uptime.uptime_seconds);
}
2025-12-11 18:32:55 -07:00
} 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');
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
const colspan = isDockerMode ? 3 : 4;
2025-12-11 18:32:55 -07:00
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
function serviceRowHtml(service) {
let row = `< tr >
2025-12-11 18:32:55 -07:00
< 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') {
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += `< button class = "btn btn-danger" onclick = "serviceAction('${service.name}', 'stop')" > Stop< / button > `;
2025-12-11 18:32:55 -07:00
} else {
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += `< button class = "btn btn-primary" onclick = "serviceAction('${service.name}', 'start')" > Start< / button > `;
2025-12-11 18:32:55 -07:00
}
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += `< button class = "btn btn-warning" onclick = "serviceAction('${service.name}', 'restart')" > Restart< / button >
2025-12-15 16:53:24 -07:00
< button class = "btn btn-info" onclick = "showLogs('${service.name}', true)" > Logs< / button > `;
2025-12-11 18:32:55 -07:00
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 || '';
2026-04-07 20:01:49 -06:00
const isDefaultPort = (protocol === 'https' & & service.port === 443) || (protocol === 'http' & & service.port === 80);
const portSuffix = isDefaultPort ? '' : ':' + service.port;
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += `< a href = "${protocol}://${host}${portSuffix}${viewPath}" target = "_blank" class = "btn btn-violet" > View< / a > `;
2025-12-11 18:32:55 -07:00
}
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += '< / td > ';
2025-12-11 18:32:55 -07:00
if (!isDockerMode) {
if (service.name === 'wrolpi-upgrade') {
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += `< td > < span style = "color: #666;" > --< / span > < / td > `;
2025-12-11 18:32:55 -07:00
} else {
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += `< td >
2025-12-11 18:32:55 -07:00
< 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 > `;
}
}
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
row += '< / tr > ';
return row;
}
// Sort problems first, then alphabetically, and split into the two
// Controller groups (Core vs Optional & Maintenance).
const statusOrder = {failed: 0, unknown: 1, stopped: 2, running: 3};
const byStatusThenName = (a, b) => {
const sa = statusOrder[a.status] ?? 1;
const sb = statusOrder[b.status] ?? 1;
if (sa !== sb) return sa - sb;
return a.name.localeCompare(b.name);
};
const core = services.filter(s => s.group !== 'optional').sort(byStatusThenName);
const optional = services.filter(s => s.group === 'optional').sort(byStatusThenName);
const labelRow = (title) => `< tr class = "service-group-label" > < td colspan = "${colspan}" > < strong > ${title}< / strong > < / td > < / tr > `;
let html = '';
if (core.length & & optional.length) {
html += labelRow('Core Services');
}
html += core.map(serviceRowHtml).join('');
if (optional.length) {
html += labelRow('Optional & Maintenance');
html += optional.map(serviceRowHtml).join('');
2025-12-11 18:32:55 -07:00
}
if (services.length === 0) {
html = `< tr > < td colspan = "${colspan}" style = "text-align: center; color: #666;" > No services< / td > < / tr > `;
}
tbody.innerHTML = html;
2026-04-07 20:01:49 -06:00
// 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';
}
}
2025-12-11 18:32:55 -07:00
} catch (e) {
console.error('Failed to update services:', e);
}
}
function startPeriodicUpdates() {
// Update every 10 seconds
const updateInterval = 10000;
2026-03-28 10:09:58 -06:00
// Fetch hardware button status immediately
2026-07-29 18:21:03 -06:00
refreshToggleButtons();
2026-07-04 08:22:24 -06:00
loadHotspotSettings();
2026-03-28 10:09:58 -06:00
2025-12-11 18:32:55 -07:00
// 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();
2026-07-29 18:21:03 -06:00
refreshToggleButtons();
2025-12-11 18:32:55 -07:00
}, updateInterval);
}
< / script >
< / body >
< / html >