feat: add Feature Manager to Sovran Hub dashboard
- flake.nix: import /etc/nixos/hub-overrides.nix alongside custom.nix - sovran-hub.nix: add hub-overrides-init service (seeds file if missing), sovran-hub-rebuild service (nixos-rebuild switch only), and feature_manager=true in generated config.json - server.py: add FEATURE_REGISTRY with 6 features (rdp, haven, element-calling, mempool, bip110, bitcoin-core); add hub-overrides.nix read/write helpers; add /api/features, /api/features/toggle, /api/rebuild/status, /api/domains/set, /api/domains/set-email, /api/domains/status endpoints; update /api/config to expose feature_manager - index.html: add domain setup modal, SSL email modal, feature confirm modal, and rebuild modal HTML - app.js: add Feature Manager rendering with sub-category layout, feature toggle cards with sliding toggles, domain setup flow, SSL email collection, conflict confirmation, rebuild polling - style.css: add Feature Manager styles (feature cards, toggle switch, domain badge, conflict warning, domain input fields)" Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
971b0797df
commit
b9c8c20347
@@ -13,10 +13,11 @@ import subprocess
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.requests import Request
|
from fastapi.requests import Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
@@ -31,6 +32,15 @@ UPDATE_LOG = "/var/log/sovran-hub-update.log"
|
|||||||
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
||||||
UPDATE_UNIT = "sovran-hub-update.service"
|
UPDATE_UNIT = "sovran-hub-update.service"
|
||||||
|
|
||||||
|
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
||||||
|
REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status"
|
||||||
|
REBUILD_UNIT = "sovran-hub-rebuild.service"
|
||||||
|
|
||||||
|
HUB_OVERRIDES_NIX = "/etc/nixos/hub-overrides.nix"
|
||||||
|
DOMAINS_DIR = "/var/lib/domains"
|
||||||
|
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
|
||||||
|
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
|
||||||
|
|
||||||
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
||||||
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
||||||
|
|
||||||
@@ -55,6 +65,85 @@ CATEGORY_ORDER = [
|
|||||||
("apps", "Self-Hosted Apps"),
|
("apps", "Self-Hosted Apps"),
|
||||||
("nostr", "Nostr"),
|
("nostr", "Nostr"),
|
||||||
("support", "Support"),
|
("support", "Support"),
|
||||||
|
("feature-manager", "Feature Manager"),
|
||||||
|
]
|
||||||
|
|
||||||
|
FEATURE_REGISTRY = [
|
||||||
|
{
|
||||||
|
"id": "rdp",
|
||||||
|
"name": "Remote Desktop (RDP)",
|
||||||
|
"description": "Access your desktop remotely via RDP client",
|
||||||
|
"category": "infrastructure",
|
||||||
|
"needs_domain": False,
|
||||||
|
"domain_name": None,
|
||||||
|
"needs_ddns": False,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "haven",
|
||||||
|
"name": "Haven NOSTR Relay",
|
||||||
|
"description": "Run your own private Nostr relay",
|
||||||
|
"category": "nostr",
|
||||||
|
"needs_domain": True,
|
||||||
|
"domain_name": "haven",
|
||||||
|
"needs_ddns": True,
|
||||||
|
"extra_fields": [
|
||||||
|
{
|
||||||
|
"id": "nostr_npub",
|
||||||
|
"label": "Nostr Public Key (npub1...)",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"current_value": "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"conflicts_with": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "element-calling",
|
||||||
|
"name": "Element Video & Audio Calling",
|
||||||
|
"description": "Add video/audio calling to Matrix via LiveKit",
|
||||||
|
"category": "communication",
|
||||||
|
"needs_domain": True,
|
||||||
|
"domain_name": "element-calling",
|
||||||
|
"needs_ddns": True,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": [],
|
||||||
|
"requires": ["matrix_domain"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mempool",
|
||||||
|
"name": "Mempool Explorer",
|
||||||
|
"description": "Bitcoin mempool visualization and explorer",
|
||||||
|
"category": "bitcoin",
|
||||||
|
"needs_domain": False,
|
||||||
|
"domain_name": None,
|
||||||
|
"needs_ddns": False,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bip110",
|
||||||
|
"name": "BIP-110 (Bitcoin Better Money)",
|
||||||
|
"description": "Bitcoin Knots with BIP-110 consensus changes",
|
||||||
|
"category": "bitcoin",
|
||||||
|
"needs_domain": False,
|
||||||
|
"domain_name": None,
|
||||||
|
"needs_ddns": False,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": ["bitcoin-core"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bitcoin-core",
|
||||||
|
"name": "Bitcoin Core",
|
||||||
|
"description": "Use Bitcoin Core instead of Bitcoin Knots",
|
||||||
|
"category": "bitcoin",
|
||||||
|
"needs_domain": False,
|
||||||
|
"domain_name": None,
|
||||||
|
"needs_ddns": False,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": ["bip110"],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
@@ -303,6 +392,77 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rebuild helpers (file-based, no systemctl) ───────────────────
|
||||||
|
|
||||||
|
def _read_rebuild_status() -> str:
|
||||||
|
"""Read the rebuild status file. Returns RUNNING, SUCCESS, FAILED, or IDLE."""
|
||||||
|
try:
|
||||||
|
with open(REBUILD_STATUS, "r") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "IDLE"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_rebuild_log(offset: int = 0) -> tuple[str, int]:
|
||||||
|
"""Read the rebuild log file from the given byte offset."""
|
||||||
|
try:
|
||||||
|
with open(REBUILD_LOG, "rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
if offset > size:
|
||||||
|
offset = 0
|
||||||
|
f.seek(offset)
|
||||||
|
chunk = f.read()
|
||||||
|
return chunk.decode(errors="replace"), offset + len(chunk)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "", 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── hub-overrides.nix helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_hub_overrides() -> tuple[dict, str | None]:
|
||||||
|
"""Parse hub-overrides.nix. Returns (features_dict, nostr_npub_or_none)."""
|
||||||
|
features: dict[str, bool] = {}
|
||||||
|
nostr_npub = None
|
||||||
|
try:
|
||||||
|
with open(HUB_OVERRIDES_NIX, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
for m in re.finditer(
|
||||||
|
r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(true|false)\s*;',
|
||||||
|
content,
|
||||||
|
):
|
||||||
|
features[m.group(1)] = m.group(2) == "true"
|
||||||
|
m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*"([^"]*)"', content)
|
||||||
|
if m2:
|
||||||
|
nostr_npub = m2.group(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return features, nostr_npub
|
||||||
|
|
||||||
|
|
||||||
|
def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None:
|
||||||
|
"""Write a complete hub-overrides.nix from the given state."""
|
||||||
|
lines = []
|
||||||
|
for feat_id, enabled in features.items():
|
||||||
|
val = "true" if enabled else "false"
|
||||||
|
lines.append(f" sovran_systemsOS.features.{feat_id} = {val};")
|
||||||
|
if nostr_npub:
|
||||||
|
lines.append(f' sovran_systemsOS.nostr_npub = "{nostr_npub}";')
|
||||||
|
body = "\n".join(lines) + "\n" if lines else ""
|
||||||
|
content = (
|
||||||
|
"# Auto-generated by Sovran Hub — do not edit manually\n"
|
||||||
|
"{ ... }:\n"
|
||||||
|
"{\n"
|
||||||
|
+ body
|
||||||
|
+ "}\n"
|
||||||
|
)
|
||||||
|
nix_dir = os.path.dirname(HUB_OVERRIDES_NIX)
|
||||||
|
if nix_dir:
|
||||||
|
os.makedirs(nix_dir, exist_ok=True)
|
||||||
|
with open(HUB_OVERRIDES_NIX, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
# ── Tech Support helpers ──────────────────────────────────────────
|
# ── Tech Support helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
def _is_support_active() -> bool:
|
def _is_support_active() -> bool:
|
||||||
@@ -422,6 +582,7 @@ async def api_config():
|
|||||||
"role": role,
|
"role": role,
|
||||||
"role_label": ROLE_LABELS.get(role, role),
|
"role_label": ROLE_LABELS.get(role, role),
|
||||||
"category_order": CATEGORY_ORDER,
|
"category_order": CATEGORY_ORDER,
|
||||||
|
"feature_manager": cfg.get("feature_manager", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -611,6 +772,232 @@ async def api_support_disable():
|
|||||||
return {"ok": True, "verified": verified, "message": "Support access removed and verified"}
|
return {"ok": True, "verified": verified, "message": "Support access removed and verified"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feature Manager endpoints ─────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/features")
|
||||||
|
async def api_features():
|
||||||
|
"""Return all toggleable features with current state and domain requirements."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
|
ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail")
|
||||||
|
ssl_email_configured = os.path.exists(ssl_email_path)
|
||||||
|
|
||||||
|
features = []
|
||||||
|
for feat in FEATURE_REGISTRY:
|
||||||
|
feat_id = feat["id"]
|
||||||
|
enabled = overrides.get(feat_id, False)
|
||||||
|
|
||||||
|
domain_name = feat.get("domain_name")
|
||||||
|
domain_configured = True
|
||||||
|
if domain_name:
|
||||||
|
domain_configured = os.path.exists(os.path.join(DOMAINS_DIR, domain_name))
|
||||||
|
|
||||||
|
extra_fields = []
|
||||||
|
for ef in feat.get("extra_fields", []):
|
||||||
|
ef_copy = dict(ef)
|
||||||
|
if ef["id"] == "nostr_npub":
|
||||||
|
ef_copy["current_value"] = nostr_npub or ""
|
||||||
|
extra_fields.append(ef_copy)
|
||||||
|
|
||||||
|
entry: dict = {
|
||||||
|
"id": feat_id,
|
||||||
|
"name": feat["name"],
|
||||||
|
"description": feat["description"],
|
||||||
|
"category": feat["category"],
|
||||||
|
"enabled": enabled,
|
||||||
|
"needs_domain": feat.get("needs_domain", False),
|
||||||
|
"domain_configured": domain_configured,
|
||||||
|
"domain_name": domain_name,
|
||||||
|
"needs_ddns": feat.get("needs_ddns", False),
|
||||||
|
"extra_fields": extra_fields,
|
||||||
|
"conflicts_with": feat.get("conflicts_with", []),
|
||||||
|
}
|
||||||
|
if "requires" in feat:
|
||||||
|
entry["requires"] = feat["requires"]
|
||||||
|
features.append(entry)
|
||||||
|
|
||||||
|
return {"features": features, "ssl_email_configured": ssl_email_configured}
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureToggleRequest(BaseModel):
|
||||||
|
feature: str
|
||||||
|
enabled: bool
|
||||||
|
extra: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/features/toggle")
|
||||||
|
async def api_features_toggle(req: FeatureToggleRequest):
|
||||||
|
"""Enable or disable a feature and trigger a system rebuild."""
|
||||||
|
feat_meta = next((f for f in FEATURE_REGISTRY if f["id"] == req.feature), None)
|
||||||
|
if not feat_meta:
|
||||||
|
raise HTTPException(status_code=404, detail="Feature not found")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
features, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
|
if req.enabled:
|
||||||
|
# Element-calling requires matrix domain
|
||||||
|
if req.feature == "element-calling":
|
||||||
|
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Element Calling requires a Matrix domain to be configured. "
|
||||||
|
"Please run `sovran-setup-domains` first or configure the Matrix domain."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Domain requirement check
|
||||||
|
if feat_meta.get("needs_domain") and feat_meta.get("domain_name"):
|
||||||
|
domain_path = os.path.join(DOMAINS_DIR, feat_meta["domain_name"])
|
||||||
|
if not os.path.exists(domain_path):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"error": "domain_required",
|
||||||
|
"domain_name": feat_meta["domain_name"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Haven requires nostr_npub
|
||||||
|
if req.feature == "haven":
|
||||||
|
npub = (req.extra or {}).get("nostr_npub", "").strip()
|
||||||
|
if npub:
|
||||||
|
nostr_npub = npub
|
||||||
|
elif not nostr_npub:
|
||||||
|
raise HTTPException(status_code=400, detail="nostr_npub is required for Haven")
|
||||||
|
|
||||||
|
# Auto-disable conflicting features
|
||||||
|
for conflict_id in feat_meta.get("conflicts_with", []):
|
||||||
|
features[conflict_id] = False
|
||||||
|
|
||||||
|
features[req.feature] = True
|
||||||
|
else:
|
||||||
|
features[req.feature] = False
|
||||||
|
|
||||||
|
# Persist any extra fields (nostr_npub)
|
||||||
|
new_npub = (req.extra or {}).get("nostr_npub", "").strip()
|
||||||
|
if new_npub:
|
||||||
|
nostr_npub = new_npub
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True)
|
||||||
|
with open(NOSTR_NPUB_FILE, "w") as f:
|
||||||
|
f.write(nostr_npub)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub)
|
||||||
|
|
||||||
|
# Start the rebuild service
|
||||||
|
await asyncio.create_subprocess_exec(
|
||||||
|
"systemctl", "reset-failed", REBUILD_UNIT,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"systemctl", "start", "--no-block", REBUILD_UNIT,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
return {"ok": True, "status": "rebuilding"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rebuild/status")
|
||||||
|
async def api_rebuild_status(offset: int = 0):
|
||||||
|
"""Poll endpoint for rebuild progress."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
status = await loop.run_in_executor(None, _read_rebuild_status)
|
||||||
|
new_log, new_offset = await loop.run_in_executor(None, _read_rebuild_log, offset)
|
||||||
|
running = status == "RUNNING"
|
||||||
|
result = "pending" if running else status.lower()
|
||||||
|
return {
|
||||||
|
"running": running,
|
||||||
|
"result": result,
|
||||||
|
"log": new_log,
|
||||||
|
"offset": new_offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Domain endpoints ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DomainSetRequest(BaseModel):
|
||||||
|
domain_name: str
|
||||||
|
domain: str
|
||||||
|
ddns_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/domains/set")
|
||||||
|
async def api_domains_set(req: DomainSetRequest):
|
||||||
|
"""Save a domain and optionally register a DDNS URL."""
|
||||||
|
os.makedirs(DOMAINS_DIR, exist_ok=True)
|
||||||
|
domain_path = os.path.join(DOMAINS_DIR, req.domain_name)
|
||||||
|
with open(domain_path, "w") as f:
|
||||||
|
f.write(req.domain.strip())
|
||||||
|
|
||||||
|
if req.ddns_url:
|
||||||
|
ddns_url = req.ddns_url.strip()
|
||||||
|
# Strip leading "curl " if present
|
||||||
|
if ddns_url.lower().startswith("curl "):
|
||||||
|
ddns_url = ddns_url[5:].strip()
|
||||||
|
# Strip surrounding quotes
|
||||||
|
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||||
|
ddns_url = ddns_url[1:-1]
|
||||||
|
# Replace trailing &auto with &a=${IP}
|
||||||
|
if ddns_url.endswith("&auto"):
|
||||||
|
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
||||||
|
# Append curl line to njalla.sh
|
||||||
|
njalla_dir = os.path.dirname(NJALLA_SCRIPT)
|
||||||
|
if njalla_dir:
|
||||||
|
os.makedirs(njalla_dir, exist_ok=True)
|
||||||
|
with open(NJALLA_SCRIPT, "a") as f:
|
||||||
|
f.write(f'curl "{ddns_url}"\n')
|
||||||
|
try:
|
||||||
|
os.chmod(NJALLA_SCRIPT, 0o755)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Run njalla.sh immediately to update DNS
|
||||||
|
try:
|
||||||
|
subprocess.run([NJALLA_SCRIPT], timeout=30, check=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DomainSetEmailRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/domains/set-email")
|
||||||
|
async def api_domains_set_email(req: DomainSetEmailRequest):
|
||||||
|
"""Save the SSL certificate email address."""
|
||||||
|
os.makedirs(DOMAINS_DIR, exist_ok=True)
|
||||||
|
with open(os.path.join(DOMAINS_DIR, "sslemail"), "w") as f:
|
||||||
|
f.write(req.email.strip())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/domains/status")
|
||||||
|
async def api_domains_status():
|
||||||
|
"""Return the value of each known domain file (or null if missing)."""
|
||||||
|
known = [
|
||||||
|
"matrix", "haven", "element-calling", "sslemail",
|
||||||
|
"vaultwarden", "btcpayserver", "nextcloud", "wordpress",
|
||||||
|
]
|
||||||
|
domains: dict[str, str | None] = {}
|
||||||
|
for name in known:
|
||||||
|
path = os.path.join(DOMAINS_DIR, name)
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
domains[name] = f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
domains[name] = None
|
||||||
|
return {"domains": domains}
|
||||||
|
|
||||||
|
|
||||||
# ── Startup: seed the internal IP file immediately ───────────────
|
# ── Startup: seed the internal IP file immediately ───────────────
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||||
v7 — Status-only dashboard + Tech Support */
|
v7 — Status-only dashboard + Tech Support + Feature Manager */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const POLL_INTERVAL_SERVICES = 5000;
|
const POLL_INTERVAL_SERVICES = 5000;
|
||||||
@@ -16,8 +16,18 @@ const CATEGORY_ORDER = [
|
|||||||
"apps",
|
"apps",
|
||||||
"nostr",
|
"nostr",
|
||||||
"support",
|
"support",
|
||||||
|
"feature-manager",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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([
|
const STATUS_LOADING_STATES = new Set([
|
||||||
"reloading", "activating", "deactivating", "maintenance",
|
"reloading", "activating", "deactivating", "maintenance",
|
||||||
]);
|
]);
|
||||||
@@ -35,6 +45,15 @@ let _supportTimerInt = null;
|
|||||||
let _supportEnabledAt = null;
|
let _supportEnabledAt = null;
|
||||||
let _cachedExternalIp = 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
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const $tilesArea = document.getElementById("tiles-area");
|
const $tilesArea = document.getElementById("tiles-area");
|
||||||
@@ -63,6 +82,35 @@ const $supportModal = document.getElementById("support-modal");
|
|||||||
const $supportBody = document.getElementById("support-body");
|
const $supportBody = document.getElementById("support-body");
|
||||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
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");
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function tileId(svc) { return svc.unit + "::" + svc.name; }
|
function tileId(svc) { return svc.unit + "::" + svc.name; }
|
||||||
@@ -480,6 +528,417 @@ function waitForServerReboot() {
|
|||||||
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
|
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rebuild modal ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openRebuildModal() {
|
||||||
|
if (!$rebuildModal) return;
|
||||||
|
_rebuildLog = "";
|
||||||
|
_rebuildLogOffset = 0;
|
||||||
|
_rebuildServerDown = false;
|
||||||
|
_rebuildFinished = false;
|
||||||
|
if ($rebuildLog) $rebuildLog.textContent = "";
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…";
|
||||||
|
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");
|
||||||
|
startRebuildPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRebuildModal() {
|
||||||
|
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||||
|
stopRebuildPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRebuildLog(text) {
|
||||||
|
if (!text) return;
|
||||||
|
_rebuildLog += text;
|
||||||
|
if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; }
|
||||||
|
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; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRebuildDone(success) {
|
||||||
|
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||||
|
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||||
|
if (success) {
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Rebuild complete";
|
||||||
|
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||||
|
// Refresh feature states
|
||||||
|
loadFeatureManager();
|
||||||
|
} else {
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Rebuild failed";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$domainSetupBody.innerHTML =
|
||||||
|
'<div class="domain-setup-intro"><p>Before continuing, you need:</p><ol><li>A subdomain purchased on njal.la</li><li>A Dynamic DNS record for it</li></ol></div>' +
|
||||||
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain:</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="relay.mydomain.com" /></div>' +
|
||||||
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la DDNS URL:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="https://njal.la/update/?h=..." /><p class="domain-field-hint">ℹ Paste the curl URL 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature toggle logic ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function performFeatureToggle(featId, enabled, extra) {
|
||||||
|
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 proceedAfterConflictCheck() {
|
||||||
|
// 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, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictNames.length > 0) {
|
||||||
|
openFeatureConfirm(
|
||||||
|
"This will disable " + conflictNames.join(", ") + ". Continue?",
|
||||||
|
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;
|
||||||
|
renderFeatureManager(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load features:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeatureManager(data) {
|
||||||
|
// Remove old feature manager section if it exists
|
||||||
|
var old = $tilesArea.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tilesArea.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">🌐 Domain: Configured</div>';
|
||||||
|
} else {
|
||||||
|
domainHtml = '<div class="feature-domain-badge not-configured">🌐 Domain: Not configured</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;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event listeners ───────────────────────────────────────────────
|
// ── Event listeners ───────────────────────────────────────────────
|
||||||
|
|
||||||
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
|
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
|
||||||
@@ -490,6 +949,26 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
|||||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
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 ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); });
|
||||||
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
||||||
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
||||||
@@ -506,14 +985,24 @@ async function init() {
|
|||||||
}
|
}
|
||||||
var badge = document.getElementById("role-badge");
|
var badge = document.getElementById("role-badge");
|
||||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await refreshServices();
|
await refreshServices();
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
checkUpdates();
|
checkUpdates();
|
||||||
|
|
||||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
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);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -960,3 +960,220 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Feature Manager ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.feature-manager-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-subcategory {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-subcategory-header {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-cards-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background-color: var(--card-color);
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature toggle switch ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.feature-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--text-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle.active .feature-toggle-slider {
|
||||||
|
background-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-toggle.active .feature-toggle-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature domain badge ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.feature-domain-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-badge.configured {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-badge.not-configured {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature conflict warning ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.feature-conflict-warning {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--yellow);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Domain setup modal inputs ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.domain-narrow-dialog {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-setup-intro {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-setup-intro ol {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-input {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #12121c;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-input:focus {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-field-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.feature-cards-wrap {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.domain-narrow-dialog {
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain Setup Modal -->
|
||||||
|
<div class="modal-overlay" id="domain-setup-modal" role="dialog" aria-modal="true" aria-labelledby="domain-setup-title">
|
||||||
|
<div class="creds-dialog">
|
||||||
|
<div class="creds-header">
|
||||||
|
<span class="creds-title" id="domain-setup-title">🌐 Domain Setup</span>
|
||||||
|
<button class="creds-close-btn" id="domain-setup-close-btn" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="creds-body" id="domain-setup-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSL Email Modal -->
|
||||||
|
<div class="modal-overlay" id="ssl-email-modal" role="dialog" aria-modal="true" aria-labelledby="ssl-email-title">
|
||||||
|
<div class="creds-dialog domain-narrow-dialog">
|
||||||
|
<div class="creds-header">
|
||||||
|
<span class="creds-title" id="ssl-email-title">📧 SSL Certificate Email</span>
|
||||||
|
<button class="creds-close-btn" id="ssl-email-close-btn" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="creds-body">
|
||||||
|
<p class="support-desc">Let's Encrypt needs an email address for SSL certificate notifications.</p>
|
||||||
|
<div class="domain-field-group">
|
||||||
|
<label class="domain-field-label" for="ssl-email-input">Email:</label>
|
||||||
|
<input class="domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="domain-field-actions">
|
||||||
|
<button class="btn btn-close-modal" id="ssl-email-cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="ssl-email-save-btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Confirm Modal -->
|
||||||
|
<div class="modal-overlay" id="feature-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="feature-confirm-title">
|
||||||
|
<div class="creds-dialog domain-narrow-dialog">
|
||||||
|
<div class="creds-header">
|
||||||
|
<span class="creds-title" id="feature-confirm-title">Confirm Action</span>
|
||||||
|
<button class="creds-close-btn" id="feature-confirm-close-btn" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="creds-body">
|
||||||
|
<p class="support-desc" id="feature-confirm-message"></p>
|
||||||
|
<div class="domain-field-actions">
|
||||||
|
<button class="btn btn-close-modal" id="feature-confirm-cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="feature-confirm-ok-btn">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rebuild Modal -->
|
||||||
|
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||||
|
<div class="modal-spinner" id="rebuild-spinner"></div>
|
||||||
|
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||||
|
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
|
||||||
|
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reboot overlay -->
|
<!-- Reboot overlay -->
|
||||||
<div class="reboot-overlay" id="reboot-overlay">
|
<div class="reboot-overlay" id="reboot-overlay">
|
||||||
<div class="reboot-card">
|
<div class="reboot-card">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
self.nixosModules.Sovran_SystemsOS
|
self.nixosModules.Sovran_SystemsOS
|
||||||
/etc/nixos/role-state.nix
|
/etc/nixos/role-state.nix
|
||||||
/etc/nixos/custom.nix
|
/etc/nixos/custom.nix
|
||||||
|
/etc/nixos/hub-overrides.nix
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ let
|
|||||||
command_method = "systemctl";
|
command_method = "systemctl";
|
||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
|
feature_manager = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
# ── Update wrapper script ──────────────────────────────────────
|
# ── Update wrapper script ──────────────────────────────────────
|
||||||
@@ -159,6 +160,39 @@ let
|
|||||||
exit "$RC"
|
exit "$RC"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# ── Rebuild wrapper script ─────────────────────────────────────
|
||||||
|
rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" ''
|
||||||
|
set -uo pipefail
|
||||||
|
export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.coreutils ]}:$PATH"
|
||||||
|
|
||||||
|
LOG="/var/log/sovran-hub-rebuild.log"
|
||||||
|
STATUS="/var/log/sovran-hub-rebuild.status"
|
||||||
|
|
||||||
|
echo "RUNNING" > "$STATUS"
|
||||||
|
: > "$LOG"
|
||||||
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo "── Rebuilding system configuration ──────────────"
|
||||||
|
if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo " ✓ Rebuild completed successfully"
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo "SUCCESS" > "$STATUS"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo " ✗ Rebuild failed — see errors above"
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo "FAILED" > "$STATUS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||||
pname = "sovran-systemsos-hub-web";
|
pname = "sovran-systemsos-hub-web";
|
||||||
version = "1.0.0";
|
version = "1.0.0";
|
||||||
@@ -241,6 +275,32 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
systemd.services.sovran-hub-rebuild = {
|
||||||
|
description = "Sovran_SystemsOS System Rebuild";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${rebuild-script}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.hub-overrides-init = {
|
||||||
|
description = "Initialize hub-overrides.nix if missing";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
unitConfig.ConditionPathExists = "!/etc/nixos/hub-overrides.nix";
|
||||||
|
script = ''
|
||||||
|
cat > /etc/nixos/hub-overrides.nix <<'EOF'
|
||||||
|
# Auto-generated by Sovran Hub — do not edit manually
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 8937 ];
|
networking.firewall.allowedTCPPorts = [ 8937 ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user