Merge pull request #52 from naturallaw777/copilot/refactor-split-css-js-files
[WIP] Refactor and split large CSS and JS files into smaller modules
This commit is contained in:
@@ -378,8 +378,6 @@ def _file_hash(filename: str) -> str:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return "0"
|
return "0"
|
||||||
|
|
||||||
_APP_JS_HASH = _file_hash("app.js")
|
|
||||||
_STYLE_CSS_HASH = _file_hash("style.css")
|
|
||||||
_ONBOARDING_JS_HASH = _file_hash("onboarding.js")
|
_ONBOARDING_JS_HASH = _file_hash("onboarding.js")
|
||||||
|
|
||||||
# ── Update check helpers ──────────────────────────────────────────
|
# ── Update check helpers ──────────────────────────────────────────
|
||||||
@@ -1137,8 +1135,6 @@ def _verify_support_removed() -> bool:
|
|||||||
async def index(request: Request):
|
async def index(request: Request):
|
||||||
return templates.TemplateResponse("index.html", {
|
return templates.TemplateResponse("index.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"app_js_hash": _APP_JS_HASH,
|
|
||||||
"style_css_hash": _STYLE_CSS_HASH,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1147,7 +1143,6 @@ async def onboarding(request: Request):
|
|||||||
return templates.TemplateResponse("onboarding.html", {
|
return templates.TemplateResponse("onboarding.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"onboarding_js_hash": _ONBOARDING_JS_HASH,
|
"onboarding_js_hash": _ONBOARDING_JS_HASH,
|
||||||
"style_css_hash": _STYLE_CSS_HASH,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
137
app/sovran_systemsos_web/static/css/base.css
Normal file
137
app/sovran_systemsos_web/static/css/base.css
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||||
|
Dark theme matching the Adwaita dark aesthetic
|
||||||
|
v6 — Status-only tiles (no controls) */
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-color: #1e1e2e;
|
||||||
|
--surface-color: #2a2a3c;
|
||||||
|
--card-color: #313244;
|
||||||
|
--border-color: #45475a;
|
||||||
|
--text-primary: #cdd6f4;
|
||||||
|
--text-secondary: #a6adc8;
|
||||||
|
--text-dim: #6c7086;
|
||||||
|
--accent-color: #89b4fa;
|
||||||
|
--green: #2ec27e;
|
||||||
|
--yellow: #e5a50a;
|
||||||
|
--red: #e01b24;
|
||||||
|
--grey: #888888;
|
||||||
|
--radius-card: 18px;
|
||||||
|
--radius-btn: 8px;
|
||||||
|
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login page ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--card-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
background-color: rgba(224, 27, 36, 0.12);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
color: #f87171;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
86
app/sovran_systemsos_web/static/css/buttons.css
Normal file
86
app/sovran_systemsos_web/static/css/buttons.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update System button: BLUE by default */
|
||||||
|
.btn-update {
|
||||||
|
background-color: #89b4fa;
|
||||||
|
color: #1e1e2e;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-update:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update System button: GREEN when updates are available */
|
||||||
|
.btn-update.has-updates {
|
||||||
|
background-color: #2ec27e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-update.has-updates:hover:not(:disabled) {
|
||||||
|
background-color: #27ae6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-badge {
|
||||||
|
display: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: var(--yellow);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-badge.visible {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-badge {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(1.35); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover:not(:disabled) {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
173
app/sovran_systemsos_web/static/css/domain-setup.css
Normal file
173
app/sovran_systemsos_web/static/css/domain-setup.css
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/* ── Domain setup modal ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
domain-narrow-dialog {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
domain-field-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
domain-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
domain-field-input {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #12121c;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
domain-field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
domain-field-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Port Requirements modal ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.domain-narrow-dialog {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-setup-intro {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-setup-intro ol {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-setup-intro li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-input {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #12121c;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-intro {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-internal-ip {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-port {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-proto {
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-req-status {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
143
app/sovran_systemsos_web/static/css/features.css
Normal file
143
app/sovran_systemsos_web/static/css/features.css
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/* ── Feature Manager styles ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.feature-manager-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-subcategory {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-subcategory-header {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-cards-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background-color: var(--card-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-desc {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-status {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle-slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle.active .feature-toggle-slider {
|
||||||
|
background-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle.active .feature-toggle-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-label--checking {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-label--ok {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-label--warn {
|
||||||
|
color: var(--yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-label--error {
|
||||||
|
color: var(--red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
72
app/sovran_systemsos_web/static/css/header.css
Normal file
72
app/sovran_systemsos_web/static/css/header.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar .title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
height: 108px;
|
||||||
|
width: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.ip-bar {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 8px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-bar .ip-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-bar .ip-value {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-separator {
|
||||||
|
color: var(--border-color);
|
||||||
|
}
|
||||||
130
app/sovran_systemsos_web/static/css/layout.css
Normal file
130
app/sovran_systemsos_web/static/css/layout.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* ── Main content ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 270px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
padding: 20px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar: Tech Support button ───────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar-support-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--card-color);
|
||||||
|
border: 2px dashed var(--accent-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-support-btn:hover {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #a8c8ff;
|
||||||
|
background-color: #35354a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-support-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-support-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-support-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-support-hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tiles area ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#tiles-area {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 20px 48px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Category sections ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.category-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
421
app/sovran_systemsos_web/static/css/modals.css
Normal file
421
app/sovran_systemsos_web/static/css/modals.css
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
/* ── Update modal ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0,0,0,0.65);
|
||||||
|
z-index: 200;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2.5px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-spinner.spinning {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-log {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: #12121c;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reboot = GREEN */
|
||||||
|
.modal-footer .btn-reboot,
|
||||||
|
button.btn-reboot {
|
||||||
|
background-color: #2ec27e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .btn-reboot:hover:not(:disabled),
|
||||||
|
button.btn-reboot:hover:not(:disabled) {
|
||||||
|
background-color: #27ae6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background-color: var(--yellow);
|
||||||
|
color: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover:not(:disabled) {
|
||||||
|
background-color: #c98d08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-modal {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-modal:hover:not(:disabled) {
|
||||||
|
background-color: #5a5c72;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Credentials info modal ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.creds-dialog {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||||
|
animation: creds-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes creds-fade-in {
|
||||||
|
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 28px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-close-btn {
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-close-btn:hover {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-body {
|
||||||
|
padding: 24px 28px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-loading {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-value-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-value {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: #12121c;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-copy-btn {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-copy-btn:hover {
|
||||||
|
background-color: #5a5c72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-copy-btn.copied {
|
||||||
|
background-color: var(--green);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Credential links ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.creds-link {
|
||||||
|
color: #b8f0c0;
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: #defce6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Matrix action buttons ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.matrix-actions-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 18px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-action-btn {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #0f0f19;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-action-btn:hover {
|
||||||
|
background-color: #a8c8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-input {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #12121c;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-checkbox-row input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-submit {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #0f0f19;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-submit:hover:not(:disabled) {
|
||||||
|
background-color: #a8c8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-submit:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-back {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-back:hover {
|
||||||
|
background-color: #5a5c72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-result {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-result.success {
|
||||||
|
background-color: rgba(74, 222, 128, 0.12);
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
color: var(--green);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-form-result.error {
|
||||||
|
background-color: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #f87171;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR code in credentials modal ────────────────────────────────── */
|
||||||
|
|
||||||
|
.creds-qr-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-qr-img {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 4px solid #fff;
|
||||||
|
background-color: #fff;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creds-qr-hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
144
app/sovran_systemsos_web/static/css/onboarding.css
Normal file
144
app/sovran_systemsos_web/static/css/onboarding.css
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/* ── Reboot overlay ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.reboot-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(15, 15, 25, 0.92);
|
||||||
|
z-index: 999;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-overlay.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-card {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 48px 56px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
|
||||||
|
animation: reboot-fade-in 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reboot-fade-in {
|
||||||
|
from { opacity: 0; transform: scale(0.92) translateY(12px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
animation: reboot-spin 2s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reboot-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-message {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
animation: reboot-bounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.reboot-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes reboot-bounce {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-submessage {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 14px 12px;
|
||||||
|
}
|
||||||
|
#tiles-area {
|
||||||
|
height: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
padding: 16px 12px 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header-bar {
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.header-bar .title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.ip-bar {
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
.tiles-grid {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.service-tile {
|
||||||
|
width: 140px;
|
||||||
|
min-height: 130px;
|
||||||
|
}
|
||||||
|
.reboot-card {
|
||||||
|
padding: 36px 28px;
|
||||||
|
margin: 0 16px;
|
||||||
|
}
|
||||||
|
.creds-dialog {
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
.creds-qr-img {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
362
app/sovran_systemsos_web/static/css/support.css
Normal file
362
app/sovran_systemsos_web/static/css/support.css
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/* ── Tech Support modal ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.support-section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon-big {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-active-icon {
|
||||||
|
animation: pulse-badge 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-heading {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-active-heading {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-desc {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-active-note {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info-box {
|
||||||
|
background-color: var(--card-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-active-box {
|
||||||
|
border-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info-value {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info-hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 6px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-steps {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background-color: var(--card-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-steps-title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-steps ol {
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-steps code {
|
||||||
|
background-color: rgba(137, 180, 250, 0.12);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-enable {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-enable:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-disable {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--red);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-disable:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-done {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-done:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-auditlog {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-auditlog:hover:not(:disabled) {
|
||||||
|
background-color: #5a5c72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-fine-print {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-verify-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--card-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-verify-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-verify-value {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-verify-value.verified-gone {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-verify-value.verify-warning {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Wallet protection ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.support-wallet-box {
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-protected {
|
||||||
|
background-color: rgba(46, 194, 126, 0.06);
|
||||||
|
border-color: rgba(46, 194, 126, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-unlocked {
|
||||||
|
background-color: rgba(229, 165, 10, 0.06);
|
||||||
|
border-color: rgba(229, 165, 10, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-warning {
|
||||||
|
background-color: rgba(224, 27, 36, 0.06);
|
||||||
|
border-color: rgba(224, 27, 36, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-paths {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-paths li {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-unlock-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-unlock-select {
|
||||||
|
background-color: var(--card-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-unlock {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--yellow);
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-unlock:hover:not(:disabled) {
|
||||||
|
background-color: #c98d08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-lock {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||||
|
background-color: #27ae6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.support-audit-container {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-log {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #12121c;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-entry {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 3px 0;
|
||||||
|
border-bottom: 1px solid rgba(69, 71, 90, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-empty {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tech Support tile ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.support-tile {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-tile:hover {
|
||||||
|
border-color: #a8c8ff;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
279
app/sovran_systemsos_web/static/css/tiles.css
Normal file
279
app/sovran_systemsos_web/static/css/tiles.css
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||||
|
|
||||||
|
.service-tile {
|
||||||
|
width: 160px;
|
||||||
|
min-height: 130px;
|
||||||
|
background-color: var(--card-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 12px 18px;
|
||||||
|
gap: 0;
|
||||||
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-tile:hover {
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
border-color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-tile.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-icon-fallback {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
max-width: 140px;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
min-height: 1.3em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.active { background-color: var(--green); }
|
||||||
|
.status-dot.inactive { background-color: var(--red); }
|
||||||
|
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
||||||
|
.status-dot.failed { background-color: var(--red); }
|
||||||
|
.status-dot.disabled { background-color: var(--grey); }
|
||||||
|
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||||
|
|
||||||
|
/* ── Service detail modal sections ───────────────────────────────── */
|
||||||
|
|
||||||
|
.svc-detail-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-section-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Service detail: Domain ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.svc-detail-domain-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-domain-label--ok {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-domain-label--warn {
|
||||||
|
color: var(--yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-domain-label--error {
|
||||||
|
color: var(--red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Service detail: Port table ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.svc-detail-port-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table-port {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table-proto {
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-port-table-status {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-status-listening { color: var(--green); }
|
||||||
|
.port-status-open { color: var(--yellow); }
|
||||||
|
.port-status-closed { color: var(--red); }
|
||||||
|
.port-status-unknown { color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ── Service detail: Troubleshoot box ────────────────────────────── */
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background-color: rgba(229, 165, 10, 0.08);
|
||||||
|
border: 1px solid rgba(229, 165, 10, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot strong {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot ol {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot code {
|
||||||
|
background-color: rgba(137, 180, 250, 0.12);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-troubleshoot a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
||||||
|
|
||||||
|
.svc-detail-addon-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-addon-status {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-status--on {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-status--off {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-conflict-warning {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background-color: rgba(229, 165, 10, 0.1);
|
||||||
|
border: 1px solid rgba(229, 165, 10, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
31
app/sovran_systemsos_web/static/js/constants.js
Normal file
31
app/sovran_systemsos_web/static/js/constants.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||||
|
v7 — Status-only dashboard + Tech Support + Feature Manager */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const POLL_INTERVAL_SERVICES = 5000;
|
||||||
|
const POLL_INTERVAL_UPDATES = 1800000;
|
||||||
|
const UPDATE_POLL_INTERVAL = 2000;
|
||||||
|
const REBOOT_CHECK_INTERVAL = 5000;
|
||||||
|
const SUPPORT_TIMER_INTERVAL = 1000;
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = [
|
||||||
|
"infrastructure",
|
||||||
|
"bitcoin-base",
|
||||||
|
"bitcoin-apps",
|
||||||
|
"communication",
|
||||||
|
"apps",
|
||||||
|
"nostr",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEATURE_SUBCATEGORY_LABELS = {
|
||||||
|
"infrastructure": "🔧 Infrastructure",
|
||||||
|
"bitcoin": "₿ Bitcoin",
|
||||||
|
"communication": "💬 Communication",
|
||||||
|
"nostr": "📡 Nostr",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"];
|
||||||
|
|
||||||
|
const STATUS_LOADING_STATES = new Set([
|
||||||
|
"reloading", "activating", "deactivating", "maintenance",
|
||||||
|
]);
|
||||||
80
app/sovran_systemsos_web/static/js/events.js
Normal file
80
app/sovran_systemsos_web/static/js/events.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Event listeners ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
|
||||||
|
if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); });
|
||||||
|
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||||
|
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||||
|
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||||
|
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
|
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||||
|
|
||||||
|
// Rebuild modal
|
||||||
|
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||||
|
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||||
|
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
||||||
|
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
||||||
|
|
||||||
|
// Domain setup modal
|
||||||
|
if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal);
|
||||||
|
if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); });
|
||||||
|
|
||||||
|
// SSL Email modal
|
||||||
|
if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal);
|
||||||
|
if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal);
|
||||||
|
if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); });
|
||||||
|
|
||||||
|
// Feature confirm modal
|
||||||
|
if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm);
|
||||||
|
if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm);
|
||||||
|
if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); });
|
||||||
|
|
||||||
|
if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); });
|
||||||
|
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
||||||
|
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Check onboarding status first — redirect to wizard if not complete
|
||||||
|
try {
|
||||||
|
var onboardingStatus = await apiFetch("/api/onboarding/status");
|
||||||
|
if (!onboardingStatus.complete) {
|
||||||
|
window.location.href = "/onboarding";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// If we can't reach the endpoint, continue to normal dashboard
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var cfg = await apiFetch("/api/config");
|
||||||
|
if (cfg.category_order) {
|
||||||
|
for (var i = 0; i < cfg.category_order.length; i++) {
|
||||||
|
_categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var badge = document.getElementById("role-badge");
|
||||||
|
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||||
|
|
||||||
|
await refreshServices();
|
||||||
|
loadNetwork();
|
||||||
|
checkUpdates();
|
||||||
|
|
||||||
|
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||||
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
|
|
||||||
|
if (cfg.feature_manager) {
|
||||||
|
loadFeatureManager();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
await refreshServices();
|
||||||
|
loadNetwork();
|
||||||
|
checkUpdates();
|
||||||
|
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||||
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
581
app/sovran_systemsos_web/static/js/features.js
Normal file
581
app/sovran_systemsos_web/static/js/features.js
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Feature confirm modal ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function openFeatureConfirm(message, onConfirm) {
|
||||||
|
if (!$featureConfirmModal) return;
|
||||||
|
if ($featureConfirmMsg) $featureConfirmMsg.textContent = message;
|
||||||
|
$featureConfirmModal.classList.add("open");
|
||||||
|
// Replace ok handler
|
||||||
|
var newOk = $featureConfirmOk.cloneNode(true);
|
||||||
|
$featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk);
|
||||||
|
newOk.addEventListener("click", function() {
|
||||||
|
closeFeatureConfirm();
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFeatureConfirm() {
|
||||||
|
if ($featureConfirmModal) $featureConfirmModal.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSL Email modal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openSslEmailModal(onSaved) {
|
||||||
|
if (!$sslEmailModal) return;
|
||||||
|
if ($sslEmailInput) $sslEmailInput.value = "";
|
||||||
|
$sslEmailModal.classList.add("open");
|
||||||
|
// Replace save handler
|
||||||
|
var newSave = $sslEmailSave.cloneNode(true);
|
||||||
|
$sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave);
|
||||||
|
newSave.addEventListener("click", async function() {
|
||||||
|
var email = $sslEmailInput ? $sslEmailInput.value.trim() : "";
|
||||||
|
if (!email) { alert("Please enter an email address."); return; }
|
||||||
|
newSave.disabled = true;
|
||||||
|
newSave.textContent = "Saving…";
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/domains/set-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: email }),
|
||||||
|
});
|
||||||
|
closeSslEmailModal();
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
newSave.disabled = false;
|
||||||
|
newSave.textContent = "Save";
|
||||||
|
alert("Failed to save email. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSslEmailModal() {
|
||||||
|
if ($sslEmailModal) $sslEmailModal.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Domain Setup modal ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openDomainSetupModal(feat, onSaved) {
|
||||||
|
if (!$domainSetupModal) return;
|
||||||
|
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||||
|
|
||||||
|
var npubField = "";
|
||||||
|
if (feat.id === "haven") {
|
||||||
|
var currentNpub = "";
|
||||||
|
if (feat.extra_fields && feat.extra_fields.length > 0) {
|
||||||
|
for (var i = 0; i < feat.extra_fields.length; i++) {
|
||||||
|
if (feat.extra_fields[i].id === "nostr_npub") {
|
||||||
|
currentNpub = feat.extra_fields[i].current_value || "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalIp = _cachedExternalIp || "your external IP";
|
||||||
|
|
||||||
|
$domainSetupBody.innerHTML =
|
||||||
|
'<div class="domain-setup-intro">' +
|
||||||
|
'<p><strong>Before continuing:</strong></p>' +
|
||||||
|
'<ol>' +
|
||||||
|
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||||
|
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
|
||||||
|
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
||||||
|
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||||
|
'<li>Njal.la will give you a curl command like:<br>' +
|
||||||
|
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
||||||
|
'<li>Enter the subdomain and paste that curl command below</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
||||||
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||||
|
npubField +
|
||||||
|
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||||
|
|
||||||
|
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
|
||||||
|
|
||||||
|
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||||
|
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||||
|
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||||
|
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
|
||||||
|
subdomain = subdomain.trim();
|
||||||
|
ddnsUrl = ddnsUrl.trim();
|
||||||
|
npub = npub.trim();
|
||||||
|
|
||||||
|
if (!subdomain) { alert("Please enter a subdomain."); return; }
|
||||||
|
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||||
|
|
||||||
|
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = "Saving…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/domains/set", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain_name: feat.domain_name,
|
||||||
|
domain: subdomain,
|
||||||
|
ddns_url: ddnsUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
closeDomainSetupModal();
|
||||||
|
onSaved(npub);
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = "Save & Enable";
|
||||||
|
alert("Failed to save domain. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$domainSetupModal.classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDomainSetupModal() {
|
||||||
|
if ($domainSetupModal) $domainSetupModal.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port Requirements modal ───────────────────────────────────────
|
||||||
|
|
||||||
|
function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||||
|
if (!$portReqModal || !$portReqBody) return;
|
||||||
|
|
||||||
|
var continueBtn = onContinue
|
||||||
|
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Show loading state while fetching port status
|
||||||
|
$portReqBody.innerHTML =
|
||||||
|
'<p class="port-req-intro">Checking port status for <strong>' + escHtml(featureName) + '</strong>…</p>' +
|
||||||
|
'<p class="port-req-hint">Detecting which ports are open on this machine…</p>';
|
||||||
|
|
||||||
|
$portReqModal.classList.add("open");
|
||||||
|
|
||||||
|
// Fetch live port status from local system commands (no external calls)
|
||||||
|
fetch("/api/ports/status", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ports: ports }),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
|
||||||
|
? data.internal_ip : null;
|
||||||
|
var portStatuses = {};
|
||||||
|
(data.ports || []).forEach(function(p) {
|
||||||
|
portStatuses[p.port + "/" + p.protocol] = p.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
var rows = ports.map(function(p) {
|
||||||
|
var key = p.port + "/" + p.protocol;
|
||||||
|
var status = portStatuses[key] || "unknown";
|
||||||
|
var statusHtml;
|
||||||
|
if (status === "listening") {
|
||||||
|
statusHtml = '<span class="port-status-listening" title="Service is running and firewall allows this port">🟢 Listening</span>';
|
||||||
|
} else if (status === "firewall_open") {
|
||||||
|
statusHtml = '<span class="port-status-open" title="Firewall allows this port but no service is bound yet">🟡 Open (idle)</span>';
|
||||||
|
} else if (status === "closed") {
|
||||||
|
statusHtml = '<span class="port-status-closed" title="Firewall blocks this port and/or nothing is listening">🔴 Closed</span>';
|
||||||
|
} else {
|
||||||
|
statusHtml = '<span class="port-status-unknown" title="Status could not be determined">⚪ Unknown</span>';
|
||||||
|
}
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||||
|
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||||
|
'<td class="port-req-desc">' + escHtml(p.description) + '</td>' +
|
||||||
|
'<td class="port-req-status">' + statusHtml + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
var ipLine = internalIp
|
||||||
|
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
|
||||||
|
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||||
|
|
||||||
|
$portReqBody.innerHTML =
|
||||||
|
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||||
|
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
|
||||||
|
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||||
|
ipLine +
|
||||||
|
'<table class="port-req-table">' +
|
||||||
|
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th><th>Status</th></tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
|
||||||
|
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
|
||||||
|
"or check your router's port forwarding page.</p>" +
|
||||||
|
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||||
|
'<div class="domain-field-actions">' +
|
||||||
|
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||||
|
continueBtn +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
|
||||||
|
closePortRequirementsModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onContinue) {
|
||||||
|
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
|
||||||
|
closePortRequirementsModal();
|
||||||
|
onContinue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
// Fallback: show static table without status column if fetch fails
|
||||||
|
var rows = ports.map(function(p) {
|
||||||
|
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||||
|
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||||
|
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
$portReqBody.innerHTML =
|
||||||
|
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||||
|
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work with clients outside your local network, ' +
|
||||||
|
'you must configure <strong>port forwarding</strong> in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.</p>' +
|
||||||
|
'<table class="port-req-table">' +
|
||||||
|
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||||
|
'<div class="domain-field-actions">' +
|
||||||
|
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||||
|
continueBtn +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
|
||||||
|
closePortRequirementsModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onContinue) {
|
||||||
|
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
|
||||||
|
closePortRequirementsModal();
|
||||||
|
onContinue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePortRequirementsModal() {
|
||||||
|
if ($portReqModal) $portReqModal.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($portReqClose) {
|
||||||
|
$portReqClose.addEventListener("click", closePortRequirementsModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature toggle logic ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function performFeatureToggle(featId, enabled, extra) {
|
||||||
|
// Look up feature name for the rebuild modal
|
||||||
|
_rebuildIsEnabling = enabled;
|
||||||
|
_rebuildFeatureName = featId;
|
||||||
|
if (_featuresData) {
|
||||||
|
var found = _featuresData.features.find(function(f) { return f.id === featId; });
|
||||||
|
if (found) _rebuildFeatureName = found.name;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var res = await fetch("/api/features/toggle", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }),
|
||||||
|
});
|
||||||
|
var body = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (body && body.error === "domain_required") {
|
||||||
|
alert("Domain not configured for this feature. Please configure it first.");
|
||||||
|
} else {
|
||||||
|
alert("Error: " + (body.detail || body.error || "Unknown error"));
|
||||||
|
}
|
||||||
|
loadFeatureManager();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openRebuildModal();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to toggle feature: " + err);
|
||||||
|
loadFeatureManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFeatureToggle(feat, newEnabled) {
|
||||||
|
if (!newEnabled) {
|
||||||
|
// Disable: ask confirmation
|
||||||
|
openFeatureConfirm(
|
||||||
|
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
||||||
|
function() { performFeatureToggle(feat.id, false, {}); }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling
|
||||||
|
var conflictNames = [];
|
||||||
|
if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) {
|
||||||
|
feat.conflicts_with.forEach(function(cid) {
|
||||||
|
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||||
|
if (cf && cf.enabled) conflictNames.push(cf.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceedAfterPortCheck() {
|
||||||
|
// Check SSL email first
|
||||||
|
if (!_featuresData || !_featuresData.ssl_email_configured) {
|
||||||
|
if (feat.needs_domain) {
|
||||||
|
openSslEmailModal(function() {
|
||||||
|
// After ssl email saved, check domain
|
||||||
|
checkDomainAndEnable(feat, {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (feat.needs_domain && !feat.domain_configured) {
|
||||||
|
checkDomainAndEnable(feat, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feat.id === "haven") {
|
||||||
|
var npub = "";
|
||||||
|
if (feat.extra_fields) {
|
||||||
|
var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; });
|
||||||
|
if (ef) npub = ef.current_value || "";
|
||||||
|
}
|
||||||
|
if (!npub) {
|
||||||
|
// Need to collect npub via domain modal
|
||||||
|
openDomainSetupModal(feat, function(collectedNpub) {
|
||||||
|
performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
performFeatureToggle(feat.id, true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceedAfterConflictCheck() {
|
||||||
|
// Show port requirements notification if the feature has extra port needs
|
||||||
|
var ports = feat.port_requirements || [];
|
||||||
|
if (ports.length > 0) {
|
||||||
|
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||||
|
} else {
|
||||||
|
proceedAfterPortCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictNames.length > 0) {
|
||||||
|
var confirmMsg;
|
||||||
|
if (feat.id === "bip110") {
|
||||||
|
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?";
|
||||||
|
} else if (feat.id === "bitcoin-core") {
|
||||||
|
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?";
|
||||||
|
} else {
|
||||||
|
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
|
||||||
|
}
|
||||||
|
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||||
|
} else {
|
||||||
|
proceedAfterConflictCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDomainAndEnable(feat, extra) {
|
||||||
|
openDomainSetupModal(feat, function(collectedNpub) {
|
||||||
|
var extraData = {};
|
||||||
|
if (collectedNpub) extraData.nostr_npub = collectedNpub;
|
||||||
|
performFeatureToggle(feat.id, true, extraData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature Manager rendering ─────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadFeatureManager() {
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/features");
|
||||||
|
_featuresData = data;
|
||||||
|
// Feature Manager is now integrated into tile modals; sidebar rendering removed.
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load features:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _checkFeatureManagerDomains(data) {
|
||||||
|
// Collect all features with a configured domain
|
||||||
|
var featsWithDomain = (data.features || []).filter(function(f) {
|
||||||
|
return f.needs_domain && f.domain_configured;
|
||||||
|
});
|
||||||
|
if (!featsWithDomain.length) return;
|
||||||
|
|
||||||
|
// Get the actual domain values from /api/domains/status, then check them
|
||||||
|
fetch("/api/domains/status")
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(statusData) {
|
||||||
|
var domainFileMap = statusData.domains || {};
|
||||||
|
// Build list of domains to check and a map from domain value → feature id
|
||||||
|
var domainsToCheck = [];
|
||||||
|
var domainToFeatIds = {};
|
||||||
|
featsWithDomain.forEach(function(feat) {
|
||||||
|
var domainName = feat.domain_name;
|
||||||
|
var domainVal = domainName ? domainFileMap[domainName] : null;
|
||||||
|
if (domainVal) {
|
||||||
|
domainsToCheck.push(domainVal);
|
||||||
|
if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = [];
|
||||||
|
domainToFeatIds[domainVal].push(feat.id);
|
||||||
|
} else {
|
||||||
|
// Domain file missing — update badge to warn
|
||||||
|
_updateFeatureDomainBadge(feat.id, null, "unresolvable");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domainsToCheck.length) return;
|
||||||
|
|
||||||
|
return fetch("/api/domains/check", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domains: domainsToCheck }),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(checkData) {
|
||||||
|
(checkData.domains || []).forEach(function(d) {
|
||||||
|
var featIds = domainToFeatIds[d.domain] || [];
|
||||||
|
featIds.forEach(function(featId) {
|
||||||
|
_updateFeatureDomainBadge(featId, d.domain, d.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateFeatureDomainBadge(featId, domainVal, status) {
|
||||||
|
var section = $sidebarFeatures.querySelector(".feature-manager-section");
|
||||||
|
if (!section) return;
|
||||||
|
// Find the card — cards don't have a data-feat-id, so find via name match
|
||||||
|
var badges = section.querySelectorAll(".feature-domain-badge.configured");
|
||||||
|
badges.forEach(function(badge) {
|
||||||
|
var domainNameAttr = badge.getAttribute("data-domain-name");
|
||||||
|
// Match by domain_name attribute — we need to look up the feat's domain_name
|
||||||
|
var feat = _featuresData && _featuresData.features
|
||||||
|
? _featuresData.features.find(function(f) { return f.id === featId; })
|
||||||
|
: null;
|
||||||
|
if (!feat) return;
|
||||||
|
if (domainNameAttr !== (feat.domain_name || "")) return;
|
||||||
|
|
||||||
|
var lbl = badge.querySelector(".feature-domain-label");
|
||||||
|
if (!lbl) return;
|
||||||
|
lbl.classList.remove("feature-domain-label--checking");
|
||||||
|
if (status === "connected") {
|
||||||
|
lbl.className = "feature-domain-label feature-domain-label--ok";
|
||||||
|
lbl.textContent = (domainVal || "Domain") + " ✓";
|
||||||
|
} else if (status === "dns_mismatch") {
|
||||||
|
lbl.className = "feature-domain-label feature-domain-label--warn";
|
||||||
|
lbl.textContent = (domainVal || "Domain") + " (IP mismatch)";
|
||||||
|
} else if (status === "unresolvable") {
|
||||||
|
lbl.className = "feature-domain-label feature-domain-label--error";
|
||||||
|
lbl.textContent = (domainVal || "Domain") + " (DNS error)";
|
||||||
|
} else {
|
||||||
|
lbl.className = "feature-domain-label feature-domain-label--warn";
|
||||||
|
lbl.textContent = (domainVal || "Domain") + " (unknown)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeatureManager(data) {
|
||||||
|
// Remove old feature manager section if it exists
|
||||||
|
var old = $sidebarFeatures.querySelector(".feature-manager-section");
|
||||||
|
if (old) old.parentNode.removeChild(old);
|
||||||
|
|
||||||
|
var section = document.createElement("div");
|
||||||
|
section.className = "category-section feature-manager-section";
|
||||||
|
section.dataset.category = "feature-manager";
|
||||||
|
section.innerHTML = '<div class="section-header">Feature Manager</div><hr class="section-divider" />';
|
||||||
|
|
||||||
|
// Group by sub-category
|
||||||
|
var grouped = {};
|
||||||
|
for (var i = 0; i < data.features.length; i++) {
|
||||||
|
var f = data.features[i];
|
||||||
|
var cat = f.category || "other";
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||||
|
Object.keys(grouped).forEach(function(k) {
|
||||||
|
if (orderedCats.indexOf(k) === -1) orderedCats.push(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var j = 0; j < orderedCats.length; j++) {
|
||||||
|
var catKey = orderedCats[j];
|
||||||
|
var feats = grouped[catKey];
|
||||||
|
if (!feats || feats.length === 0) continue;
|
||||||
|
|
||||||
|
var subcat = document.createElement("div");
|
||||||
|
subcat.className = "feature-subcategory";
|
||||||
|
var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey;
|
||||||
|
subcat.innerHTML = '<div class="feature-subcategory-header">' + escHtml(subcatLabel) + '</div>';
|
||||||
|
|
||||||
|
var cardsWrap = document.createElement("div");
|
||||||
|
cardsWrap.className = "feature-cards-wrap";
|
||||||
|
|
||||||
|
for (var k = 0; k < feats.length; k++) {
|
||||||
|
cardsWrap.appendChild(buildFeatureCard(feats[k]));
|
||||||
|
}
|
||||||
|
subcat.appendChild(cardsWrap);
|
||||||
|
section.appendChild(subcat);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sidebarFeatures.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeatureCard(feat) {
|
||||||
|
var card = document.createElement("div");
|
||||||
|
card.className = "feature-card";
|
||||||
|
|
||||||
|
var conflictHtml = "";
|
||||||
|
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
|
||||||
|
var conflictNames = feat.conflicts_with.map(function(cid) {
|
||||||
|
if (!_featuresData) return cid;
|
||||||
|
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||||
|
return cf ? cf.name : cid;
|
||||||
|
});
|
||||||
|
conflictHtml = '<div class="feature-conflict-warning">⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainHtml = "";
|
||||||
|
if (feat.needs_domain) {
|
||||||
|
if (feat.domain_configured) {
|
||||||
|
domainHtml = '<div class="feature-domain-badge configured" data-domain-name="' + escHtml(feat.domain_name || '') + '">'
|
||||||
|
+ '<span class="feature-domain-icon">🌐</span>'
|
||||||
|
+ '<span class="feature-domain-label feature-domain-label--checking">Domain: Checking\u2026</span>'
|
||||||
|
+ '</div>';
|
||||||
|
} else {
|
||||||
|
domainHtml = '<div class="feature-domain-badge not-configured">'
|
||||||
|
+ '<span class="feature-domain-icon">🌐</span>'
|
||||||
|
+ '<span class="feature-domain-label feature-domain-label--warn">Domain: Not configured</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusText = feat.enabled ? "Enabled" : "Disabled";
|
||||||
|
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="feature-card-top">' +
|
||||||
|
'<div class="feature-card-info">' +
|
||||||
|
'<div class="feature-card-name">' + escHtml(feat.name) + '</div>' +
|
||||||
|
'<div class="feature-card-desc">' + escHtml(feat.description) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<label class="feature-toggle' + (feat.enabled ? " active" : "") + '" title="Toggle ' + escHtml(feat.name) + '">' +
|
||||||
|
'<input type="checkbox" class="feature-toggle-input"' + (feat.enabled ? " checked" : "") + ' />' +
|
||||||
|
'<span class="feature-toggle-slider"></span>' +
|
||||||
|
'</label>' +
|
||||||
|
'</div>' +
|
||||||
|
domainHtml +
|
||||||
|
conflictHtml +
|
||||||
|
'<div class="feature-card-status">Status: ' + escHtml(statusText) + '</div>';
|
||||||
|
|
||||||
|
var toggle = card.querySelector(".feature-toggle-input");
|
||||||
|
var toggleLabel = card.querySelector(".feature-toggle");
|
||||||
|
toggle.addEventListener("change", function() {
|
||||||
|
var newEnabled = toggle.checked;
|
||||||
|
// Revert visually until confirmed
|
||||||
|
toggle.checked = feat.enabled;
|
||||||
|
if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); }
|
||||||
|
handleFeatureToggle(feat, newEnabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
58
app/sovran_systemsos_web/static/js/helpers.js
Normal file
58
app/sovran_systemsos_web/static/js/helpers.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function tileId(svc) { return svc.unit + "::" + svc.name; }
|
||||||
|
|
||||||
|
function statusClass(health) {
|
||||||
|
if (!health) return "unknown";
|
||||||
|
if (health === "healthy") return "active";
|
||||||
|
if (health === "needs_attention") return "needs-attention";
|
||||||
|
if (health === "active") return "active"; // backwards compat
|
||||||
|
if (health === "inactive") return "inactive";
|
||||||
|
if (health === "failed") return "failed";
|
||||||
|
if (health === "disabled") return "disabled";
|
||||||
|
if (STATUS_LOADING_STATES.has(health)) return "loading";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(health, enabled) {
|
||||||
|
if (!enabled) return "Disabled";
|
||||||
|
if (health === "healthy") return "Active";
|
||||||
|
if (health === "needs_attention") return "Needs Attention";
|
||||||
|
if (health === "active") return "Active";
|
||||||
|
if (health === "inactive") return "Inactive";
|
||||||
|
if (health === "failed") return "Failed";
|
||||||
|
if (!health || health === "unknown") return "Unknown";
|
||||||
|
if (STATUS_LOADING_STATES.has(health)) return health;
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkify(str) {
|
||||||
|
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
if (h > 0) return h + "h " + m + "m " + s + "s";
|
||||||
|
if (m > 0) return m + "m " + s + "s";
|
||||||
|
return s + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function apiFetch(path, options) {
|
||||||
|
const res = await fetch(path, options || {});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = res.status + " " + res.statusText;
|
||||||
|
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
84
app/sovran_systemsos_web/static/js/rebuild.js
Normal file
84
app/sovran_systemsos_web/static/js/rebuild.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Rebuild modal ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openRebuildModal() {
|
||||||
|
if (!$rebuildModal) return;
|
||||||
|
_rebuildLog = "";
|
||||||
|
_rebuildLogOffset = 0;
|
||||||
|
_rebuildServerDown = false;
|
||||||
|
_rebuildFinished = false;
|
||||||
|
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||||
|
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||||
|
var label = _rebuildFeatureName || "feature";
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
|
||||||
|
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||||
|
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
||||||
|
if ($rebuildSave) $rebuildSave.style.display = "none";
|
||||||
|
if ($rebuildClose) $rebuildClose.disabled = true;
|
||||||
|
$rebuildModal.classList.add("open");
|
||||||
|
// Delay first poll slightly to let the rebuild service start and clear stale log
|
||||||
|
setTimeout(startRebuildPoll, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRebuildModal() {
|
||||||
|
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||||
|
stopRebuildPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRebuildLog(text) {
|
||||||
|
if (!text) return;
|
||||||
|
_rebuildLog += text;
|
||||||
|
// Log is collected silently for error reports — not displayed to user
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRebuildPoll() {
|
||||||
|
pollRebuildStatus();
|
||||||
|
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRebuildPoll() {
|
||||||
|
if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRebuildStatus() {
|
||||||
|
if (_rebuildFinished) return;
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
||||||
|
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||||
|
if (data.log) appendRebuildLog(data.log);
|
||||||
|
_rebuildLogOffset = data.offset;
|
||||||
|
if (data.running) return;
|
||||||
|
_rebuildFinished = true;
|
||||||
|
stopRebuildPoll();
|
||||||
|
onRebuildDone(data.result === "success");
|
||||||
|
} catch (err) {
|
||||||
|
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRebuildDone(success) {
|
||||||
|
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||||
|
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||||
|
if (success) {
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
||||||
|
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||||
|
setTimeout(function() { window.location.reload(); }, 1200);
|
||||||
|
} else {
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||||
|
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||||
|
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRebuildErrorReport() {
|
||||||
|
var blob = new Blob([_rebuildLog], { type: "text/plain" });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
478
app/sovran_systemsos_web/static/js/service-detail.js
Normal file
478
app/sovran_systemsos_web/static/js/service-detail.js
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Service detail modal ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCredsHtml(credentials, unit) {
|
||||||
|
var html = "";
|
||||||
|
for (var i = 0; i < credentials.length; i++) {
|
||||||
|
var cred = credentials[i];
|
||||||
|
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||||
|
var displayValue = linkify(cred.value);
|
||||||
|
var qrBlock = "";
|
||||||
|
if (cred.qrcode) {
|
||||||
|
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
||||||
|
}
|
||||||
|
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attachCopyHandlers(container) {
|
||||||
|
container.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
|
||||||
|
btn.addEventListener("click", function() {
|
||||||
|
var target = document.getElementById(btn.dataset.target);
|
||||||
|
if (!target) return;
|
||||||
|
var text = target.textContent;
|
||||||
|
|
||||||
|
function onSuccess() {
|
||||||
|
btn.textContent = "Copied!";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy() {
|
||||||
|
var ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.left = "-9999px";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy);
|
||||||
|
} else {
|
||||||
|
fallbackCopy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openServiceDetailModal(unit, name, icon) {
|
||||||
|
if (!$credsModal) return;
|
||||||
|
if ($credsTitle) $credsTitle.textContent = name;
|
||||||
|
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||||
|
$credsModal.classList.add("open");
|
||||||
|
|
||||||
|
try {
|
||||||
|
var url = "/api/service-detail/" + encodeURIComponent(unit);
|
||||||
|
if (icon) url += "?icon=" + encodeURIComponent(icon);
|
||||||
|
var data = await apiFetch(url);
|
||||||
|
var html = "";
|
||||||
|
|
||||||
|
// Section A: Description
|
||||||
|
if (data.description) {
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section B: Status
|
||||||
|
// When a feature override is present, use the feature's enabled state so the
|
||||||
|
// modal matches what the dashboard tile shows (feature toggle is authoritative).
|
||||||
|
var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled;
|
||||||
|
var effectiveHealth = data.feature && !data.feature.enabled
|
||||||
|
? "disabled"
|
||||||
|
: (data.health || data.status);
|
||||||
|
var sc = statusClass(effectiveHealth);
|
||||||
|
var st = statusText(effectiveHealth, effectiveEnabled);
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Status</div>' +
|
||||||
|
'<div class="svc-detail-status">' +
|
||||||
|
'<span class="status-dot ' + sc + '"></span>' +
|
||||||
|
'<span>' + escHtml(st) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Section C: Ports (only if service has port_requirements)
|
||||||
|
if (data.port_statuses && data.port_statuses.length > 0) {
|
||||||
|
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
|
||||||
|
var portTableRows = "";
|
||||||
|
data.port_statuses.forEach(function(p) {
|
||||||
|
var statusIcon, statusClass2;
|
||||||
|
if (p.status === "listening") {
|
||||||
|
statusIcon = "✅ Open";
|
||||||
|
statusClass2 = "port-status-listening";
|
||||||
|
} else if (p.status === "firewall_open") {
|
||||||
|
statusIcon = "🟡 Firewall open";
|
||||||
|
statusClass2 = "port-status-open";
|
||||||
|
} else if (p.status === "closed") {
|
||||||
|
statusIcon = "🔴 Closed";
|
||||||
|
statusClass2 = "port-status-closed";
|
||||||
|
} else {
|
||||||
|
statusIcon = "— Unknown";
|
||||||
|
statusClass2 = "port-status-unknown";
|
||||||
|
}
|
||||||
|
var desc = p.description;
|
||||||
|
var portNum = parseInt(p.port, 10);
|
||||||
|
if (portNum === 80 || portNum === 443) {
|
||||||
|
desc += " (shared — all services)";
|
||||||
|
}
|
||||||
|
portTableRows += '<tr>' +
|
||||||
|
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||||
|
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||||
|
'<td class="svc-detail-port-table-desc">' + escHtml(desc) + '</td>' +
|
||||||
|
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
var troubleshootHtml = "";
|
||||||
|
if (anyPortClosed) {
|
||||||
|
var sharedPorts = [];
|
||||||
|
var specificPorts = [];
|
||||||
|
data.port_statuses.forEach(function(p) {
|
||||||
|
if (p.status === "closed") {
|
||||||
|
var portNum = parseInt(p.port, 10);
|
||||||
|
if (portNum === 80 || portNum === 443) {
|
||||||
|
sharedPorts.push(p);
|
||||||
|
} else {
|
||||||
|
specificPorts.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var troubleParts = [];
|
||||||
|
|
||||||
|
if (sharedPorts.length > 0) {
|
||||||
|
troubleParts.push(
|
||||||
|
'<strong>⚠️ Ports 80 and 443 need to be forwarded on your router.</strong>' +
|
||||||
|
'<p style="margin-top:8px">These are <strong>shared system ports</strong> — you only need to set them up once and they cover all your domain-based services ' +
|
||||||
|
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).</p>' +
|
||||||
|
'<p style="margin-top:8px">If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:</p>' +
|
||||||
|
'<ol>' +
|
||||||
|
'<li>Log into your router\'s admin panel (usually <code>http://192.168.1.1</code>)</li>' +
|
||||||
|
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
|
||||||
|
'<li>Forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong> to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
||||||
|
'<li>Save your router settings</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'<p style="margin-top:8px">💡 Once these two ports are forwarded, you won\'t see this warning on any service again.</p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specificPorts.length > 0) {
|
||||||
|
var portList = specificPorts.map(function(p) {
|
||||||
|
return '<strong>' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ')</strong> — ' + escHtml(p.description);
|
||||||
|
}).join('<br>');
|
||||||
|
|
||||||
|
troubleParts.push(
|
||||||
|
'<strong>⚠️ This service requires additional ports to be forwarded:</strong>' +
|
||||||
|
'<p style="margin-top:8px">' + portList + '</p>' +
|
||||||
|
'<ol>' +
|
||||||
|
'<li>Log into your router\'s admin panel</li>' +
|
||||||
|
'<li>Forward each port listed above to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
||||||
|
'<li>Save your router settings</li>' +
|
||||||
|
'</ol>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
troubleshootHtml = '<div class="svc-detail-troubleshoot">' + troubleParts.join('<hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);margin:16px 0">') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Port Status</div>' +
|
||||||
|
'<table class="svc-detail-port-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + portTableRows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
troubleshootHtml +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section D: Domain (only if service needs_domain)
|
||||||
|
if (data.needs_domain) {
|
||||||
|
var domainStatusHtml = "";
|
||||||
|
var ds = data.domain_status || {};
|
||||||
|
var domainBadge = "";
|
||||||
|
|
||||||
|
if (data.domain) {
|
||||||
|
if (ds.status === "connected") {
|
||||||
|
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
|
||||||
|
} else if (ds.status === "dns_mismatch") {
|
||||||
|
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
|
||||||
|
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||||
|
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
|
||||||
|
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
|
||||||
|
'<ol>' +
|
||||||
|
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
||||||
|
'<li>Find your domain and check the Dynamic DNS record</li>' +
|
||||||
|
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
||||||
|
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'</div>';
|
||||||
|
} else if (ds.status === "unresolvable") {
|
||||||
|
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
|
||||||
|
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||||
|
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
|
||||||
|
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
|
||||||
|
'<ol>' +
|
||||||
|
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
||||||
|
'<li>Find the domain you purchased for this service</li>' +
|
||||||
|
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
||||||
|
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
|
||||||
|
'<li>You can re-enter it in the Feature Manager to update your configuration</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
|
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
|
||||||
|
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||||
|
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
|
||||||
|
'<p style="margin-top:8px">To get this service working:</p>' +
|
||||||
|
'<ol>' +
|
||||||
|
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
|
||||||
|
'<li>Go to the <strong>Feature Manager</strong> in the sidebar</li>' +
|
||||||
|
'<li>Find this service and configure your domain through the setup wizard</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Domain</div>' +
|
||||||
|
domainBadge +
|
||||||
|
domainStatusHtml +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section E: Credentials & Links
|
||||||
|
if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||||
|
_renderCredsHtml(data.credentials, unit) +
|
||||||
|
(unit === "matrix-synapse.service" ?
|
||||||
|
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||||
|
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||||
|
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||||
|
'</div>' : "") +
|
||||||
|
'</div>';
|
||||||
|
} else if (!data.enabled && !data.feature) {
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<p class="creds-empty">This service is not enabled in your configuration.</p>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section F: Addon Feature toggle
|
||||||
|
if (data.feature) {
|
||||||
|
var feat = data.feature;
|
||||||
|
// Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state
|
||||||
|
if (!_featuresData) {
|
||||||
|
_featuresData = { features: [feat], ssl_email_configured: false };
|
||||||
|
} else {
|
||||||
|
var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; });
|
||||||
|
if (fidx >= 0) { _featuresData.features[fidx] = feat; }
|
||||||
|
else { _featuresData.features.push(feat); }
|
||||||
|
}
|
||||||
|
var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled";
|
||||||
|
var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off";
|
||||||
|
var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature";
|
||||||
|
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
|
||||||
|
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||||
|
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
|
||||||
|
? "\u20BF Bitcoin Node Selection"
|
||||||
|
: "\uD83D\uDD27 Addon Feature";
|
||||||
|
|
||||||
|
// Description: prefer the feature's own description over a generic fallback
|
||||||
|
var addonDesc = feat.description
|
||||||
|
? feat.description
|
||||||
|
: "This is an optional addon feature. You can enable or disable it at any time.";
|
||||||
|
|
||||||
|
// Conflicts warning: list mutually-exclusive feature names when present
|
||||||
|
var conflictsHtml = "";
|
||||||
|
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
|
||||||
|
var conflictNames = feat.conflicts_with.map(function(cid) {
|
||||||
|
if (_featuresData && Array.isArray(_featuresData.features)) {
|
||||||
|
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||||
|
if (cf) return cf.name;
|
||||||
|
}
|
||||||
|
return cid;
|
||||||
|
});
|
||||||
|
conflictsHtml = '<div class="feature-conflict-warning">\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">' + addonSectionTitle + '</div>' +
|
||||||
|
'<p class="svc-detail-desc">' + escHtml(addonDesc) + '</p>' +
|
||||||
|
conflictsHtml +
|
||||||
|
'<div class="svc-detail-addon-row">' +
|
||||||
|
'<span class="svc-detail-addon-status ' + addonStatusCls + '">' + addonStatusLabel + '</span>' +
|
||||||
|
'<button class="' + addonBtnCls + '" id="svc-detail-addon-btn">' + escHtml(addonBtnLabel) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$credsBody.innerHTML = html;
|
||||||
|
_attachCopyHandlers($credsBody);
|
||||||
|
|
||||||
|
if (unit === "matrix-synapse.service") {
|
||||||
|
var addBtn = document.getElementById("matrix-add-user-btn");
|
||||||
|
var changePwBtn = document.getElementById("matrix-change-pw-btn");
|
||||||
|
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); });
|
||||||
|
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.feature) {
|
||||||
|
var addonBtn = document.getElementById("svc-detail-addon-btn");
|
||||||
|
if (addonBtn) {
|
||||||
|
var addonFeat = data.feature;
|
||||||
|
addonBtn.addEventListener("click", function() {
|
||||||
|
closeCredsModal();
|
||||||
|
handleFeatureToggle(addonFeat, !addonFeat.enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credentials info modal ────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openCredsModal(unit, name) {
|
||||||
|
if (!$credsModal) return;
|
||||||
|
if ($credsTitle) $credsTitle.textContent = name + " — Connection Info";
|
||||||
|
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||||
|
$credsModal.classList.add("open");
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
|
||||||
|
if (!data.credentials || data.credentials.length === 0) {
|
||||||
|
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = _renderCredsHtml(data.credentials, unit);
|
||||||
|
if (unit === "matrix-synapse.service") {
|
||||||
|
html += '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||||
|
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||||
|
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
$credsBody.innerHTML = html;
|
||||||
|
_attachCopyHandlers($credsBody);
|
||||||
|
if (unit === "matrix-synapse.service") {
|
||||||
|
var addBtn = document.getElementById("matrix-add-user-btn");
|
||||||
|
var changePwBtn = document.getElementById("matrix-change-pw-btn");
|
||||||
|
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); });
|
||||||
|
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
$credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMatrixCreateUserModal(unit, name, icon) {
|
||||||
|
if (!$credsBody) return;
|
||||||
|
$credsBody.innerHTML =
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-username">Username</label>' +
|
||||||
|
'<input class="matrix-form-input" type="text" id="matrix-new-username" placeholder="alice" autocomplete="off"></div>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-password">Password</label>' +
|
||||||
|
'<input class="matrix-form-input" type="password" id="matrix-new-password" placeholder="Strong password" autocomplete="new-password"></div>' +
|
||||||
|
'<div class="matrix-form-checkbox-row"><input type="checkbox" id="matrix-new-admin"><label class="matrix-form-label" for="matrix-new-admin" style="margin:0">Make admin</label></div>' +
|
||||||
|
'<div class="matrix-form-actions">' +
|
||||||
|
'<button class="matrix-form-back" id="matrix-create-back-btn">← Back</button>' +
|
||||||
|
'<button class="matrix-form-submit" id="matrix-create-submit-btn">Create User</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="matrix-form-result" id="matrix-create-result"></div>';
|
||||||
|
|
||||||
|
document.getElementById("matrix-create-back-btn").addEventListener("click", function() {
|
||||||
|
openServiceDetailModal(unit, name, icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() {
|
||||||
|
var submitBtn = document.getElementById("matrix-create-submit-btn");
|
||||||
|
var resultEl = document.getElementById("matrix-create-result");
|
||||||
|
var username = (document.getElementById("matrix-new-username").value || "").trim();
|
||||||
|
var password = document.getElementById("matrix-new-password").value || "";
|
||||||
|
var isAdmin = document.getElementById("matrix-new-admin").checked;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
resultEl.className = "matrix-form-result error";
|
||||||
|
resultEl.textContent = "Username and password are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = "Creating…";
|
||||||
|
resultEl.className = "matrix-form-result";
|
||||||
|
resultEl.textContent = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await apiFetch("/api/matrix/create-user", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: username, password: password, admin: isAdmin })
|
||||||
|
});
|
||||||
|
resultEl.className = "matrix-form-result success";
|
||||||
|
resultEl.textContent = "✅ User @" + escHtml(resp.username) + " created successfully.";
|
||||||
|
submitBtn.textContent = "Create User";
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
} catch (err) {
|
||||||
|
resultEl.className = "matrix-form-result error";
|
||||||
|
resultEl.textContent = "❌ " + (err.message || "Failed to create user.");
|
||||||
|
submitBtn.textContent = "Create User";
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMatrixChangePasswordModal(unit, name, icon) {
|
||||||
|
if (!$credsBody) return;
|
||||||
|
$credsBody.innerHTML =
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-username">Username (localpart only, e.g. <em>alice</em>)</label>' +
|
||||||
|
'<input class="matrix-form-input" type="text" id="matrix-chpw-username" placeholder="alice" autocomplete="off"></div>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-password">New Password</label>' +
|
||||||
|
'<input class="matrix-form-input" type="password" id="matrix-chpw-password" placeholder="New strong password" autocomplete="new-password"></div>' +
|
||||||
|
'<div class="matrix-form-actions">' +
|
||||||
|
'<button class="matrix-form-back" id="matrix-chpw-back-btn">← Back</button>' +
|
||||||
|
'<button class="matrix-form-submit" id="matrix-chpw-submit-btn">Change Password</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="matrix-form-result" id="matrix-chpw-result"></div>';
|
||||||
|
|
||||||
|
document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() {
|
||||||
|
openServiceDetailModal(unit, name, icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() {
|
||||||
|
var submitBtn = document.getElementById("matrix-chpw-submit-btn");
|
||||||
|
var resultEl = document.getElementById("matrix-chpw-result");
|
||||||
|
var username = (document.getElementById("matrix-chpw-username").value || "").trim();
|
||||||
|
var newPassword = document.getElementById("matrix-chpw-password").value || "";
|
||||||
|
|
||||||
|
if (!username || !newPassword) {
|
||||||
|
resultEl.className = "matrix-form-result error";
|
||||||
|
resultEl.textContent = "Username and new password are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = "Changing…";
|
||||||
|
resultEl.className = "matrix-form-result";
|
||||||
|
resultEl.textContent = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await apiFetch("/api/matrix/change-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: username, new_password: newPassword })
|
||||||
|
});
|
||||||
|
resultEl.className = "matrix-form-result success";
|
||||||
|
resultEl.textContent = "✅ Password for @" + escHtml(resp.username) + " changed successfully.";
|
||||||
|
submitBtn.textContent = "Change Password";
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
} catch (err) {
|
||||||
|
resultEl.className = "matrix-form-result error";
|
||||||
|
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
|
||||||
|
submitBtn.textContent = "Change Password";
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
|
||||||
94
app/sovran_systemsos_web/static/js/state.js
Normal file
94
app/sovran_systemsos_web/static/js/state.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _servicesCache = [];
|
||||||
|
let _categoryLabels = {};
|
||||||
|
let _updateLog = "";
|
||||||
|
let _updatePollTimer = null;
|
||||||
|
let _updateLogOffset = 0;
|
||||||
|
let _serverWasDown = false;
|
||||||
|
let _updateFinished = false;
|
||||||
|
let _supportTimerInt = null;
|
||||||
|
let _supportEnabledAt = null;
|
||||||
|
let _supportStatus = null; // last fetched /api/support/status payload
|
||||||
|
let _walletUnlockTimerInt = null;
|
||||||
|
let _cachedExternalIp = null;
|
||||||
|
|
||||||
|
// Feature Manager state
|
||||||
|
let _featuresData = null;
|
||||||
|
let _rebuildLog = "";
|
||||||
|
let _rebuildLogOffset = 0;
|
||||||
|
let _rebuildPollTimer = null;
|
||||||
|
let _rebuildFinished = false;
|
||||||
|
let _rebuildServerDown = false;
|
||||||
|
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
||||||
|
let _rebuildFeatureName = "";
|
||||||
|
let _rebuildIsEnabling = true;
|
||||||
|
|
||||||
|
// ── DOM refs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const $tilesArea = document.getElementById("tiles-area");
|
||||||
|
const $sidebarSupport = document.getElementById("sidebar-support");
|
||||||
|
const $sidebarFeatures = document.getElementById("sidebar-features");
|
||||||
|
const $updateBtn = document.getElementById("btn-update");
|
||||||
|
const $updateBadge = document.getElementById("update-badge");
|
||||||
|
const $refreshBtn = document.getElementById("btn-refresh");
|
||||||
|
const $internalIp = document.getElementById("ip-internal");
|
||||||
|
const $externalIp = document.getElementById("ip-external");
|
||||||
|
|
||||||
|
const $modal = document.getElementById("update-modal");
|
||||||
|
const $modalSpinner = document.getElementById("modal-spinner");
|
||||||
|
const $modalStatus = document.getElementById("modal-status");
|
||||||
|
const $modalLog = document.getElementById("modal-log");
|
||||||
|
const $btnReboot = document.getElementById("btn-reboot");
|
||||||
|
const $btnSave = document.getElementById("btn-save-report");
|
||||||
|
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
|
||||||
|
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||||
|
|
||||||
|
const $credsModal = document.getElementById("creds-modal");
|
||||||
|
const $credsTitle = document.getElementById("creds-modal-title");
|
||||||
|
const $credsBody = document.getElementById("creds-body");
|
||||||
|
const $credsCloseBtn = document.getElementById("creds-close-btn");
|
||||||
|
|
||||||
|
const $supportModal = document.getElementById("support-modal");
|
||||||
|
const $supportBody = document.getElementById("support-body");
|
||||||
|
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||||
|
|
||||||
|
// Feature Manager — rebuild modal
|
||||||
|
const $rebuildModal = document.getElementById("rebuild-modal");
|
||||||
|
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
||||||
|
const $rebuildStatus = document.getElementById("rebuild-status");
|
||||||
|
const $rebuildLog = document.getElementById("rebuild-log");
|
||||||
|
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||||
|
const $rebuildSave = document.getElementById("rebuild-save-report");
|
||||||
|
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
||||||
|
|
||||||
|
// Feature Manager — domain setup modal
|
||||||
|
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||||
|
const $domainSetupTitle = document.getElementById("domain-setup-title");
|
||||||
|
const $domainSetupBody = document.getElementById("domain-setup-body");
|
||||||
|
const $domainSetupClose = document.getElementById("domain-setup-close-btn");
|
||||||
|
|
||||||
|
// Feature Manager — SSL email modal
|
||||||
|
const $sslEmailModal = document.getElementById("ssl-email-modal");
|
||||||
|
const $sslEmailInput = document.getElementById("ssl-email-input");
|
||||||
|
const $sslEmailSave = document.getElementById("ssl-email-save-btn");
|
||||||
|
const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn");
|
||||||
|
const $sslEmailClose = document.getElementById("ssl-email-close-btn");
|
||||||
|
|
||||||
|
// Feature Manager — confirm modal
|
||||||
|
const $featureConfirmModal = document.getElementById("feature-confirm-modal");
|
||||||
|
const $featureConfirmMsg = document.getElementById("feature-confirm-message");
|
||||||
|
const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn");
|
||||||
|
const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn");
|
||||||
|
const $featureConfirmClose = document.getElementById("feature-confirm-close-btn");
|
||||||
|
|
||||||
|
// Port Requirements modal
|
||||||
|
const $portReqModal = document.getElementById("port-requirements-modal");
|
||||||
|
const $portReqBody = document.getElementById("port-req-body");
|
||||||
|
const $portReqClose = document.getElementById("port-req-close-btn");
|
||||||
|
|
||||||
|
// System status banner
|
||||||
|
// (removed — health is now shown per-tile via the composite health field)
|
||||||
261
app/sovran_systemsos_web/static/js/support.js
Normal file
261
app/sovran_systemsos_web/static/js/support.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Tech Support modal ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openSupportModal() {
|
||||||
|
if (!$supportModal) return;
|
||||||
|
$supportModal.classList.add("open");
|
||||||
|
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||||
|
try {
|
||||||
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
|
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); }
|
||||||
|
else { renderSupportInactive(); }
|
||||||
|
} catch (err) {
|
||||||
|
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportInactive() {
|
||||||
|
stopSupportTimer();
|
||||||
|
var ip = _cachedExternalIp || "loading…";
|
||||||
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big">🛟</div>',
|
||||||
|
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
|
||||||
|
'<p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p>',
|
||||||
|
'<div class="support-info-box">',
|
||||||
|
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||||
|
'<div class="support-info-hint">This IP will be shared with Sovran Systems support</div>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Protection</span></div>',
|
||||||
|
'<p class="support-wallet-desc">Wallet files (LND, Sparrow, Bisq) are <strong>protected by default</strong>. Support staff cannot access your private keys unless you explicitly grant access.</p>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||||
|
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||||
|
'<li>Our SSH key is added only to that restricted account</li>',
|
||||||
|
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||||
|
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||||
|
'<li>All session events are logged for your audit</li>',
|
||||||
|
'</ol></div>',
|
||||||
|
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||||
|
'<p class="support-fine-print">You can revoke access at any time. Wallet files are protected unless you unlock them.</p>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportActive(status) {
|
||||||
|
var ip = _cachedExternalIp || "loading…";
|
||||||
|
var walletProtected = status && status.wallet_protected;
|
||||||
|
var walletUnlocked = status && status.wallet_unlocked;
|
||||||
|
var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : "";
|
||||||
|
var protectedPaths = (status && status.protected_paths && status.protected_paths.length)
|
||||||
|
? status.protected_paths : [];
|
||||||
|
|
||||||
|
var walletSection;
|
||||||
|
if (walletProtected) {
|
||||||
|
if (walletUnlocked) {
|
||||||
|
walletSection = [
|
||||||
|
'<div class="support-wallet-box support-wallet-unlocked">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">🔓</span><span class="support-wallet-title">Wallet Access: UNLOCKED</span></div>',
|
||||||
|
'<p class="support-wallet-desc">You have granted support temporary access to wallet files' + (unlockUntil ? ' until <strong>' + escHtml(unlockUntil) + '</strong>' : '') + '.</p>',
|
||||||
|
'<button class="btn support-btn-wallet-lock" id="btn-wallet-lock">Re-lock Wallet Now</button>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
} else {
|
||||||
|
var pathList = protectedPaths.length
|
||||||
|
? '<ul class="support-wallet-paths">' + protectedPaths.map(function(p){ return '<li>' + escHtml(p) + '</li>'; }).join("") + '</ul>'
|
||||||
|
: '';
|
||||||
|
walletSection = [
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Files: Protected</span></div>',
|
||||||
|
'<p class="support-wallet-desc">Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.</p>',
|
||||||
|
pathList,
|
||||||
|
'<div class="support-wallet-unlock-row">',
|
||||||
|
'<select id="wallet-unlock-duration" class="support-unlock-select">',
|
||||||
|
'<option value="3600">1 hour</option>',
|
||||||
|
'<option value="1800">30 minutes</option>',
|
||||||
|
'<option value="7200">2 hours</option>',
|
||||||
|
'</select>',
|
||||||
|
'<button class="btn support-btn-wallet-unlock" id="btn-wallet-unlock">Grant Wallet Access</button>',
|
||||||
|
'</div>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
walletSection = [
|
||||||
|
'<div class="support-wallet-box support-wallet-warning">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">⚠️</span><span class="support-wallet-title">Wallet Protection Unavailable</span></div>',
|
||||||
|
'<p class="support-wallet-desc">The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.</p>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big support-active-icon">🔓</div>',
|
||||||
|
'<h3 class="support-heading support-active-heading">Support Access is Active</h3>',
|
||||||
|
'<p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p>',
|
||||||
|
'<div class="support-info-box support-active-box">',
|
||||||
|
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||||
|
'<div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div>',
|
||||||
|
'</div>',
|
||||||
|
walletSection,
|
||||||
|
'<button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button>',
|
||||||
|
'<p class="support-fine-print">This will remove the SSH key and revoke all wallet access immediately.</p>',
|
||||||
|
'<button class="btn support-btn-auditlog" id="btn-support-audit">View Audit Log</button>',
|
||||||
|
'</div>',
|
||||||
|
'<div id="support-audit-container" class="support-audit-container" style="display:none;"></div>',
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
|
||||||
|
document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog);
|
||||||
|
if (walletProtected && !walletUnlocked) {
|
||||||
|
document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock);
|
||||||
|
}
|
||||||
|
if (walletProtected && walletUnlocked) {
|
||||||
|
document.getElementById("btn-wallet-lock").addEventListener("click", walletLock);
|
||||||
|
}
|
||||||
|
startSupportTimer();
|
||||||
|
if (walletUnlocked && status.wallet_unlocked_until) {
|
||||||
|
startWalletUnlockTimer(status.wallet_unlocked_until);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportRemoved(verified) {
|
||||||
|
stopSupportTimer();
|
||||||
|
stopWalletUnlockTimer();
|
||||||
|
var icon = verified ? "✅" : "⚠️";
|
||||||
|
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone.";
|
||||||
|
var vclass = verified ? "verified-gone" : "verify-warning";
|
||||||
|
var vlabel = verified ? "✓ Removed — No access" : "⚠ Verify by rebooting";
|
||||||
|
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">' + icon + '</div><h3 class="support-heading">Support Session Ended</h3><p class="support-desc">' + escHtml(msg) + '</p><div class="support-verify-box"><span class="support-verify-label">SSH Key Status:</span><span class="support-verify-value ' + vclass + '">' + vlabel + '</span></div><button class="btn support-btn-done" id="btn-support-done">Done</button></div>';
|
||||||
|
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableSupport() {
|
||||||
|
var btn = document.getElementById("btn-support-enable");
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/support/enable", { method: "POST" });
|
||||||
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
|
_supportEnabledAt = status.enabled_at;
|
||||||
|
renderSupportActive(status);
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||||
|
alert("Failed to enable support access. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableSupport() {
|
||||||
|
var btn = document.getElementById("btn-support-disable");
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; }
|
||||||
|
try {
|
||||||
|
var result = await apiFetch("/api/support/disable", { method: "POST" });
|
||||||
|
renderSupportRemoved(result.verified);
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; }
|
||||||
|
alert("Failed to disable support access. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walletUnlock() {
|
||||||
|
var btn = document.getElementById("btn-wallet-unlock");
|
||||||
|
var sel = document.getElementById("wallet-unlock-duration");
|
||||||
|
var duration = sel ? parseInt(sel.value, 10) : 3600;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; }
|
||||||
|
try {
|
||||||
|
var result = await apiFetch("/api/support/wallet-unlock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ duration: duration }),
|
||||||
|
});
|
||||||
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
|
renderSupportActive(status);
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; }
|
||||||
|
alert("Failed to unlock wallet access: " + (err.message || "Unknown error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walletLock() {
|
||||||
|
var btn = document.getElementById("btn-wallet-lock");
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Locking…"; }
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/support/wallet-lock", { method: "POST" });
|
||||||
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
|
renderSupportActive(status);
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; }
|
||||||
|
alert("Failed to re-lock wallet: " + (err.message || "Unknown error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAuditLog() {
|
||||||
|
var container = document.getElementById("support-audit-container");
|
||||||
|
if (!container) return;
|
||||||
|
if (container.style.display !== "none") {
|
||||||
|
container.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.style.display = "block";
|
||||||
|
container.innerHTML = '<p class="creds-loading">Loading audit log…</p>';
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/support/audit-log");
|
||||||
|
if (!data.entries || data.entries.length === 0) {
|
||||||
|
container.innerHTML = '<p class="support-audit-empty">No audit events recorded yet.</p>';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="support-audit-log">' +
|
||||||
|
data.entries.map(function(e) { return '<div class="support-audit-entry">' + escHtml(e) + '</div>'; }).join("") +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = '<p class="creds-empty">Could not load audit log.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSupportTimer() {
|
||||||
|
stopSupportTimer();
|
||||||
|
updateSupportTimer();
|
||||||
|
_supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSupportTimer() {
|
||||||
|
if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSupportTimer() {
|
||||||
|
var el = document.getElementById("support-timer");
|
||||||
|
if (!el || !_supportEnabledAt) return;
|
||||||
|
var elapsed = (Date.now() / 1000) - _supportEnabledAt;
|
||||||
|
el.textContent = formatDuration(Math.max(0, elapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWalletUnlockTimer(expiresAt) {
|
||||||
|
stopWalletUnlockTimer();
|
||||||
|
_walletUnlockTimerInt = setInterval(function() {
|
||||||
|
if (Date.now() / 1000 >= expiresAt) {
|
||||||
|
stopWalletUnlockTimer();
|
||||||
|
// Refresh the support modal to show re-locked state
|
||||||
|
apiFetch("/api/support/status").then(function(status) {
|
||||||
|
_supportStatus = status;
|
||||||
|
renderSupportActive(status);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWalletUnlockTimer() {
|
||||||
|
if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSupportModal() {
|
||||||
|
if ($supportModal) $supportModal.classList.remove("open");
|
||||||
|
stopSupportTimer();
|
||||||
|
stopWalletUnlockTimer();
|
||||||
|
}
|
||||||
151
app/sovran_systemsos_web/static/js/tiles.js
Normal file
151
app/sovran_systemsos_web/static/js/tiles.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Render: initial build ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildTiles(services, categoryLabels) {
|
||||||
|
_servicesCache = services;
|
||||||
|
var grouped = {};
|
||||||
|
var supportServices = [];
|
||||||
|
for (var i = 0; i < services.length; i++) {
|
||||||
|
var svc = services[i];
|
||||||
|
// Support tiles go to the sidebar, not the main grid
|
||||||
|
if (svc.category === "support" || svc.type === "support") {
|
||||||
|
supportServices.push(svc);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var cat = svc.category || "other";
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(svc);
|
||||||
|
}
|
||||||
|
renderSidebarSupport(supportServices);
|
||||||
|
$tilesArea.innerHTML = "";
|
||||||
|
var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||||
|
Object.keys(grouped).forEach(function(k) {
|
||||||
|
if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k);
|
||||||
|
});
|
||||||
|
for (var j = 0; j < orderedKeys.length; j++) {
|
||||||
|
var catKey = orderedKeys[j];
|
||||||
|
var entries = grouped[catKey];
|
||||||
|
if (!entries || entries.length === 0) continue;
|
||||||
|
var label = categoryLabels[catKey] || catKey;
|
||||||
|
var section = document.createElement("div");
|
||||||
|
section.className = "category-section";
|
||||||
|
section.dataset.category = catKey;
|
||||||
|
section.innerHTML = '<div class="section-header">' + escHtml(label) + '</div><hr class="section-divider" /><div class="tiles-grid" data-cat="' + escHtml(catKey) + '"></div>';
|
||||||
|
var grid = section.querySelector(".tiles-grid");
|
||||||
|
for (var k = 0; k < entries.length; k++) {
|
||||||
|
grid.appendChild(buildTile(entries[k]));
|
||||||
|
}
|
||||||
|
$tilesArea.appendChild(section);
|
||||||
|
}
|
||||||
|
if ($tilesArea.children.length === 0) {
|
||||||
|
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebarSupport(supportServices) {
|
||||||
|
$sidebarSupport.innerHTML = "";
|
||||||
|
for (var i = 0; i < supportServices.length; i++) {
|
||||||
|
var svc = supportServices[i];
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.className = "sidebar-support-btn";
|
||||||
|
btn.innerHTML =
|
||||||
|
'<span class="sidebar-support-icon">🛟</span>' +
|
||||||
|
'<span class="sidebar-support-text">' +
|
||||||
|
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||||
|
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||||
|
'</span>';
|
||||||
|
btn.addEventListener("click", function() { openSupportModal(); });
|
||||||
|
$sidebarSupport.appendChild(btn);
|
||||||
|
}
|
||||||
|
if (supportServices.length > 0) {
|
||||||
|
var hr = document.createElement("hr");
|
||||||
|
hr.className = "sidebar-divider";
|
||||||
|
$sidebarSupport.appendChild(hr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTile(svc) {
|
||||||
|
var isSupport = svc.type === "support";
|
||||||
|
var sc = statusClass(svc.health || svc.status);
|
||||||
|
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||||
|
var dis = !svc.enabled;
|
||||||
|
|
||||||
|
var tile = document.createElement("div");
|
||||||
|
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
||||||
|
tile.dataset.unit = svc.unit;
|
||||||
|
tile.dataset.tileId = tileId(svc);
|
||||||
|
if (dis) tile.title = svc.name + " is not enabled in custom.nix";
|
||||||
|
|
||||||
|
if (isSupport) {
|
||||||
|
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click for help</span></div>';
|
||||||
|
tile.style.cursor = "pointer";
|
||||||
|
tile.addEventListener("click", function() { openSupportModal(); });
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||||
|
|
||||||
|
tile.style.cursor = "pointer";
|
||||||
|
tile.addEventListener("click", function() {
|
||||||
|
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: live update ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateTiles(services) {
|
||||||
|
_servicesCache = services;
|
||||||
|
for (var i = 0; i < services.length; i++) {
|
||||||
|
var svc = services[i];
|
||||||
|
if (svc.type === "support") continue;
|
||||||
|
var id = CSS.escape(tileId(svc));
|
||||||
|
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
||||||
|
if (!tile) continue;
|
||||||
|
var sc = statusClass(svc.health || svc.status);
|
||||||
|
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||||
|
var dot = tile.querySelector(".status-dot");
|
||||||
|
var text = tile.querySelector(".status-text");
|
||||||
|
if (dot) dot.className = "status-dot " + sc;
|
||||||
|
if (text) text.textContent = st;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service polling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _firstLoad = true;
|
||||||
|
|
||||||
|
async function refreshServices() {
|
||||||
|
try {
|
||||||
|
var services = await apiFetch("/api/services");
|
||||||
|
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||||
|
else { updateTiles(services); }
|
||||||
|
} catch (err) { console.warn("Failed to fetch services:", err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Network IPs ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadNetwork() {
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/network");
|
||||||
|
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
||||||
|
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
||||||
|
_cachedExternalIp = data.external_ip || "unavailable";
|
||||||
|
} catch (_) {
|
||||||
|
if ($internalIp) $internalIp.textContent = "—";
|
||||||
|
if ($externalIp) $externalIp.textContent = "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function checkUpdates() {
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/updates/check");
|
||||||
|
var hasUpdates = !!data.available;
|
||||||
|
if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates);
|
||||||
|
if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
120
app/sovran_systemsos_web/static/js/update.js
Normal file
120
app/sovran_systemsos_web/static/js/update.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openUpdateModal() {
|
||||||
|
if (!$modal) return;
|
||||||
|
_updateLog = "";
|
||||||
|
_updateLogOffset = 0;
|
||||||
|
_serverWasDown = false;
|
||||||
|
_updateFinished = false;
|
||||||
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
|
$modal.classList.add("open");
|
||||||
|
startUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUpdateModal() {
|
||||||
|
if (!$modal) return;
|
||||||
|
$modal.classList.remove("open");
|
||||||
|
stopUpdatePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(text) {
|
||||||
|
if (!text) return;
|
||||||
|
_updateLog += text;
|
||||||
|
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpdate() {
|
||||||
|
fetch("/api/updates/run", { method: "POST" })
|
||||||
|
.then(function(response) {
|
||||||
|
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||||
|
startUpdatePoll();
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
appendLog("[Error: failed to start update — " + err + "]\n");
|
||||||
|
onUpdateDone(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpdatePoll() {
|
||||||
|
pollUpdateStatus();
|
||||||
|
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopUpdatePoll() {
|
||||||
|
if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUpdateStatus() {
|
||||||
|
if (_updateFinished) return;
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||||
|
if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; }
|
||||||
|
if (data.log) appendLog(data.log);
|
||||||
|
_updateLogOffset = data.offset;
|
||||||
|
if (data.running) return;
|
||||||
|
_updateFinished = true;
|
||||||
|
stopUpdatePoll();
|
||||||
|
if (data.result === "success") onUpdateDone(true);
|
||||||
|
else onUpdateDone(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateDone(success) {
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
|
if (success) {
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||||
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||||
|
} else {
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||||
|
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||||
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveErrorReport() {
|
||||||
|
var blob = new Blob([_updateLog], { type: "text/plain" });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reboot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function doReboot() {
|
||||||
|
if ($modal) $modal.classList.remove("open");
|
||||||
|
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||||
|
stopUpdatePoll();
|
||||||
|
stopRebuildPoll();
|
||||||
|
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||||
|
fetch("/api/reboot", { method: "POST" }).catch(function() {});
|
||||||
|
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForServerReboot() {
|
||||||
|
fetch("/api/config", { cache: "no-store" })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.ok) window.location.reload();
|
||||||
|
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||||
|
})
|
||||||
|
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,16 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sovran_SystemsOS Hub</title>
|
<title>Sovran_SystemsOS Hub</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v={{ style_css_hash }}" />
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/header.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/features.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/support.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -182,6 +191,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js?v={{ app_js_hash }}"></script>
|
<script src="/static/js/constants.js"></script>
|
||||||
|
<script src="/static/js/state.js"></script>
|
||||||
|
<script src="/static/js/helpers.js"></script>
|
||||||
|
<script src="/static/js/tiles.js"></script>
|
||||||
|
<script src="/static/js/service-detail.js"></script>
|
||||||
|
<script src="/static/js/support.js"></script>
|
||||||
|
<script src="/static/js/update.js"></script>
|
||||||
|
<script src="/static/js/rebuild.js"></script>
|
||||||
|
<script src="/static/js/features.js"></script>
|
||||||
|
<script src="/static/js/events.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,16 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sovran_SystemsOS — First-Boot Setup</title>
|
<title>Sovran_SystemsOS — First-Boot Setup</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v={{ style_css_hash }}" />
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/header.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/features.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/support.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="onboarding-body">
|
<body class="onboarding-body">
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user