mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
* Surface coarse SMART health for USB-bridge drives USB enclosures that reject ATA pass-through (e.g. Seagate Expansion) are invisible to pySMART/DeviceList, so the drive shows no SMART at all on the Controller page. Fall back to smartctl over the SCSI transport, which many such bridges still answer, to report a coarse PASS/FAIL health plus drive identity (model/serial/capacity). The health (-d scsi -H) and identity (-d scsi -i) reads are issued as separate calls: combining them makes smartctl suppress the health line when the identity read reports "SMART support is: Unavailable" (which these bridges do). The entry is marked smart_limited (labelled "(limited)" in both UIs) so a green PASS is not mistaken for full monitoring — these drives expose no attributes or temperature, and some bridges report "OK" statically. * Harden SCSI health fallback (Greptile review) - _scsi_health: treat any unrecognised status (e.g. "UNKNOWN", "N/A") as unreadable (None) instead of a false FAIL. Only "OK" -> PASS and "FAIL*" -> FAIL; anything else omits the drive rather than showing a red FAIL that contradicts the smart_limited disclaimer. - get_all_smart_status: isolate the SCSI supplement loop in its own try/except so an error there cannot discard the pySMART results already collected. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2783 lines
102 KiB
HTML
2783 lines
102 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Controller - {{ host }} WROLPi</title>
|
|
<style>
|
|
/* ===== INLINE CSS - No external files ===== */
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
line-height: 1.6;
|
|
padding: 1rem;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
background: #16213e;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 1.5rem;
|
|
color: #4ecca3;
|
|
}
|
|
|
|
.version {
|
|
font-size: 0.8rem;
|
|
color: #666;
|
|
}
|
|
|
|
.warning-banner {
|
|
background: #ff6b6b;
|
|
color: #fff;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 4px;
|
|
margin-bottom: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.info-banner {
|
|
background: #0f3460;
|
|
color: #fff;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 4px;
|
|
margin-bottom: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Onboarding */
|
|
.onboarding-banner {
|
|
background: linear-gradient(135deg, #4ecca3, #2ecc71);
|
|
color: #000;
|
|
padding: 1.5rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.onboarding-banner h2 {
|
|
margin: 0 0 0.5rem 0;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.onboarding-banner p {
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.drive-cards {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
justify-content: center;
|
|
margin: 1rem 0;
|
|
}
|
|
|
|
.drive-card {
|
|
background: rgba(0, 0, 0, 0.15);
|
|
border: 2px solid rgba(0, 0, 0, 0.2);
|
|
border-radius: 8px;
|
|
padding: 1rem 1.5rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
min-width: 220px;
|
|
max-width: 300px;
|
|
transition: border-color 0.2s, background 0.2s;
|
|
}
|
|
|
|
.drive-card:hover {
|
|
border-color: #16213e;
|
|
background: rgba(0, 0, 0, 0.25);
|
|
}
|
|
|
|
.drive-card .drive-name {
|
|
font-weight: bold;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.drive-card .drive-detail {
|
|
font-size: 0.85rem;
|
|
opacity: 0.8;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.onboarding-banner .btn {
|
|
display: inline-block;
|
|
padding: 0.5rem 1.5rem;
|
|
border-radius: 4px;
|
|
border: none;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
margin: 0.25rem;
|
|
}
|
|
|
|
.onboarding-banner .btn-primary {
|
|
background: #16213e;
|
|
color: #fff;
|
|
}
|
|
|
|
.onboarding-banner .btn-primary:hover {
|
|
background: #1a2a4e;
|
|
}
|
|
|
|
.onboarding-banner .btn-secondary {
|
|
background: rgba(0, 0, 0, 0.15);
|
|
color: #000;
|
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.onboarding-banner .btn-secondary:hover {
|
|
background: rgba(0, 0, 0, 0.25);
|
|
}
|
|
|
|
.onboarding-banner .error-msg {
|
|
color: #c0392b;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.onboarding-banner .warning-msg {
|
|
background: rgba(0, 0, 0, 0.1);
|
|
padding: 0.75rem;
|
|
border-radius: 4px;
|
|
margin: 0.75rem 0;
|
|
}
|
|
|
|
.fallback-banner {
|
|
background: #f39c12;
|
|
color: #000;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 4px;
|
|
margin-bottom: 1rem;
|
|
text-align: center;
|
|
display: none;
|
|
}
|
|
|
|
.fallback-banner.show {
|
|
display: block;
|
|
}
|
|
|
|
.fallback-banner .loading {
|
|
border-color: #333;
|
|
border-top-color: #000;
|
|
}
|
|
|
|
/* Status Grid */
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.status-card {
|
|
background: #16213e;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.status-card h3 {
|
|
font-size: 0.85rem;
|
|
color: #888;
|
|
margin-bottom: 0.5rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.status-card .value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: #4ecca3;
|
|
}
|
|
|
|
.status-card .sub {
|
|
font-size: 0.8rem;
|
|
color: #666;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
/* Tables */
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: #16213e;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
th, td {
|
|
padding: 0.75rem 1rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid #0f3460;
|
|
}
|
|
|
|
th {
|
|
background: #0f3460;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.8rem;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
tr:hover {
|
|
background: #1a3a5c;
|
|
}
|
|
|
|
/* Status indicators */
|
|
.status-running {
|
|
color: #4ecca3;
|
|
}
|
|
|
|
.status-stopped {
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.status-unknown {
|
|
color: #ffd93d;
|
|
}
|
|
|
|
/* SMART health status */
|
|
.health-pass {
|
|
color: #4ecca3;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.health-warn {
|
|
color: #ffd93d;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.health-fail {
|
|
color: #ff6b6b;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.health-unknown {
|
|
color: #888;
|
|
}
|
|
|
|
/* Drive health cards */
|
|
.health-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.health-card {
|
|
background: #16213e;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.health-card h4 {
|
|
margin: 0 0 0.5rem 0;
|
|
font-size: 1rem;
|
|
color: #ccc;
|
|
}
|
|
|
|
.health-card .assessment {
|
|
font-size: 1.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.health-card .temp {
|
|
font-size: 0.9rem;
|
|
color: #888;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
display: inline-block;
|
|
padding: 0.4rem 0.8rem;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
margin-right: 0.25rem;
|
|
text-decoration: none;
|
|
transition: opacity 0.2s, transform 0.1s;
|
|
}
|
|
|
|
.btn:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.btn:active {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #4ecca3;
|
|
color: #000;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #ff6b6b;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-warning {
|
|
background: #ffd93d;
|
|
color: #000;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #0f3460;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-info {
|
|
background: #3498db;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-violet {
|
|
background: #9b59b6;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-success {
|
|
background: #27ae60;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
z-index: 1000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal.show {
|
|
display: flex;
|
|
}
|
|
|
|
.modal-content {
|
|
background: #16213e;
|
|
border-radius: 8px;
|
|
width: 90%;
|
|
max-width: 800px;
|
|
max-height: 80vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-bottom: 1px solid #0f3460;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
margin: 0;
|
|
color: #4ecca3;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 1rem;
|
|
overflow: auto;
|
|
flex: 1;
|
|
}
|
|
|
|
.modal-body pre {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
font-size: 0.85rem;
|
|
line-height: 1.4;
|
|
color: #ccc;
|
|
}
|
|
|
|
/* Mount modal form */
|
|
.modal-form label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
color: #ccc;
|
|
}
|
|
|
|
.modal-form input[type="text"] {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
background: #1a1a2e;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.modal-form input[type="text"]:focus {
|
|
outline: none;
|
|
border-color: #4ecca3;
|
|
}
|
|
|
|
.modal-form .checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.modal-form input[type="checkbox"] {
|
|
width: 1.1rem;
|
|
height: 1.1rem;
|
|
accent-color: #4ecca3;
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid #0f3460;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.logs-select {
|
|
background: #1a1a2e;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
padding: 0.4rem 0.8rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Sections */
|
|
section {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
section h2 {
|
|
font-size: 1.1rem;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid #333;
|
|
color: #ccc;
|
|
}
|
|
|
|
/* Action buttons section */
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Loading spinner */
|
|
.loading {
|
|
display: inline-block;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border: 2px solid #333;
|
|
border-top-color: #4ecca3;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Toggle Switch */
|
|
.toggle {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 44px;
|
|
height: 24px;
|
|
}
|
|
|
|
.toggle input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #555;
|
|
transition: 0.3s;
|
|
border-radius: 24px;
|
|
}
|
|
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 18px;
|
|
width: 18px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background-color: #fff;
|
|
transition: 0.3s;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider {
|
|
background-color: #4ecca3;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider:before {
|
|
transform: translateX(20px);
|
|
}
|
|
|
|
.toggle input:disabled + .toggle-slider {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.toggle-label {
|
|
margin-left: 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: #888;
|
|
}
|
|
|
|
/* Scripts section */
|
|
.scripts-list {
|
|
background: #16213e;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.script-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 1px solid #0f3460;
|
|
}
|
|
|
|
.script-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.script-row:hover {
|
|
background: #1a3a5c;
|
|
}
|
|
|
|
.script-info h4 {
|
|
margin: 0 0 0.25rem 0;
|
|
color: #4ecca3;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.script-info p {
|
|
color: #aaa;
|
|
font-size: 0.85rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.script-row.unavailable {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.script-row.unavailable .btn {
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.script-running-banner {
|
|
background: linear-gradient(135deg, #3498db, #2980b9);
|
|
color: #fff;
|
|
padding: 1.5rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
text-align: center;
|
|
display: none;
|
|
}
|
|
|
|
.script-running-banner.show {
|
|
display: block;
|
|
}
|
|
|
|
.script-running-banner h2 {
|
|
margin: 0 0 0.5rem 0;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.script-running-banner .loading {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-color: rgba(255,255,255,0.3);
|
|
border-top-color: #fff;
|
|
margin: 0.5rem auto;
|
|
}
|
|
|
|
.script-running-banner .actions {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.warning-list {
|
|
background: #1a1a2e;
|
|
border: 1px solid #ff6b6b;
|
|
border-radius: 4px;
|
|
padding: 1rem;
|
|
margin: 1rem 0;
|
|
}
|
|
|
|
.warning-list h4 {
|
|
color: #ff6b6b;
|
|
margin: 0 0 0.75rem 0;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.warning-list ul {
|
|
margin: 0;
|
|
padding-left: 1.25rem;
|
|
color: #ccc;
|
|
}
|
|
|
|
.warning-list li {
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.warning-list li:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 600px) {
|
|
header {
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
text-align: center;
|
|
}
|
|
|
|
nav {
|
|
justify-content: center;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.35rem 0.6rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.status-card .value {
|
|
font-size: 1.5rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{% if docker_mode %}
|
|
<div class="warning-banner">
|
|
Running in Docker mode. Disk mounting, hotspot, Bluetooth, Samba, and shutdown are not available.
|
|
</div>
|
|
{% endif %}
|
|
|
|
<header>
|
|
<div>
|
|
<h1>Controller - {{ host }} WROLPi</h1>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Certificate trust banner — shown by JS when HTTPS cert is not trusted -->
|
|
<div id="cert-banner" class="card" style="display: none; background: #16213e; border: 1px solid #1a3a5c; border-radius: 12px; padding: 1.5rem; margin-bottom: 1rem;">
|
|
<h2 style="font-size: 1.1rem; margin-bottom: 0.75rem; color: #4cc9f0;">HTTPS Certificate Setup</h2>
|
|
<p style="line-height: 1.6; color: #ccc; margin-bottom: 1rem;">
|
|
Install the WROLPi Root CA certificate so your browser trusts all WROLPi HTTPS services.
|
|
</p>
|
|
<a class="btn btn-info" href="/ca.crt" download="wrolpi-ca.crt" style="margin-bottom: 1rem; display: inline-block;">Download WROLPi Root CA</a>
|
|
|
|
<details style="margin-bottom: 0.75rem;">
|
|
<summary style="cursor: pointer; padding: 0.5rem 0; font-weight: 600; color: #4cc9f0;">iOS / iPadOS</summary>
|
|
<div style="padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7;">
|
|
<ol style="padding-left: 1.25rem;">
|
|
<li>Tap the download button above, then tap <strong>Allow</strong>.</li>
|
|
<li>Open <strong>Settings</strong> → <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>
|
|
|
|
{% if not drive_mounted and not docker_mode %}
|
|
<div id="onboarding-banner" class="onboarding-banner">
|
|
<!-- Step 1: Select drive -->
|
|
<div id="onboarding-select" style="display: block;">
|
|
<h2>Welcome to WROLPi</h2>
|
|
<p>Select your primary WROLPi drive to get started.</p>
|
|
<div id="onboarding-drives" class="drive-cards">
|
|
<span class="loading"></span>
|
|
</div>
|
|
<p id="onboarding-no-drives" style="display: none;">No unmounted drives detected. Plug in a USB drive and refresh.</p>
|
|
<div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.2);">
|
|
<p style="margin-bottom: 0.5rem; font-size: 0.9em; opacity: 0.8;">No external drive? You can still set up WROLPi using internal storage.</p>
|
|
<button class="btn btn-secondary" onclick="repairWithoutDrive()">Run repair without external drive</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Confirm -->
|
|
<div id="onboarding-confirm" style="display: none;">
|
|
<h2>Confirm Drive Setup</h2>
|
|
<div id="onboarding-config-found" style="display: none;">
|
|
<p>Found WROLPi configuration on this drive.</p>
|
|
<p id="onboarding-mounts-info"></p>
|
|
<button class="btn btn-primary" onclick="commitOnboarding({})">Set Up This Drive</button>
|
|
<button class="btn btn-secondary" onclick="cancelOnboarding()">Choose Different Drive</button>
|
|
</div>
|
|
<div id="onboarding-no-config" style="display: none;">
|
|
<div class="warning-msg">
|
|
<p><strong>No WROLPi configuration found on this drive.</strong></p>
|
|
<p>This may be a new drive or the wrong drive. You can still use it as a fresh WROLPi drive.</p>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="commitOnboarding({})">Use Anyway</button>
|
|
<button class="btn btn-secondary" onclick="cancelOnboarding()">Choose Different Drive</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2b: Shadowed-data warning -->
|
|
<div id="onboarding-shadowed" style="display: none;">
|
|
<h2>Existing Files Detected</h2>
|
|
<div class="warning-msg">
|
|
<p><strong>Mounting this drive would hide existing files at the mount point.</strong></p>
|
|
<p id="onboarding-shadowed-msg"></p>
|
|
<p>If you mount anyway, those files will continue to consume space on the underlying filesystem (typically the SD card on a Raspberry Pi) and can fill it up.</p>
|
|
<p>Recommended: cancel, move or delete the existing files, then mount.</p>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="confirmShadowedMount()">Mount Anyway</button>
|
|
<button class="btn btn-secondary" onclick="cancelOnboarding()">Cancel</button>
|
|
</div>
|
|
|
|
<!-- Step 3: Progress -->
|
|
<div id="onboarding-progress" style="display: none;">
|
|
<h2>Setting Up Drives...</h2>
|
|
<span class="loading"></span>
|
|
<p>Mounting drives and starting repair. Please wait...</p>
|
|
</div>
|
|
|
|
<!-- Step 4: Complete -->
|
|
<div id="onboarding-complete" style="display: none;">
|
|
<h2>Setup Complete!</h2>
|
|
<p id="onboarding-complete-msg">Repair is running. The main UI will be available shortly.</p>
|
|
<p>You will be redirected automatically, or you can continue using this page.</p>
|
|
</div>
|
|
|
|
<!-- Step 5: Error -->
|
|
<div id="onboarding-error" style="display: none;">
|
|
<h2>Setup Failed</h2>
|
|
<p class="error-msg" id="onboarding-error-msg"></p>
|
|
<button class="btn btn-primary" onclick="resetOnboarding()">Try Again</button>
|
|
</div>
|
|
</div>
|
|
{% elif not drive_mounted %}
|
|
<div class="info-banner">
|
|
Primary drive not mounted. Some features may be limited.
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if not docker_mode %}
|
|
<div id="script-running-banner" class="script-running-banner">
|
|
<h2 id="script-running-name">Script Running</h2>
|
|
<span class="loading"></span>
|
|
<div class="actions">
|
|
<button class="btn btn-info" onclick="showScriptOutput()">View Output</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% set app_ready = services | selectattr('name', 'in', ['caddy', 'web']) | selectattr('status', 'equalto', 'running') | list | length > 0 %}
|
|
<div style="margin-bottom: 1rem;">
|
|
<a id="proceed-btn" class="btn btn-success" style="{% if not app_ready %}display: none;{% endif %} font-size: 1.1rem; padding: 0.6rem 1.5rem;" href="#">Proceed to WROLPi</a>
|
|
<a id="view-files-btn" class="btn btn-info" style="{% if not app_ready %}display: none;{% endif %} font-size: 1.1rem; padding: 0.6rem 1.5rem; margin-left: 0.5rem;" href="#">View Files</a>
|
|
</div>
|
|
|
|
<div id="fallback-banner" class="fallback-banner">
|
|
<span class="loading"></span>
|
|
Main UI is unavailable. Using Controller fallback. Will redirect when ready...
|
|
</div>
|
|
|
|
<main>
|
|
<section id="status">
|
|
<h2>System Status</h2>
|
|
<div class="status-grid">
|
|
<div class="status-card">
|
|
<h3>CPU</h3>
|
|
<p class="value">{{ cpu.percent if cpu.percent is not none else '--' }}%</p>
|
|
<p class="sub">{% if cpu.temperature_c %}{{ cpu.temperature_c }}°C{% else %}--{% endif %}</p>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Memory</h3>
|
|
<p class="value">{{ memory.percent|default('--') }}%</p>
|
|
<p class="sub">{{ memory.used_gb|default('--') }} GB used</p>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Load</h3>
|
|
<p class="value">{{ load.load_1min|default('--') }}</p>
|
|
<p class="sub">1 min avg</p>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Storage</h3>
|
|
<p class="value">{{ storage.percent|default('--') }}%</p>
|
|
<p class="sub">{{ storage.free_gb|default('--') }} GB free</p>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>IO Wait</h3>
|
|
<p class="value" id="iowait-value">{% if iostat and iostat.percent_iowait is not none %}{{ iostat.percent_iowait }}{% else %}--{% endif %}%</p>
|
|
<p class="sub">disk waiting</p>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Uptime</h3>
|
|
<p class="value" id="uptime-value">{{ uptime.formatted|default('--') }}</p>
|
|
<p class="sub">since boot</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="actions">
|
|
<h2>System Actions</h2>
|
|
{% if not docker_mode %}
|
|
<div class="action-buttons" style="margin-bottom: 1rem;">
|
|
<button id="hotspot-btn" class="btn" onclick="toggleHotspot()" disabled>Loading...</button>
|
|
<button id="bluetooth-btn" class="btn" onclick="toggleBluetooth()" disabled>Loading...</button>
|
|
</div>
|
|
{% endif %}
|
|
<div class="action-buttons">
|
|
<button class="btn btn-warning" onclick="restartServices()">Restart All Services</button>
|
|
{% if not docker_mode %}
|
|
<button class="btn btn-danger" onclick="rebootSystem()">Reboot System</button>
|
|
<button class="btn btn-danger" onclick="shutdownSystem()">Shutdown System</button>
|
|
{% endif %}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="services">
|
|
<h2>Services</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Service</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
{% if not docker_mode %}
|
|
<th>Boot</th>
|
|
{% endif %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for service in services %}
|
|
<tr>
|
|
<td>
|
|
{{ service.name }}
|
|
{% if service.port %}<span class="sub">:{{ service.port }}</span>{% endif %}
|
|
</td>
|
|
<td class="status-{{ service.status }}">{{ service.status }}</td>
|
|
<td>
|
|
{% if service.status == 'running' %}
|
|
<button class="btn btn-danger" onclick="serviceAction('{{ service.name }}', 'stop')">Stop</button>
|
|
{% else %}
|
|
<button class="btn btn-primary" onclick="serviceAction('{{ service.name }}', 'start')">Start
|
|
</button>
|
|
{% endif %}
|
|
<button class="btn btn-warning" onclick="serviceAction('{{ service.name }}', 'restart')">Restart
|
|
</button>
|
|
<button class="btn btn-info" onclick="showLogs('{{ service.name }}', true)">Logs</button>
|
|
{% if service.viewable and service.status == 'running' and service.port %}
|
|
{% set is_default_port = (service.use_https and service.port == 443) or (not service.use_https and service.port == 80) %}
|
|
<a href="{{ 'https' if service.use_https else 'http' }}://{{ host }}{% if not is_default_port %}:{{ service.port }}{% endif %}{{ service.view_path|default('') }}"
|
|
target="_blank" class="btn btn-violet">View</a>
|
|
{% endif %}
|
|
</td>
|
|
{% if not docker_mode %}
|
|
<td>
|
|
{% if service.name == 'wrolpi-upgrade' %}
|
|
<span style="color: #666;">--</span>
|
|
{% else %}
|
|
<label class="toggle">
|
|
<input type="checkbox"
|
|
id="boot-{{ service.name }}"
|
|
{% if service.enabled %}checked{% endif %}
|
|
onchange="toggleBoot('{{ service.name }}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span class="toggle-label" id="boot-label-{{ service.name }}">
|
|
{{ 'Enabled' if service.enabled else 'Disabled' }}
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
{% endif %}
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="{% if docker_mode %}3{% else %}4{% endif %}" style="text-align: center; color: #666;">
|
|
Service status not yet implemented
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section id="disks">
|
|
<h2>Disks</h2>
|
|
<div id="disks-content">
|
|
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="drive-health">
|
|
<h2>Drive Health (SMART)</h2>
|
|
<div id="smart-content">
|
|
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
|
|
</div>
|
|
</section>
|
|
|
|
{% if not docker_mode %}
|
|
<section id="scripts">
|
|
<h2>Maintenance Scripts</h2>
|
|
<div id="scripts-content">
|
|
<p style="color: #888; text-align: center;"><span class="loading"></span> Loading...</p>
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
</main>
|
|
|
|
<!-- Logs Modal -->
|
|
<div id="logs-modal" class="modal" onclick="if(event.target===this)closeLogsModal()">
|
|
<div class="modal-content" style="width: 90%; max-width: none;">
|
|
<div class="modal-header">
|
|
<h3 id="logs-title">Service Logs</h3>
|
|
<button class="modal-close" onclick="closeLogsModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<pre id="logs-content"><span class="loading"></span> Loading logs...</pre>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<span id="logs-countdown" style="color: #888; margin-right: 0.5rem;">10s</span>
|
|
<select id="logs-lines-select" class="logs-select" onchange="changeLogLines()">
|
|
<option value="100">100 lines</option>
|
|
<option value="250" selected>250 lines</option>
|
|
<option value="500">500 lines</option>
|
|
<option value="1000">1000 lines</option>
|
|
<option value="5000">5000 lines</option>
|
|
</select>
|
|
<button class="btn btn-info" onclick="refreshLogs()">Refresh</button>
|
|
<button class="btn btn-warning" onclick="downloadLogs()">Download</button>
|
|
<button class="btn btn-secondary" onclick="closeLogsModal()">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mount Modal -->
|
|
<div id="mount-modal" class="modal" onclick="if(event.target===this)closeMountModal()">
|
|
<div class="modal-content" style="max-width: 400px;">
|
|
<div class="modal-header">
|
|
<h3>Mount Disk</h3>
|
|
<button class="modal-close" onclick="closeMountModal()">×</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>
|
|
|
|
<!-- 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>
|
|
|
|
<script>
|
|
/* ===== INLINE JAVASCRIPT - No external files ===== */
|
|
|
|
// Certificate trust detection.
|
|
(function() {
|
|
var host = window.location.hostname;
|
|
var port = window.location.port;
|
|
// Docker: 8080 → 8443; native: 80/empty → 443 (default).
|
|
var httpsPort = (port === '8080') ? ':8443' : '';
|
|
var httpsBase = 'https://' + host + httpsPort;
|
|
|
|
// Set the "Continue to WROLPi" link.
|
|
var continueBtn = document.getElementById('cert-continue-btn');
|
|
if (continueBtn) continueBtn.href = httpsBase;
|
|
|
|
// Set the "Proceed to WROLPi" and "View Files" links.
|
|
var proceedBtn = document.getElementById('proceed-btn');
|
|
if (proceedBtn) proceedBtn.href = httpsBase;
|
|
var viewFilesBtn = document.getElementById('view-files-btn');
|
|
if (viewFilesBtn) viewFilesBtn.href = httpsBase + '/media/';
|
|
|
|
// Skip cert check if user has permanently hidden the banner.
|
|
{% if hide_cert_banner %}return;{% endif %}
|
|
|
|
// Probe the HTTPS endpoint; show the cert banner if it fails.
|
|
var controller = new AbortController();
|
|
var timeout = setTimeout(function() { controller.abort(); }, 3000);
|
|
fetch(httpsBase + '/api/echo', { signal: controller.signal })
|
|
.then(function(r) {
|
|
// Cert is trusted — keep banner hidden.
|
|
})
|
|
.catch(function() {
|
|
// Cert not trusted or HTTPS unreachable — show the banner.
|
|
var banner = document.getElementById('cert-banner');
|
|
if (banner) banner.style.display = 'block';
|
|
})
|
|
.finally(function() { clearTimeout(timeout); });
|
|
})();
|
|
|
|
async function apiCall(url, method = 'GET', body = null) {
|
|
try {
|
|
const options = {method};
|
|
if (body) {
|
|
options.headers = {'Content-Type': 'application/json'};
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
const resp = await fetch(url, options);
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.detail || `HTTP ${resp.status}`);
|
|
}
|
|
return await resp.json();
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function serviceAction(name, action) {
|
|
const btn = event.target;
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
|
|
try {
|
|
await apiCall(`api/services/${name}/${action}`, 'POST');
|
|
// Reload page to show updated status
|
|
setTimeout(() => location.reload(), 1000);
|
|
} catch (e) {
|
|
alert(`Failed to ${action} ${name}: ${e.message}`);
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
async function toggleBoot(name, enabled) {
|
|
const checkbox = document.getElementById(`boot-${name}`);
|
|
const label = document.getElementById(`boot-label-${name}`);
|
|
const action = enabled ? 'enable' : 'disable';
|
|
|
|
// Disable checkbox while processing
|
|
checkbox.disabled = true;
|
|
|
|
try {
|
|
await apiCall(`api/services/${name}/${action}`, 'POST');
|
|
// Update label text
|
|
label.textContent = enabled ? 'Enabled' : 'Disabled';
|
|
} catch (e) {
|
|
// Revert checkbox on failure
|
|
checkbox.checked = !enabled;
|
|
alert(`Failed to ${action} ${name}: ${e.message}`);
|
|
} finally {
|
|
checkbox.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function restartServices() {
|
|
if (!confirm('This will restart all WROLPi services. Continue?')) return;
|
|
|
|
document.body.innerHTML = `
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;">
|
|
<span class="loading" style="width:3rem;height:3rem;margin-bottom:1rem;"></span>
|
|
<h1 style="color:#4ecca3;">Restarting services...</h1>
|
|
<p style="color:#888;">Please wait, this page will reload automatically.</p>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
await apiCall('api/restart', 'POST');
|
|
} catch (e) {
|
|
// Expected - connection may drop
|
|
}
|
|
|
|
// Poll until Controller is back
|
|
await waitForController();
|
|
location.reload();
|
|
}
|
|
|
|
async function rebootSystem() {
|
|
if (!confirm('This will REBOOT the entire system. Continue?')) return;
|
|
|
|
try {
|
|
await apiCall('api/reboot', 'POST');
|
|
document.body.innerHTML = `
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;">
|
|
<h1 style="color:#ffd93d;">Rebooting...</h1>
|
|
<p style="color:#888;">The system is rebooting. This page will reload when ready.</p>
|
|
</div>
|
|
`;
|
|
|
|
// Wait then poll
|
|
setTimeout(async () => {
|
|
await waitForController(120000);
|
|
location.reload();
|
|
}, 10000);
|
|
} catch (e) {
|
|
alert(`Failed to reboot: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function shutdownSystem() {
|
|
if (!confirm('This will SHUT DOWN the system. You will need physical access to turn it back on. Continue?')) return;
|
|
|
|
try {
|
|
await apiCall('api/shutdown', 'POST');
|
|
document.body.innerHTML = `
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;">
|
|
<h1 style="color:#ff6b6b;">Shutting down...</h1>
|
|
<p style="color:#888;">The system is shutting down.</p>
|
|
</div>
|
|
`;
|
|
} catch (e) {
|
|
alert(`Failed to shutdown: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// --- Hotspot ---
|
|
let hotspotEnabled = null;
|
|
|
|
async function updateHotspotButton() {
|
|
const btn = document.getElementById('hotspot-btn');
|
|
if (!btn) return;
|
|
try {
|
|
const data = await apiCall('api/hotspot/status');
|
|
hotspotEnabled = data.enabled;
|
|
if (!data.available) {
|
|
btn.textContent = 'Hotspot Unavailable';
|
|
btn.disabled = true;
|
|
btn.className = 'btn';
|
|
} else if (data.enabled) {
|
|
btn.textContent = 'Disable Hotspot';
|
|
btn.disabled = false;
|
|
btn.className = 'btn btn-warning';
|
|
} else {
|
|
btn.textContent = 'Enable Hotspot';
|
|
btn.disabled = false;
|
|
btn.className = 'btn btn-primary';
|
|
}
|
|
} catch (e) {
|
|
btn.textContent = 'Hotspot Unavailable';
|
|
btn.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function toggleHotspot() {
|
|
const btn = document.getElementById('hotspot-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
try {
|
|
if (hotspotEnabled) {
|
|
await apiCall('api/hotspot/disable', 'POST');
|
|
} else {
|
|
await apiCall('api/hotspot/enable', 'POST');
|
|
}
|
|
} catch (e) {
|
|
alert(`Hotspot error: ${e.message}`);
|
|
}
|
|
await updateHotspotButton();
|
|
}
|
|
|
|
// --- Bluetooth ---
|
|
let bluetoothEnabled = null;
|
|
|
|
async function updateBluetoothButton() {
|
|
const btn = document.getElementById('bluetooth-btn');
|
|
if (!btn) return;
|
|
try {
|
|
const data = await apiCall('api/bluetooth/status');
|
|
bluetoothEnabled = data.enabled;
|
|
if (!data.available) {
|
|
btn.textContent = 'Bluetooth Unavailable';
|
|
btn.disabled = true;
|
|
btn.className = 'btn';
|
|
} else if (data.enabled) {
|
|
btn.textContent = 'Disable Bluetooth';
|
|
btn.disabled = false;
|
|
btn.className = 'btn btn-warning';
|
|
} else {
|
|
btn.textContent = 'Enable Bluetooth';
|
|
btn.disabled = false;
|
|
btn.className = 'btn btn-primary';
|
|
}
|
|
} catch (e) {
|
|
btn.textContent = 'Bluetooth Unavailable';
|
|
btn.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function toggleBluetooth() {
|
|
const btn = document.getElementById('bluetooth-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
try {
|
|
if (bluetoothEnabled) {
|
|
await apiCall('api/bluetooth/disable', 'POST');
|
|
} else {
|
|
await apiCall('api/bluetooth/enable', 'POST');
|
|
}
|
|
} catch (e) {
|
|
alert(`Bluetooth error: ${e.message}`);
|
|
}
|
|
await updateBluetoothButton();
|
|
}
|
|
|
|
async function waitForController(maxWaitMs = 60000) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < maxWaitMs) {
|
|
try {
|
|
const resp = await fetch('api/health');
|
|
if (resp.ok) return;
|
|
} catch (e) {
|
|
// Still waiting
|
|
}
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
}
|
|
throw new Error('Controller did not respond in time');
|
|
}
|
|
|
|
// ===== LOGS MODAL =====
|
|
|
|
let currentLogsService = null;
|
|
let logsCountdown = 10;
|
|
let logsCountdownInterval = null;
|
|
|
|
function startLogsCountdown() {
|
|
if (logsCountdownInterval) clearInterval(logsCountdownInterval);
|
|
logsCountdown = 10;
|
|
document.getElementById('logs-countdown').textContent = '10s';
|
|
|
|
logsCountdownInterval = setInterval(() => {
|
|
logsCountdown--;
|
|
document.getElementById('logs-countdown').textContent = logsCountdown + 's';
|
|
|
|
if (logsCountdown <= 0) {
|
|
// Check if scrolled to bottom (within 50px threshold)
|
|
const modalBody = document.getElementById('logs-content').closest('.modal-body');
|
|
const isAtBottom = modalBody.scrollHeight - modalBody.scrollTop - modalBody.clientHeight < 50;
|
|
|
|
if (isAtBottom && currentLogsService) {
|
|
showLogs(currentLogsService);
|
|
}
|
|
logsCountdown = 10;
|
|
document.getElementById('logs-countdown').textContent = '10s';
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopLogsCountdown() {
|
|
if (logsCountdownInterval) {
|
|
clearInterval(logsCountdownInterval);
|
|
logsCountdownInterval = null;
|
|
}
|
|
}
|
|
|
|
async function showLogs(serviceName, showLoading = false) {
|
|
const modal = document.getElementById('logs-modal');
|
|
const title = document.getElementById('logs-title');
|
|
const content = document.getElementById('logs-content');
|
|
const modalBody = content.closest('.modal-body');
|
|
const linesSelect = document.getElementById('logs-lines-select');
|
|
|
|
currentLogsService = serviceName;
|
|
const lines = linesSelect.value;
|
|
|
|
title.textContent = `${serviceName} Logs`;
|
|
if (showLoading) {
|
|
content.innerHTML = '<span class="loading"></span> Loading logs...';
|
|
}
|
|
modal.classList.add('show');
|
|
|
|
try {
|
|
const data = await apiCall(`api/services/${serviceName}/logs?lines=${lines}`);
|
|
content.textContent = data.logs || 'No logs available';
|
|
// Scroll modal-body to bottom of logs
|
|
modalBody.scrollTop = modalBody.scrollHeight;
|
|
// Start countdown after logs loaded
|
|
startLogsCountdown();
|
|
} catch (e) {
|
|
content.textContent = `Error loading logs: ${e.message}`;
|
|
}
|
|
}
|
|
|
|
function changeLogLines() {
|
|
if (currentLogsService) {
|
|
showLogs(currentLogsService);
|
|
}
|
|
}
|
|
|
|
function refreshLogs() {
|
|
if (currentLogsService) {
|
|
showLogs(currentLogsService);
|
|
}
|
|
}
|
|
|
|
function downloadLogs() {
|
|
const content = document.getElementById('logs-content').textContent;
|
|
const datetime = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const filename = `${currentLogsService}_${datetime}.txt`;
|
|
const blob = new Blob([content], {type: 'text/plain'});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function closeLogsModal() {
|
|
stopLogsCountdown();
|
|
document.getElementById('logs-modal').classList.remove('show');
|
|
}
|
|
|
|
// Close modals on Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeLogsModal();
|
|
closeMountModal();
|
|
closeSmartModal();
|
|
closeScriptConfirmModal();
|
|
closeScriptOutputModal();
|
|
}
|
|
});
|
|
|
|
// ===== DISK MANAGEMENT =====
|
|
|
|
function formatSize(bytes) {
|
|
if (!bytes) return '--';
|
|
// If already a string (e.g., "59.5G" from lsblk), return as-is
|
|
if (typeof bytes === 'string') return bytes;
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
while (bytes >= 1024 && i < units.length - 1) {
|
|
bytes /= 1024;
|
|
i++;
|
|
}
|
|
return `${bytes.toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
async function loadDisks() {
|
|
const container = document.getElementById('disks-content');
|
|
try {
|
|
// Fetch disks and fstab entries in parallel
|
|
const [disks, fstabEntries] = await Promise.all([
|
|
apiCall('api/disks'),
|
|
apiCall('api/disks/fstab').catch(() => []) // fstab may fail, that's ok
|
|
]);
|
|
|
|
if (disks.length === 0) {
|
|
container.innerHTML = '<p style="color: #888; text-align: center;">No disks detected</p>';
|
|
return;
|
|
}
|
|
|
|
// Build set of persistent mount points for quick lookup
|
|
const persistentMounts = new Set(fstabEntries.map(e => e.mount_point));
|
|
// Build map of device (UUID) to fstab entry for unmounted drive lookup
|
|
const persistentByDevice = new Map();
|
|
fstabEntries.forEach(e => {
|
|
// e.device is like "UUID=1234-5678" or "/dev/sda1"
|
|
persistentByDevice.set(e.device, e);
|
|
});
|
|
|
|
let html = `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Label</th>
|
|
<th>Mount Point</th>
|
|
<th>Persist</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
for (const disk of disks) {
|
|
const mounted = disk.mountpoint && disk.mountpoint !== '';
|
|
const mountClass = mounted ? 'status-running' : 'status-stopped';
|
|
const mountText = mounted ? disk.mountpoint : '--';
|
|
|
|
html += `
|
|
<tr>
|
|
<td>${disk.name}</td>
|
|
<td>${formatSize(disk.size)}</td>
|
|
<td>${disk.fstype || '--'}</td>
|
|
<td>${disk.label || '--'}</td>
|
|
<td class="${mountClass}">${mountText}</td>
|
|
<td>
|
|
`;
|
|
|
|
const protectedMounts = ['/', '/boot', '/boot/firmware', '/media/wrolpi'];
|
|
const isProtected = protectedMounts.includes(disk.mountpoint);
|
|
|
|
// Check if this disk has a persistent entry (by mount point or UUID)
|
|
let isPersistent = false;
|
|
let persistentEntry = null;
|
|
|
|
if (mounted) {
|
|
isPersistent = persistentMounts.has(disk.mountpoint);
|
|
} else if (disk.uuid) {
|
|
// Check if unmounted drive has fstab entry by UUID
|
|
persistentEntry = persistentByDevice.get(`UUID=${disk.uuid}`);
|
|
isPersistent = !!persistentEntry;
|
|
}
|
|
|
|
// Persist column - show for mounted drives OR unmounted persistent drives
|
|
if ((mounted || isPersistent) && !isProtected) {
|
|
const mountPoint = mounted ? disk.mountpoint : persistentEntry.mount_point;
|
|
html += `
|
|
<label class="toggle">
|
|
<input type="checkbox"
|
|
${isPersistent ? 'checked' : ''}
|
|
onchange="togglePersist('${disk.path}', '${mountPoint}', '${disk.fstype}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span class="toggle-label">${isPersistent ? 'Enabled' : 'Disabled'}</span>
|
|
`;
|
|
} else {
|
|
html += '<span style="color: #666;">--</span>';
|
|
}
|
|
|
|
html += `</td><td>`;
|
|
|
|
// Actions column
|
|
if (mounted && !isProtected) {
|
|
html += `<button class="btn btn-danger" onclick="unmountDisk('${disk.mountpoint}')">Unmount</button>`;
|
|
} else if (mounted && isProtected) {
|
|
html += `<span style="color: #888;">System</span>`;
|
|
} else {
|
|
const defaultMount = disk.label ? `/media/${disk.label}` : `/media/${disk.name}`;
|
|
html += `<button class="btn btn-primary" onclick="mountDisk('${disk.path}', '${defaultMount}', '${disk.fstype || ''}')">Mount</button>`;
|
|
}
|
|
|
|
html += `
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
if (e.message.includes('501')) {
|
|
container.innerHTML = '<p style="color: #888; text-align: center;">Disk management not available in Docker mode</p>';
|
|
} else {
|
|
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading disks: ${e.message}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mount modal state
|
|
let pendingMountDevice = null;
|
|
let pendingMountFstype = null;
|
|
|
|
function mountDisk(device, defaultMount, fstype) {
|
|
pendingMountDevice = device;
|
|
pendingMountFstype = fstype;
|
|
document.getElementById('mount-point-input').value = defaultMount;
|
|
document.getElementById('mount-persist-checkbox').checked = false;
|
|
document.getElementById('mount-modal').classList.add('show');
|
|
document.getElementById('mount-point-input').focus();
|
|
}
|
|
|
|
function closeMountModal() {
|
|
document.getElementById('mount-modal').classList.remove('show');
|
|
pendingMountDevice = null;
|
|
pendingMountFstype = null;
|
|
}
|
|
|
|
async function confirmMount() {
|
|
const mountPoint = document.getElementById('mount-point-input').value.trim();
|
|
const persist = document.getElementById('mount-persist-checkbox').checked;
|
|
|
|
if (!mountPoint) {
|
|
alert('Mount point is required');
|
|
return;
|
|
}
|
|
|
|
const btn = document.querySelector('#mount-modal .btn-primary');
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
|
|
try {
|
|
await apiCall('api/disks/mount', 'POST', {
|
|
device: pendingMountDevice,
|
|
mount_point: mountPoint,
|
|
fstype: pendingMountFstype || null,
|
|
persist: persist
|
|
});
|
|
closeMountModal();
|
|
setTimeout(() => location.reload(), 500);
|
|
} catch (e) {
|
|
alert(`Failed to mount: ${e.message}`);
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
async function unmountDisk(mountPoint) {
|
|
if (!confirm(`Unmount ${mountPoint}?`)) return;
|
|
|
|
try {
|
|
await apiCall('api/disks/unmount', 'POST', {
|
|
mount_point: mountPoint
|
|
});
|
|
setTimeout(() => location.reload(), 500);
|
|
} catch (e) {
|
|
alert(`Failed to unmount: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function togglePersist(device, mountPoint, fstype, enablePersist) {
|
|
const checkbox = event.target;
|
|
const label = checkbox.closest('td').querySelector('.toggle-label');
|
|
checkbox.disabled = true;
|
|
|
|
try {
|
|
if (enablePersist) {
|
|
// Add to fstab
|
|
await apiCall('api/disks/fstab', 'POST', {
|
|
device: device,
|
|
mount_point: mountPoint,
|
|
fstype: fstype || 'auto'
|
|
});
|
|
if (label) label.textContent = 'Enabled';
|
|
} else {
|
|
// Remove from fstab
|
|
const encodedPath = encodeURIComponent(mountPoint.slice(1));
|
|
await apiCall(`api/disks/fstab/${encodedPath}`, 'DELETE');
|
|
if (label) label.textContent = 'Disabled';
|
|
}
|
|
checkbox.disabled = false;
|
|
} catch (e) {
|
|
alert(`Failed to update persistence: ${e.message}`);
|
|
// Revert checkbox state on failure
|
|
checkbox.checked = !enablePersist;
|
|
checkbox.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Load disks on page load
|
|
loadDisks();
|
|
|
|
// ===== SMART DRIVE HEALTH =====
|
|
|
|
// Store SMART data for modal display
|
|
let smartData = [];
|
|
|
|
async function loadSmart() {
|
|
const container = document.getElementById('smart-content');
|
|
try {
|
|
const response = await apiCall('api/disks/smart');
|
|
|
|
if (!response.available) {
|
|
container.innerHTML = `<p style="color: #888; text-align: center;">${response.reason || 'SMART monitoring not available'}</p>`;
|
|
return;
|
|
}
|
|
|
|
const drives = response.drives || [];
|
|
smartData = drives;
|
|
|
|
if (drives.length === 0) {
|
|
container.innerHTML = '<p style="color: #888; text-align: center;">No SMART-capable drives detected</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="health-grid">';
|
|
|
|
for (const drive of drives) {
|
|
const assessmentClass = getHealthClass(drive.health || drive.assessment);
|
|
const assessmentText = (drive.health || drive.assessment || 'Unknown')
|
|
+ (drive.smart_limited ? ' (limited)' : '');
|
|
const tempText = drive.temperature !== null ? `${drive.temperature}°C` : '--';
|
|
|
|
html += `
|
|
<div class="health-card">
|
|
<h4>${drive.device}</h4>
|
|
<p class="assessment ${assessmentClass}">${assessmentText}</p>
|
|
<p class="temp">${tempText}</p>
|
|
<button class="btn btn-sm btn-secondary" onclick="showSmartDetails('${drive.device}')">Details</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
if (e.message.includes('501')) {
|
|
container.innerHTML = '<p style="color: #888; text-align: center;">SMART monitoring not available in Docker mode</p>';
|
|
} else {
|
|
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading SMART data: ${e.message}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getHealthClass(assessment) {
|
|
if (!assessment) return 'health-unknown';
|
|
const upper = assessment.toUpperCase();
|
|
if (upper === 'PASS') return 'health-pass';
|
|
if (upper === 'WARN') return 'health-warn';
|
|
if (upper === 'FAIL') return 'health-fail';
|
|
return 'health-unknown';
|
|
}
|
|
|
|
function showSmartDetails(deviceName) {
|
|
const drive = smartData.find(d => d.device === deviceName);
|
|
if (!drive) {
|
|
alert('Drive data not found');
|
|
return;
|
|
}
|
|
|
|
const modal = document.getElementById('smart-modal');
|
|
const title = document.getElementById('smart-modal-title');
|
|
const content = document.getElementById('smart-modal-content');
|
|
|
|
title.textContent = `${drive.device} - Health Details`;
|
|
|
|
const assessmentClass = getHealthClass(drive.health || drive.assessment);
|
|
const formatHours = (hours) => {
|
|
if (hours === null || hours === undefined) return '--';
|
|
const days = Math.floor(hours / 24);
|
|
const years = Math.floor(days / 365);
|
|
if (years > 0) return `${hours.toLocaleString()} hrs (${years}y ${days % 365}d)`;
|
|
if (days > 0) return `${hours.toLocaleString()} hrs (${days}d)`;
|
|
return `${hours} hrs`;
|
|
};
|
|
|
|
content.innerHTML = `
|
|
<table style="width: 100%; margin: 0;">
|
|
<tr>
|
|
<td style="color: #888; width: 40%;">Model</td>
|
|
<td>${drive.model || '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Serial</td>
|
|
<td>${drive.serial || '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Capacity</td>
|
|
<td>${drive.capacity || '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Assessment</td>
|
|
<td class="${assessmentClass}">${drive.assessment || drive.health || 'Unknown'}${drive.smart_limited ? ' (limited — USB enclosure reports health only)' : ''}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Temperature</td>
|
|
<td>${drive.temperature !== null ? drive.temperature + '°C' : '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Power-On Hours</td>
|
|
<td>${formatHours(drive.power_on_hours)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Reallocated Sectors</td>
|
|
<td class="${drive.reallocated_sectors > 0 ? 'health-warn' : ''}">${drive.reallocated_sectors !== null ? drive.reallocated_sectors : '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Pending Sectors</td>
|
|
<td class="${drive.pending_sectors > 0 ? 'health-warn' : ''}">${drive.pending_sectors !== null ? drive.pending_sectors : '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">Uncorrectable Sectors</td>
|
|
<td class="${drive.offline_uncorrectable > 0 ? 'health-warn' : ''}">${drive.offline_uncorrectable !== null && drive.offline_uncorrectable !== undefined ? drive.offline_uncorrectable : '--'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #888;">SMART Enabled</td>
|
|
<td>${drive.smart_enabled ? 'Yes' : 'No'}</td>
|
|
</tr>
|
|
</table>
|
|
`;
|
|
|
|
modal.classList.add('show');
|
|
}
|
|
|
|
function closeSmartModal() {
|
|
document.getElementById('smart-modal').classList.remove('show');
|
|
}
|
|
|
|
// Load SMART data on page load
|
|
loadSmart();
|
|
|
|
// ===== SCRIPTS MANAGEMENT =====
|
|
|
|
let scriptsData = [];
|
|
let pendingScriptName = null;
|
|
let scriptStatusInterval = null;
|
|
let scriptOutputCountdown = 10;
|
|
let scriptOutputInterval = null;
|
|
let currentScriptOutput = null;
|
|
|
|
async function loadScripts() {
|
|
const container = document.getElementById('scripts-content');
|
|
if (!container) return; // Docker mode - section doesn't exist
|
|
|
|
try {
|
|
const scripts = await apiCall('api/scripts');
|
|
scriptsData = scripts;
|
|
|
|
if (scripts.length === 0) {
|
|
container.innerHTML = '<p style="color: #888; text-align: center;">No scripts available</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="scripts-list">';
|
|
for (const script of scripts) {
|
|
const unavailableClass = script.available ? '' : 'unavailable';
|
|
const btnDisabled = script.available ? '' : 'disabled';
|
|
|
|
html += `
|
|
<div class="script-row ${unavailableClass}">
|
|
<div class="script-info">
|
|
<h4>${script.display_name}</h4>
|
|
<p>${script.description}</p>
|
|
</div>
|
|
<button class="btn btn-warning" ${btnDisabled} onclick="showScriptConfirmation('${script.name}')">Run</button>
|
|
</div>
|
|
`;
|
|
}
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
if (e.message.includes('501')) {
|
|
container.innerHTML = '<p style="color: #888; text-align: center;">Scripts not available in Docker mode</p>';
|
|
} else {
|
|
container.innerHTML = `<p style="color: #ff6b6b; text-align: center;">Error loading scripts: ${e.message}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkScriptStatus() {
|
|
const banner = document.getElementById('script-running-banner');
|
|
if (!banner) return; // Docker mode
|
|
|
|
try {
|
|
const status = await apiCall('api/scripts/status');
|
|
|
|
if (status.running) {
|
|
// Show banner
|
|
banner.classList.add('show');
|
|
const script = scriptsData.find(s => s.name === status.script_name);
|
|
document.getElementById('script-running-name').textContent =
|
|
script ? `${script.display_name} Running` : 'Script Running';
|
|
|
|
currentScriptOutput = status.script_name;
|
|
|
|
// Hide onboarding while a script is running
|
|
const onboardingEl = document.getElementById('onboarding-banner');
|
|
if (onboardingEl) {
|
|
onboardingEl.style.display = 'none';
|
|
}
|
|
|
|
// Disable run buttons while script is running
|
|
document.querySelectorAll('.script-row .btn').forEach(btn => {
|
|
btn.disabled = true;
|
|
});
|
|
} else {
|
|
// Hide banner
|
|
banner.classList.remove('show');
|
|
currentScriptOutput = null;
|
|
|
|
// Re-check if onboarding is still needed (e.g. after repair finishes)
|
|
const onboardingEl = document.getElementById('onboarding-banner');
|
|
if (onboardingEl) {
|
|
loadOnboardingCandidates();
|
|
}
|
|
|
|
// Re-enable run buttons
|
|
document.querySelectorAll('.script-row').forEach(row => {
|
|
if (!row.classList.contains('unavailable')) {
|
|
row.querySelector('.btn').disabled = false;
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check script status:', e);
|
|
}
|
|
}
|
|
|
|
async function showScriptConfirmation(name) {
|
|
const script = scriptsData.find(s => s.name === name);
|
|
if (!script) return;
|
|
|
|
pendingScriptName = name;
|
|
|
|
document.getElementById('script-confirm-title').textContent = `Run ${script.display_name}?`;
|
|
document.getElementById('script-confirm-description').textContent = script.description;
|
|
|
|
const warningsList = document.getElementById('script-warnings-list');
|
|
warningsList.innerHTML = script.warnings.map(w => `<li>${w}</li>`).join('');
|
|
|
|
// Generate parameter inputs dynamically
|
|
const paramsContainer = document.getElementById('script-params-container');
|
|
paramsContainer.innerHTML = '';
|
|
|
|
const parameters = script.parameters || [];
|
|
for (const param of parameters) {
|
|
const inputId = `script-param-${param.name}`;
|
|
let defaultValue = '';
|
|
|
|
// For branch type, fetch current branch
|
|
if (param.type === 'branch') {
|
|
try {
|
|
const data = await apiCall('api/scripts/branch');
|
|
if (data.branch) {
|
|
defaultValue = data.branch;
|
|
}
|
|
} catch (e) {
|
|
defaultValue = 'release';
|
|
}
|
|
}
|
|
|
|
const html = `
|
|
<label for="${inputId}">${param.label}:</label>
|
|
<input type="text" id="${inputId}" data-param-name="${param.name}"
|
|
value="${defaultValue}" placeholder="${param.label.toLowerCase()}" />
|
|
`;
|
|
paramsContainer.innerHTML += html;
|
|
}
|
|
|
|
document.getElementById('script-confirm-modal').classList.add('show');
|
|
}
|
|
|
|
function closeScriptConfirmModal() {
|
|
document.getElementById('script-confirm-modal').classList.remove('show');
|
|
pendingScriptName = null;
|
|
}
|
|
|
|
async function confirmRunScript() {
|
|
if (!pendingScriptName) return;
|
|
|
|
const btn = document.getElementById('script-confirm-btn');
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
|
|
const script = scriptsData.find(s => s.name === pendingScriptName);
|
|
const isUpgrade = pendingScriptName === 'upgrade';
|
|
const isRepair = pendingScriptName === 'repair';
|
|
|
|
// Collect all parameter values from dynamic inputs
|
|
let body = null;
|
|
const paramsContainer = document.getElementById('script-params-container');
|
|
const paramInputs = paramsContainer.querySelectorAll('input[data-param-name]');
|
|
|
|
if (paramInputs.length > 0) {
|
|
const params = {};
|
|
paramInputs.forEach(input => {
|
|
const value = input.value.trim();
|
|
if (value) {
|
|
params[input.dataset.paramName] = value;
|
|
}
|
|
});
|
|
if (Object.keys(params).length > 0) {
|
|
body = { params: params };
|
|
}
|
|
}
|
|
|
|
try {
|
|
await apiCall(`api/scripts/${pendingScriptName}/start`, 'POST', body);
|
|
closeScriptConfirmModal();
|
|
|
|
// For upgrade script, redirect to upgrade mode
|
|
if (isUpgrade) {
|
|
window.location.href = `?upgrade=true`;
|
|
return;
|
|
}
|
|
|
|
// For repair script, redirect to fallback mode
|
|
if (isRepair) {
|
|
window.location.href = `?fallback=true`;
|
|
return;
|
|
}
|
|
|
|
// Immediately check status to show banner
|
|
await checkScriptStatus();
|
|
|
|
// Start polling for status updates
|
|
if (!scriptStatusInterval) {
|
|
scriptStatusInterval = setInterval(checkScriptStatus, 3000);
|
|
}
|
|
} catch (e) {
|
|
alert(`Failed to start script: ${e.message}`);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
function showScriptOutput() {
|
|
const name = currentScriptOutput || (scriptsData[0] && scriptsData[0].name);
|
|
if (!name) return;
|
|
|
|
const script = scriptsData.find(s => s.name === name);
|
|
document.getElementById('script-output-title').textContent =
|
|
script ? `${script.display_name} Output` : 'Script Output';
|
|
document.getElementById('script-output-content').innerHTML =
|
|
'<span class="loading"></span> Loading output...';
|
|
|
|
document.getElementById('script-output-modal').classList.add('show');
|
|
refreshScriptOutput();
|
|
startScriptOutputCountdown();
|
|
}
|
|
|
|
function startScriptOutputCountdown() {
|
|
if (scriptOutputInterval) clearInterval(scriptOutputInterval);
|
|
scriptOutputCountdown = 10;
|
|
document.getElementById('script-output-countdown').textContent = '10s';
|
|
|
|
scriptOutputInterval = setInterval(() => {
|
|
scriptOutputCountdown--;
|
|
document.getElementById('script-output-countdown').textContent = scriptOutputCountdown + 's';
|
|
|
|
if (scriptOutputCountdown <= 0) {
|
|
// Check if scrolled to bottom
|
|
const modalBody = document.getElementById('script-output-content').closest('.modal-body');
|
|
const isAtBottom = modalBody.scrollHeight - modalBody.scrollTop - modalBody.clientHeight < 50;
|
|
|
|
if (isAtBottom) {
|
|
refreshScriptOutput();
|
|
}
|
|
scriptOutputCountdown = 10;
|
|
document.getElementById('script-output-countdown').textContent = '10s';
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopScriptOutputCountdown() {
|
|
if (scriptOutputInterval) {
|
|
clearInterval(scriptOutputInterval);
|
|
scriptOutputInterval = null;
|
|
}
|
|
}
|
|
|
|
async function refreshScriptOutput() {
|
|
const name = currentScriptOutput || (scriptsData[0] && scriptsData[0].name);
|
|
if (!name) return;
|
|
|
|
const lines = document.getElementById('script-output-lines-select').value;
|
|
const content = document.getElementById('script-output-content');
|
|
const modalBody = content.closest('.modal-body');
|
|
|
|
try {
|
|
const data = await apiCall(`api/scripts/${name}/output?lines=${lines}`);
|
|
content.textContent = data.output || 'No output available';
|
|
// Scroll to bottom
|
|
modalBody.scrollTop = modalBody.scrollHeight;
|
|
} catch (e) {
|
|
content.textContent = `Error loading output: ${e.message}`;
|
|
}
|
|
}
|
|
|
|
function changeScriptOutputLines() {
|
|
refreshScriptOutput();
|
|
}
|
|
|
|
function closeScriptOutputModal() {
|
|
stopScriptOutputCountdown();
|
|
document.getElementById('script-output-modal').classList.remove('show');
|
|
}
|
|
|
|
// ===== ONBOARDING =====
|
|
let onboardingDevicePath = null;
|
|
let onboardingFstype = null;
|
|
|
|
function showOnboardingStep(step) {
|
|
['select', 'confirm', 'shadowed', 'progress', 'complete', 'error'].forEach(s => {
|
|
const el = document.getElementById('onboarding-' + s);
|
|
if (el) el.style.display = (s === step) ? 'block' : 'none';
|
|
});
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes || bytes < 1) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
let n = bytes;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i++;
|
|
}
|
|
return `${n.toFixed(n >= 10 ? 0 : 1)} ${units[i]}`;
|
|
}
|
|
|
|
async function loadOnboardingCandidates() {
|
|
const container = document.getElementById('onboarding-drives');
|
|
const noDrives = document.getElementById('onboarding-no-drives');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const resp = await fetch('api/onboarding/candidates', {
|
|
headers: {'Accept': 'application/json'},
|
|
signal: AbortSignal.timeout(10000)
|
|
});
|
|
if (!resp.ok) {
|
|
// Drive already mounted or other precondition — hide wizard
|
|
document.getElementById('onboarding-banner').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const candidates = await resp.json();
|
|
container.innerHTML = '';
|
|
|
|
// Re-show the onboarding banner (may have been hidden while a script was running)
|
|
document.getElementById('onboarding-banner').style.display = '';
|
|
|
|
if (candidates.length === 0) {
|
|
noDrives.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
noDrives.style.display = 'none';
|
|
candidates.forEach(drive => {
|
|
const card = document.createElement('div');
|
|
card.className = 'drive-card';
|
|
card.onclick = () => probeDrive(drive.path, drive.fstype);
|
|
|
|
let details = '';
|
|
if (drive.size) details += `<div class="drive-detail">Size: ${drive.size}</div>`;
|
|
if (drive.label) details += `<div class="drive-detail">Label: ${drive.label}</div>`;
|
|
if (drive.model) details += `<div class="drive-detail">Model: ${drive.model}</div>`;
|
|
if (drive.fstype) details += `<div class="drive-detail">Type: ${drive.fstype}</div>`;
|
|
if (drive.uuid) details += `<div class="drive-detail">UUID: ${drive.uuid}</div>`;
|
|
if (drive.mountpoint) details += `<div class="drive-detail">Mounted at: ${drive.mountpoint}</div>`;
|
|
|
|
card.innerHTML = `<div class="drive-name">${drive.path}</div>${details}`;
|
|
container.appendChild(card);
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to load onboarding candidates:', e);
|
|
}
|
|
}
|
|
|
|
async function probeDrive(path, fstype) {
|
|
onboardingDevicePath = path;
|
|
onboardingFstype = fstype;
|
|
showOnboardingStep('progress');
|
|
document.querySelector('#onboarding-progress p').textContent = 'Checking drive for configuration...';
|
|
|
|
try {
|
|
const resp = await fetch('api/onboarding/probe', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
|
|
body: JSON.stringify({device_path: path, fstype: fstype}),
|
|
signal: AbortSignal.timeout(30000)
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.detail || 'Probe failed');
|
|
}
|
|
|
|
const data = await resp.json();
|
|
showOnboardingStep('confirm');
|
|
|
|
if (data.config_found) {
|
|
document.getElementById('onboarding-config-found').style.display = 'block';
|
|
document.getElementById('onboarding-no-config').style.display = 'none';
|
|
const mountCount = (data.mounts || []).length;
|
|
document.getElementById('onboarding-mounts-info').textContent =
|
|
mountCount > 0
|
|
? `Configuration includes ${mountCount} mount(s).`
|
|
: '';
|
|
} else {
|
|
document.getElementById('onboarding-config-found').style.display = 'none';
|
|
document.getElementById('onboarding-no-config').style.display = 'block';
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('onboarding-error-msg').textContent = e.message;
|
|
showOnboardingStep('error');
|
|
}
|
|
}
|
|
|
|
async function commitOnboarding(opts) {
|
|
opts = opts || {};
|
|
const forceShadowed = !!opts.force_shadowed;
|
|
|
|
showOnboardingStep('progress');
|
|
document.querySelector('#onboarding-progress p').textContent = 'Mounting drives and starting repair. Please wait...';
|
|
|
|
try {
|
|
const resp = await fetch('api/onboarding/commit', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
|
|
body: JSON.stringify({
|
|
device_path: onboardingDevicePath,
|
|
fstype: onboardingFstype,
|
|
force_shadowed: forceShadowed,
|
|
}),
|
|
signal: AbortSignal.timeout(60000)
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.detail || 'Setup failed');
|
|
}
|
|
|
|
const data = await resp.json();
|
|
|
|
// Soft-block: target contains existing data that would be shadowed.
|
|
if (data.success === false && data.needs_force === 'shadowed') {
|
|
const shadowed = data.shadowed_data || {entries: [], size_bytes: 0};
|
|
const entries = (shadowed.entries || []).join(', ') || '(unknown)';
|
|
document.getElementById('onboarding-shadowed-msg').textContent =
|
|
`Found ${entries} (${formatBytes(shadowed.size_bytes)}) at the mount target.`;
|
|
showOnboardingStep('shadowed');
|
|
return;
|
|
}
|
|
|
|
showOnboardingStep('complete');
|
|
|
|
if (data.repair_started) {
|
|
document.getElementById('onboarding-complete-msg').textContent =
|
|
'Drives mounted and repair started. The main UI will be available shortly.';
|
|
// 10s delay so user sees "Setup Complete!" before redirect
|
|
setTimeout(() => {
|
|
window.location.href = '?fallback=true';
|
|
}, 10000);
|
|
} else {
|
|
document.getElementById('onboarding-complete-msg').textContent =
|
|
'Drives mounted successfully. You may need to start repair manually.';
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('onboarding-error-msg').textContent = e.message;
|
|
showOnboardingStep('error');
|
|
}
|
|
}
|
|
|
|
function confirmShadowedMount() {
|
|
commitOnboarding({force_shadowed: true});
|
|
}
|
|
|
|
async function cancelOnboarding() {
|
|
try {
|
|
await fetch('api/onboarding/cancel', {
|
|
method: 'POST',
|
|
headers: {'Accept': 'application/json'},
|
|
signal: AbortSignal.timeout(10000)
|
|
});
|
|
} catch (e) {
|
|
// Ignore cancel errors
|
|
}
|
|
onboardingDevicePath = null;
|
|
onboardingFstype = null;
|
|
showOnboardingStep('select');
|
|
loadOnboardingCandidates();
|
|
}
|
|
|
|
async function repairWithoutDrive() {
|
|
showOnboardingStep('progress');
|
|
document.querySelector('#onboarding-progress h2').textContent = 'Running Repair...';
|
|
document.querySelector('#onboarding-progress p').textContent = 'Starting repair without external drive. Please wait...';
|
|
|
|
try {
|
|
const resp = await fetch('api/scripts/repair/start', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
|
|
signal: AbortSignal.timeout(60000)
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.detail || 'Failed to start repair');
|
|
}
|
|
|
|
showOnboardingStep('complete');
|
|
document.getElementById('onboarding-complete-msg').textContent =
|
|
'Repair started without external drive. The main UI will be available shortly.';
|
|
setTimeout(() => {
|
|
window.location.href = '?fallback=true';
|
|
}, 10000);
|
|
} catch (e) {
|
|
document.getElementById('onboarding-error-msg').textContent = e.message;
|
|
showOnboardingStep('error');
|
|
}
|
|
}
|
|
|
|
function resetOnboarding() {
|
|
cancelOnboarding();
|
|
}
|
|
|
|
// ===== INITIALIZATION =====
|
|
// Each section is wrapped in try-catch to prevent one failure from breaking the entire page
|
|
|
|
// Check URL parameters for special modes
|
|
let isFallback = false;
|
|
let isUpgradeMode = false;
|
|
let urlParams = null;
|
|
try {
|
|
urlParams = new URLSearchParams(window.location.search);
|
|
isFallback = urlParams.get('fallback') === 'true';
|
|
isUpgradeMode = urlParams.get('upgrade') === 'true';
|
|
} catch (e) {
|
|
console.error('Failed to parse URL parameters:', e);
|
|
}
|
|
|
|
// ===== ONBOARDING INIT =====
|
|
try {
|
|
const onboardingEl = document.getElementById('onboarding-banner');
|
|
if (onboardingEl && !isUpgradeMode) {
|
|
loadOnboardingCandidates();
|
|
} else if (onboardingEl && isUpgradeMode) {
|
|
onboardingEl.style.display = 'none';
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to initialize onboarding:', e);
|
|
}
|
|
|
|
// ===== SCRIPTS SECTION =====
|
|
try {
|
|
// Always load scripts list (shows available scripts)
|
|
if (document.getElementById('scripts-content')) {
|
|
loadScripts();
|
|
}
|
|
|
|
// Only poll status when NOT in upgrade mode (upgrade banner handles status display)
|
|
if (document.getElementById('scripts-content') && !isUpgradeMode) {
|
|
checkScriptStatus();
|
|
// Poll for script status every 5 seconds
|
|
scriptStatusInterval = setInterval(checkScriptStatus, 5000);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to initialize scripts section:', e);
|
|
}
|
|
|
|
// ===== UPGRADE MODE =====
|
|
// When ?upgrade=true, poll for completion and redirect to main UI when ready.
|
|
try {
|
|
if (isUpgradeMode) {
|
|
// Show the script-running banner with upgrade-specific text
|
|
const upgradeBanner = document.getElementById('script-running-banner');
|
|
if (upgradeBanner) {
|
|
upgradeBanner.classList.add('show');
|
|
document.getElementById('script-running-name').textContent = 'WROLPi Upgrade Running';
|
|
currentScriptOutput = 'upgrade';
|
|
}
|
|
|
|
const upgradeIsDocker = {{ 'true' if docker_mode else 'false' }};
|
|
let upgradeHttpsBase;
|
|
if (window.location.protocol === 'https:') {
|
|
upgradeHttpsBase = `https://${window.location.hostname}`;
|
|
} else if (upgradeIsDocker) {
|
|
upgradeHttpsBase = `https://${window.location.hostname}:8443`;
|
|
} else {
|
|
upgradeHttpsBase = `https://${window.location.hostname}`;
|
|
}
|
|
|
|
// Two-phase upgrade detection:
|
|
// Phase 1: Wait for services to go DOWN (confirms upgrade actually started).
|
|
// Phase 2: Wait for services to come back UP (upgrade complete).
|
|
let upgradeServicesWentDown = sessionStorage.getItem('upgradeServicesWentDown') === 'true';
|
|
|
|
const checkUpgradeComplete = async () => {
|
|
try {
|
|
const resp = await fetch('api/ready', {
|
|
method: 'GET',
|
|
headers: {'Accept': 'application/json'},
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (!upgradeServicesWentDown) {
|
|
if (!data.api || !data.app) {
|
|
upgradeServicesWentDown = true;
|
|
sessionStorage.setItem('upgradeServicesWentDown', 'true');
|
|
}
|
|
} else if (data.api && data.app) {
|
|
sessionStorage.removeItem('upgradeServicesWentDown');
|
|
setTimeout(() => {
|
|
window.location.href = `${upgradeHttpsBase}/`;
|
|
}, 2000);
|
|
}
|
|
} else {
|
|
upgradeServicesWentDown = true;
|
|
sessionStorage.setItem('upgradeServicesWentDown', 'true');
|
|
}
|
|
} catch (e) {
|
|
upgradeServicesWentDown = true;
|
|
sessionStorage.setItem('upgradeServicesWentDown', 'true');
|
|
}
|
|
};
|
|
|
|
setInterval(checkUpgradeComplete, 3000);
|
|
checkUpgradeComplete();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to initialize upgrade mode:', e);
|
|
}
|
|
|
|
// ===== FALLBACK MODE =====
|
|
try {
|
|
if (isFallback && !isUpgradeMode) {
|
|
// Show fallback banner
|
|
document.getElementById('fallback-banner').classList.add('show');
|
|
|
|
// Poll controller's readiness endpoint (same-origin, avoids CORS/cert issues).
|
|
const checkMainApp = async () => {
|
|
try {
|
|
// WARNING: This must be a relative URL (no leading slash) so it resolves to
|
|
// /controller/api/ready behind Caddy, or /api/ready when accessed directly.
|
|
// An absolute '/api/ready' would be routed to Sanic by Caddy, not the Controller.
|
|
const resp = await fetch('api/ready', {
|
|
method: 'GET',
|
|
headers: {'Accept': 'application/json'},
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (data.api && data.app) {
|
|
const isDocker = {{ 'true' if docker_mode else 'false' }};
|
|
let httpsBase;
|
|
if (window.location.protocol === 'https:') {
|
|
httpsBase = `https://${window.location.hostname}`;
|
|
} else if (isDocker) {
|
|
httpsBase = `https://${window.location.hostname}:8443`;
|
|
} else {
|
|
httpsBase = `https://${window.location.hostname}`;
|
|
}
|
|
window.location.href = `${httpsBase}/`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Still down, continue polling
|
|
}
|
|
};
|
|
|
|
// Check every 5 seconds
|
|
setInterval(checkMainApp, 5000);
|
|
// Also check immediately
|
|
checkMainApp();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to initialize fallback mode:', e);
|
|
}
|
|
|
|
// ===== PERIODIC UPDATES =====
|
|
// Always fetch stats - Controller is independent and can provide stats during upgrade/fallback
|
|
try {
|
|
startPeriodicUpdates();
|
|
} catch (e) {
|
|
console.error('Failed to start periodic updates:', e);
|
|
}
|
|
|
|
function formatUptime(seconds) {
|
|
if (!seconds || seconds < 0) return '--';
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
if (days > 0) {
|
|
return `${days}d ${hours}h`;
|
|
} else if (hours > 0) {
|
|
return `${hours}h ${minutes}m`;
|
|
} else {
|
|
return `${minutes}m`;
|
|
}
|
|
}
|
|
|
|
async function updateSystemStatus() {
|
|
try {
|
|
// Fetch all stats endpoints in parallel
|
|
const [cpu, memory, load, primaryDrive, iostat, uptime] = await Promise.all([
|
|
apiCall('api/stats/cpu'),
|
|
apiCall('api/stats/memory'),
|
|
apiCall('api/stats/load'),
|
|
apiCall('api/stats/drives/primary'),
|
|
apiCall('api/stats/iostat'),
|
|
apiCall('api/stats/uptime'),
|
|
]);
|
|
|
|
// Update CPU card
|
|
const cpuValue = document.querySelector('.status-card:nth-child(1) .value');
|
|
const cpuSub = document.querySelector('.status-card:nth-child(1) .sub');
|
|
if (cpuValue) cpuValue.textContent = (cpu.percent !== null ? cpu.percent : '--') + '%';
|
|
if (cpuSub) cpuSub.innerHTML = cpu.temperature ? cpu.temperature + '°C' : '--';
|
|
|
|
// Update Memory card
|
|
const memValue = document.querySelector('.status-card:nth-child(2) .value');
|
|
const memSub = document.querySelector('.status-card:nth-child(2) .sub');
|
|
if (memValue && memory.total > 0) {
|
|
const memPercent = ((memory.used / memory.total) * 100).toFixed(1);
|
|
memValue.textContent = memPercent + '%';
|
|
}
|
|
if (memSub) {
|
|
const usedGb = (memory.used / (1024 ** 3)).toFixed(1);
|
|
memSub.textContent = usedGb + ' GB used';
|
|
}
|
|
|
|
// Update Load card
|
|
const loadValue = document.querySelector('.status-card:nth-child(3) .value');
|
|
if (loadValue) loadValue.textContent = load.minute_1 !== undefined ? load.minute_1 : '--';
|
|
|
|
// Update Storage card
|
|
const storageValue = document.querySelector('.status-card:nth-child(4) .value');
|
|
const storageSub = document.querySelector('.status-card:nth-child(4) .sub');
|
|
if (storageValue) {
|
|
storageValue.textContent = (primaryDrive.mounted && primaryDrive.percent !== undefined)
|
|
? primaryDrive.percent + '%'
|
|
: '--';
|
|
}
|
|
if (storageSub && primaryDrive.mounted && primaryDrive.size && primaryDrive.used) {
|
|
const freeBytes = primaryDrive.size - primaryDrive.used;
|
|
const freeGb = (freeBytes / (1024 ** 3)).toFixed(1);
|
|
storageSub.textContent = freeGb + ' GB free';
|
|
}
|
|
|
|
// Update IO Wait card
|
|
const iowaitValue = document.getElementById('iowait-value');
|
|
if (iowaitValue) {
|
|
const iowait = iostat.percent_iowait;
|
|
if (iowait !== null && iowait !== undefined) {
|
|
iowaitValue.textContent = iowait.toFixed(1) + '%';
|
|
// Color based on IO wait severity
|
|
if (iowait >= 30) {
|
|
iowaitValue.style.color = '#ff6b6b'; // Red
|
|
} else if (iowait >= 10) {
|
|
iowaitValue.style.color = '#ffd93d'; // Yellow
|
|
} else {
|
|
iowaitValue.style.color = '#4ecca3'; // Green (default)
|
|
}
|
|
} else {
|
|
iowaitValue.textContent = '--%';
|
|
iowaitValue.style.color = '#4ecca3';
|
|
}
|
|
}
|
|
|
|
// Update Uptime card
|
|
const uptimeValue = document.getElementById('uptime-value');
|
|
if (uptimeValue) {
|
|
uptimeValue.textContent = formatUptime(uptime.uptime_seconds);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to update system status:', e);
|
|
}
|
|
}
|
|
|
|
async function updateServicesTable() {
|
|
try {
|
|
const services = await apiCall('api/services');
|
|
const tbody = document.querySelector('#services table tbody');
|
|
if (!tbody || !Array.isArray(services)) return;
|
|
|
|
// Check if we're in docker mode by looking for Boot column
|
|
const isDockerMode = !document.querySelector('th:last-child')?.textContent?.includes('Boot');
|
|
|
|
let html = '';
|
|
for (const service of services) {
|
|
html += `<tr>
|
|
<td>
|
|
${service.name}
|
|
${service.port ? `<span class="sub">:${service.port}</span>` : ''}
|
|
</td>
|
|
<td class="status-${service.status}">${service.status}</td>
|
|
<td>`;
|
|
|
|
if (service.status === 'running') {
|
|
html += `<button class="btn btn-danger" onclick="serviceAction('${service.name}', 'stop')">Stop</button>`;
|
|
} else {
|
|
html += `<button class="btn btn-primary" onclick="serviceAction('${service.name}', 'start')">Start</button>`;
|
|
}
|
|
|
|
html += `<button class="btn btn-warning" onclick="serviceAction('${service.name}', 'restart')">Restart</button>
|
|
<button class="btn btn-info" onclick="showLogs('${service.name}', true)">Logs</button>`;
|
|
|
|
if (service.viewable && service.status === 'running' && service.port) {
|
|
const protocol = service.use_https ? 'https' : 'http';
|
|
const host = window.location.hostname;
|
|
const viewPath = service.view_path || '';
|
|
const isDefaultPort = (protocol === 'https' && service.port === 443) || (protocol === 'http' && service.port === 80);
|
|
const portSuffix = isDefaultPort ? '' : ':' + service.port;
|
|
html += `<a href="${protocol}://${host}${portSuffix}${viewPath}" target="_blank" class="btn btn-violet">View</a>`;
|
|
}
|
|
|
|
html += '</td>';
|
|
|
|
if (!isDockerMode) {
|
|
if (service.name === 'wrolpi-upgrade') {
|
|
html += `<td><span style="color: #666;">--</span></td>`;
|
|
} else {
|
|
html += `<td>
|
|
<label class="toggle">
|
|
<input type="checkbox"
|
|
id="boot-${service.name}"
|
|
${service.enabled ? 'checked' : ''}
|
|
onchange="toggleBoot('${service.name}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span class="toggle-label" id="boot-label-${service.name}">
|
|
${service.enabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
</td>`;
|
|
}
|
|
}
|
|
|
|
html += '</tr>';
|
|
}
|
|
|
|
if (services.length === 0) {
|
|
const colspan = isDockerMode ? 3 : 4;
|
|
html = `<tr><td colspan="${colspan}" style="text-align: center; color: #666;">No services</td></tr>`;
|
|
}
|
|
|
|
tbody.innerHTML = html;
|
|
|
|
// Show "Proceed to WROLPi" when the main UI is reachable.
|
|
var proceedBtn = document.getElementById('proceed-btn');
|
|
if (proceedBtn) {
|
|
// Check if caddy/web is running (native or Docker).
|
|
var appReady = services.some(function(s) {
|
|
return (s.name === 'caddy' || s.name === 'web') && s.status === 'running';
|
|
});
|
|
if (appReady) {
|
|
var host = window.location.hostname;
|
|
var port = window.location.port;
|
|
// Docker: 8080 → 8443; native: 80/empty → 443 (default).
|
|
var httpsPort = (port === '8080') ? ':8443' : '';
|
|
var httpsBase = 'https://' + host + httpsPort;
|
|
proceedBtn.href = httpsBase;
|
|
proceedBtn.style.display = 'inline-block';
|
|
var viewFilesBtn = document.getElementById('view-files-btn');
|
|
if (viewFilesBtn) {
|
|
viewFilesBtn.href = httpsBase + '/media/';
|
|
viewFilesBtn.style.display = 'inline-block';
|
|
}
|
|
} else {
|
|
proceedBtn.style.display = 'none';
|
|
var viewFilesBtn2 = document.getElementById('view-files-btn');
|
|
if (viewFilesBtn2) viewFilesBtn2.style.display = 'none';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to update services:', e);
|
|
}
|
|
}
|
|
|
|
function startPeriodicUpdates() {
|
|
// Update every 10 seconds
|
|
const updateInterval = 10000;
|
|
|
|
// Fetch hardware button status immediately
|
|
updateHotspotButton();
|
|
updateBluetoothButton();
|
|
|
|
// Initial update after a short delay (to not overlap with initial load)
|
|
setTimeout(() => {
|
|
updateSystemStatus();
|
|
updateServicesTable();
|
|
loadDisks();
|
|
loadSmart();
|
|
}, updateInterval);
|
|
|
|
// Set up periodic updates
|
|
setInterval(() => {
|
|
updateSystemStatus();
|
|
updateServicesTable();
|
|
loadDisks();
|
|
loadSmart();
|
|
updateHotspotButton();
|
|
updateBluetoothButton();
|
|
}, updateInterval);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|