Split style.css and app.js into modular CSS/JS files under css/ and js/ directories
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/50712b31-5843-45c4-a8f1-3952656b636c Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
2493777a42
commit
815b195600
@@ -378,8 +378,6 @@ def _file_hash(filename: str) -> str:
|
||||
except FileNotFoundError:
|
||||
return "0"
|
||||
|
||||
_APP_JS_HASH = _file_hash("app.js")
|
||||
_STYLE_CSS_HASH = _file_hash("style.css")
|
||||
_ONBOARDING_JS_HASH = _file_hash("onboarding.js")
|
||||
|
||||
# ── Update check helpers ──────────────────────────────────────────
|
||||
@@ -1137,8 +1135,6 @@ def _verify_support_removed() -> bool:
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"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", {
|
||||
"request": request,
|
||||
"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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
|
||||
@@ -182,6 +191,15 @@
|
||||
</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>
|
||||
</html>
|
||||
@@ -4,7 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body class="onboarding-body">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user