mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
wrolpi-bootstrap.sh created controller.yaml on installed systems, which hid the onboarding wizard. Installed systems now wait for the wizard, whose repair.sh finishes setup. "Run repair without external drive" now writes controller.yaml before starting repair, so the wizard does not return after repair. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2986 lines
113 KiB
HTML
2986 lines
113 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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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 and Root CA is ready -->
|
|
<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>
|
|
{% if cert_download_ready %}
|
|
<a class="btn btn-info" href="/ca.crt" download="wrolpi-ca.crt" style="margin-bottom: 1rem; display: inline-block;">Download WROLPi Root CA</a>
|
|
{% endif %}
|
|
|
|
<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 %}{% if cpu.fan_rpm is not none %} · {{ cpu.fan_rpm }} RPM{% 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="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>
|
|
</div>
|
|
<p id="hotspot-address" style="margin: -0.5rem 0 1rem; color: #aaa; font-size: 0.9rem;" hidden></p>
|
|
<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
|
|
<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"
|
|
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 style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
|
|
<input type="checkbox" id="hotspot-captive-portal">
|
|
Captive portal: show a welcome page (with this WROLPi's address) to devices that join
|
|
</label>
|
|
<button class="btn btn-primary" onclick="saveHotspotSettings()">Save Hotspot Settings</button>
|
|
</div>
|
|
</details>
|
|
{% 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>
|
|
|
|
{% 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 %}
|
|
|
|
<section id="services">
|
|
<h2>Services</h2>
|
|
{% set core = services | rejectattr('group', 'equalto', 'optional') | list %}
|
|
{% set optional = services | selectattr('group', 'equalto', 'optional') | list %}
|
|
{% set span = 3 if docker_mode else 4 %}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Service</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
{% if not docker_mode %}
|
|
<th>Boot</th>
|
|
{% endif %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% 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 %}
|
|
{% endif %}
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="{{ span }}" style="text-align: center; color: #666;">
|
|
Service status not yet implemented
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
</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 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 %}
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
|
|
// --- 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,
|
|
onStatus: showHotspotAddress,
|
|
},
|
|
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);
|
|
if (!btn) return;
|
|
const startText = config.startText || `Start ${config.label}`;
|
|
const stopText = config.stopText || `Stop ${config.label}`;
|
|
try {
|
|
const data = await apiCall(config.statusUrl);
|
|
toggleStates[name] = config.isOn(data);
|
|
if (config.onStatus) config.onStatus(data);
|
|
btn.title = '';
|
|
if (!data.available) {
|
|
btn.textContent = `${config.label} Unavailable`;
|
|
btn.disabled = true;
|
|
btn.className = 'btn';
|
|
btn.title = data.reason || '';
|
|
} else if (toggleStates[name]) {
|
|
btn.textContent = stopText;
|
|
btn.disabled = false;
|
|
btn.className = 'btn btn-warning';
|
|
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 || '';
|
|
} else {
|
|
btn.textContent = startText;
|
|
btn.disabled = false;
|
|
btn.className = 'btn btn-primary';
|
|
}
|
|
} catch (e) {
|
|
btn.textContent = `${config.label} Unavailable`;
|
|
btn.disabled = true;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
try {
|
|
await apiCall(on ? config.stopUrl : config.startUrl, 'POST');
|
|
} catch (e) {
|
|
alert(`${config.label} error: ${e.message}`);
|
|
}
|
|
await updateToggleButton(name);
|
|
for (const other of config.alsoRefresh || []) {
|
|
await updateToggleButton(other);
|
|
}
|
|
}
|
|
|
|
// Hotspot clients need the address to reach this WROLPi; the portal page tells them.
|
|
function showHotspotAddress(data) {
|
|
const el = document.getElementById('hotspot-address');
|
|
if (!el) return;
|
|
if (data.enabled && data.ip) {
|
|
el.textContent = `Hotspot address: ${data.ip}`
|
|
+ (data.portal_url ? ` · Welcome page: ${data.portal_url}` : ' · Captive portal off');
|
|
el.hidden = false;
|
|
} else {
|
|
el.hidden = true;
|
|
}
|
|
}
|
|
|
|
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;
|
|
document.getElementById('hotspot-captive-portal').checked = settings.captive_portal !== false;
|
|
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);
|
|
}
|
|
await loadHotspotProtocols(settings.protocol);
|
|
} catch (e) {
|
|
console.error('Failed to load hotspot settings', e);
|
|
}
|
|
}
|
|
|
|
// 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)}`);
|
|
// Ignore out-of-order responses when the user switches devices quickly.
|
|
if (document.getElementById('hotspot-device').value !== device) return;
|
|
select.innerHTML = '';
|
|
// 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];
|
|
for (const protocol of protocols) {
|
|
const option = document.createElement('option');
|
|
option.value = protocol;
|
|
option.textContent = hotspotProtocolLabels[protocol] || protocol;
|
|
option.selected = protocol === selected;
|
|
select.appendChild(option);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load hotspot protocols', e);
|
|
}
|
|
}
|
|
|
|
async function saveHotspotSettings() {
|
|
const btn = event.target;
|
|
btn.disabled = true;
|
|
try {
|
|
const saved = await apiCall('api/hotspot/settings', 'POST', {
|
|
ssid: document.getElementById('hotspot-ssid').value,
|
|
password: document.getElementById('hotspot-password').value,
|
|
device: document.getElementById('hotspot-device').value,
|
|
protocol: document.getElementById('hotspot-protocol').value || null,
|
|
captive_portal: document.getElementById('hotspot-captive-portal').checked,
|
|
});
|
|
await updateToggleButton('hotspot');
|
|
if (saved.restart_required) {
|
|
alert('Saved. Restart the hotspot to apply the new settings.');
|
|
}
|
|
} catch (e) {
|
|
alert(`Failed to save hotspot settings: ${e.message}`);
|
|
}
|
|
btn.disabled = false;
|
|
}
|
|
|
|
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>
|
|
`;
|
|
|
|
// 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.
|
|
const isPrimary = disk.mountpoint === '/media/wrolpi';
|
|
const isSystemMount = mounted && !isPrimary && !disk.mountpoint.startsWith('/media/');
|
|
|
|
// 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) && !isSystemMount) {
|
|
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 && isSystemMount) {
|
|
html += `<span style="color: #888;">System</span>`;
|
|
} else if (mounted) {
|
|
html += `<button class="btn btn-danger" onclick="unmountDisk('${disk.mountpoint}')">Unmount</button>`;
|
|
} 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) {
|
|
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;
|
|
|
|
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('');
|
|
document.getElementById('script-confirm-warnings').style.display = script.warnings.length ? '' : 'none';
|
|
|
|
// 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/onboarding/without-drive', {
|
|
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) {
|
|
let cpuSubText = cpu.temperature ? cpu.temperature + '°C' : '--';
|
|
if (cpu.fan_rpm !== null && cpu.fan_rpm !== undefined) {
|
|
cpuSubText += ' · ' + cpu.fan_rpm + ' RPM';
|
|
}
|
|
cpuSub.innerHTML = cpuSubText;
|
|
}
|
|
|
|
// 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');
|
|
const colspan = isDockerMode ? 3 : 4;
|
|
|
|
function serviceRowHtml(service) {
|
|
let row = `<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') {
|
|
row += `<button class="btn btn-danger" onclick="serviceAction('${service.name}', 'stop')">Stop</button>`;
|
|
} else {
|
|
row += `<button class="btn btn-primary" onclick="serviceAction('${service.name}', 'start')">Start</button>`;
|
|
}
|
|
|
|
row += `<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;
|
|
row += `<a href="${protocol}://${host}${portSuffix}${viewPath}" target="_blank" class="btn btn-violet">View</a>`;
|
|
}
|
|
|
|
row += '</td>';
|
|
|
|
if (!isDockerMode) {
|
|
if (service.name === 'wrolpi-upgrade') {
|
|
row += `<td><span style="color: #666;">--</span></td>`;
|
|
} else {
|
|
row += `<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>`;
|
|
}
|
|
}
|
|
|
|
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('');
|
|
}
|
|
|
|
if (services.length === 0) {
|
|
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
|
|
refreshToggleButtons();
|
|
loadHotspotSettings();
|
|
|
|
// 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();
|
|
refreshToggleButtons();
|
|
}, updateInterval);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|