Replace GTK4 desktop app with FastAPI web app (Sovran_SystemsOS Hub)

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-02 17:01:42 +00:00
committed by GitHub
parent af1ad09e2e
commit 42900608f6
17 changed files with 1368 additions and 1183 deletions

View File

View File

@@ -0,0 +1,20 @@
"""Load the Nix-generated config for Sovran_SystemsOS_Hub."""
import json
import os
def load_config() -> dict:
"""Read config from the path injected by the Nix derivation."""
path = os.environ.get(
"SOVRAN_HUB_CONFIG",
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config.json",
),
)
try:
with open(path, "r") as fh:
return json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
return {"refresh_interval": 5, "command_method": "systemctl", "services": []}

View File

@@ -0,0 +1,329 @@
"""Sovran_SystemsOS Hub — FastAPI web server."""
from __future__ import annotations
import asyncio
import json
import os
import socket
import subprocess
import threading
import urllib.request
from typing import AsyncIterator
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
from .config import load_config
from . import systemctl as sysctl
# ── Constants ────────────────────────────────────────────────────
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
FLAKE_INPUT_NAME = "Sovran_Systems"
GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits"
REBOOT_COMMAND = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
"root@localhost",
"reboot",
]
UPDATE_COMMAND = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
"root@localhost",
"cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y",
]
CATEGORY_ORDER = [
("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"),
("bitcoin-apps", "Bitcoin Apps"),
("communication", "Communication"),
("apps", "Self-Hosted Apps"),
("nostr", "Nostr"),
]
ROLE_LABELS = {
"server_plus_desktop": "Server + Desktop",
"desktop": "Desktop Only",
"node": "Bitcoin Node",
}
# ── App setup ────────────────────────────────────────────────────
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="Sovran_SystemsOS Hub")
app.mount(
"/static",
StaticFiles(directory=os.path.join(_BASE_DIR, "static")),
name="static",
)
# Also serve icons from the app/icons directory (set via env or adjacent folder)
_ICONS_DIR = os.environ.get(
"SOVRAN_HUB_ICONS",
os.path.join(os.path.dirname(_BASE_DIR), "icons"),
)
if os.path.isdir(_ICONS_DIR):
app.mount(
"/static/icons",
StaticFiles(directory=_ICONS_DIR),
name="icons",
)
templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates"))
# ── Update check helpers ─────────────────────────────────────────
def _get_locked_info():
try:
with open(FLAKE_LOCK_PATH, "r") as f:
lock = json.load(f)
nodes = lock.get("nodes", {})
node = nodes.get(FLAKE_INPUT_NAME, {})
locked = node.get("locked", {})
rev = locked.get("rev")
branch = locked.get("ref")
if not branch:
branch = node.get("original", {}).get("ref")
return rev, branch
except Exception:
pass
return None, None
def _get_remote_rev(branch=None):
try:
url = GITEA_API_BASE + "?limit=1"
if branch:
url += f"&sha={branch}"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if isinstance(data, list) and len(data) > 0:
return data[0].get("sha")
except Exception:
pass
return None
def check_for_updates() -> bool:
locked_rev, branch = _get_locked_info()
remote_rev = _get_remote_rev(branch)
if locked_rev and remote_rev:
return locked_rev != remote_rev
return False
# ── IP helpers ───────────────────────────────────────────────────
def _get_internal_ip() -> str:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.connect(("1.1.1.1", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
pass
try:
result = subprocess.run(
["hostname", "-I"], capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
parts = result.stdout.strip().split()
if parts:
return parts[0]
except Exception:
pass
return "unavailable"
def _get_external_ip() -> str:
# Max length 46 covers the longest valid IPv6 address (45 chars) plus a newline
MAX_IP_LENGTH = 46
for url in [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]:
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=8) as resp:
ip = resp.read().decode().strip()
if ip and len(ip) < MAX_IP_LENGTH:
return ip
except Exception:
continue
return "unavailable"
# ── Routes ───────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/config")
async def api_config():
cfg = load_config()
role = cfg.get("role", "server_plus_desktop")
return {
"role": role,
"role_label": ROLE_LABELS.get(role, role),
"category_order": CATEGORY_ORDER,
}
@app.get("/api/services")
async def api_services():
cfg = load_config()
method = cfg.get("command_method", "systemctl")
services = cfg.get("services", [])
loop = asyncio.get_event_loop()
async def get_status(entry):
unit = entry.get("unit", "")
scope = entry.get("type", "system")
enabled = entry.get("enabled", True)
if enabled:
status = await loop.run_in_executor(
None, lambda: sysctl.is_active(unit, scope)
)
else:
status = "disabled"
return {
"name": entry.get("name", ""),
"unit": unit,
"type": scope,
"icon": entry.get("icon", ""),
"enabled": enabled,
"category": entry.get("category", "other"),
"status": status,
}
results = await asyncio.gather(*[get_status(s) for s in services])
return list(results)
def _get_allowed_units() -> set[str]:
"""Return the set of unit names from the current config (whitelist)."""
cfg = load_config()
return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")}
@app.post("/api/services/{unit}/start")
async def service_start(unit: str):
if unit not in _get_allowed_units():
raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list")
cfg = load_config()
method = cfg.get("command_method", "systemctl")
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(
None, lambda: sysctl.run_action("start", unit, "system", method)
)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to start {unit}")
return {"ok": True}
@app.post("/api/services/{unit}/stop")
async def service_stop(unit: str):
if unit not in _get_allowed_units():
raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list")
cfg = load_config()
method = cfg.get("command_method", "systemctl")
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(
None, lambda: sysctl.run_action("stop", unit, "system", method)
)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to stop {unit}")
return {"ok": True}
@app.post("/api/services/{unit}/restart")
async def service_restart(unit: str):
if unit not in _get_allowed_units():
raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list")
cfg = load_config()
method = cfg.get("command_method", "systemctl")
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(
None, lambda: sysctl.run_action("restart", unit, "system", method)
)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to restart {unit}")
return {"ok": True}
@app.get("/api/network")
async def api_network():
loop = asyncio.get_event_loop()
internal, external = await asyncio.gather(
loop.run_in_executor(None, _get_internal_ip),
loop.run_in_executor(None, _get_external_ip),
)
return {"internal_ip": internal, "external_ip": external}
@app.get("/api/updates/check")
async def api_updates_check():
loop = asyncio.get_event_loop()
available = await loop.run_in_executor(None, check_for_updates)
return {"available": available}
@app.post("/api/reboot")
async def api_reboot():
try:
await asyncio.create_subprocess_exec(*REBOOT_COMMAND)
except Exception:
raise HTTPException(status_code=500, detail="Failed to initiate reboot")
return {"ok": True}
async def api_updates_run():
async def event_stream() -> AsyncIterator[str]:
yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n"
yield "data: \n\n"
process = await asyncio.create_subprocess_exec(
*UPDATE_COMMAND,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
assert process.stdout is not None
try:
async for raw_line in process.stdout:
line = raw_line.decode(errors="replace").rstrip("\n")
# SSE requires data: prefix; escape newlines within a line
yield f"data: {line}\n\n"
except Exception:
yield "data: [stream error: output read interrupted]\n\n"
await process.wait()
if process.returncode == 0:
yield "event: done\ndata: success\n\n"
else:
yield f"event: error\ndata: exit code {process.returncode}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,408 @@
/* Sovran_SystemsOS Hub — Vanilla JS Frontend */
"use strict";
const POLL_INTERVAL_SERVICES = 5000; // 5 s
const POLL_INTERVAL_UPDATES = 1800000; // 30 min
const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart
const CATEGORY_ORDER = [
"infrastructure",
"bitcoin-base",
"bitcoin-apps",
"communication",
"apps",
"nostr",
];
const STATUS_LOADING_STATES = new Set([
"reloading", "activating", "deactivating", "maintenance",
]);
// ── State ─────────────────────────────────────────────────────────
let _servicesCache = [];
let _categoryLabels = {};
let _updateSource = null;
let _updateLog = "";
// ── DOM refs ──────────────────────────────────────────────────────
const $tilesArea = document.getElementById("tiles-area");
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");
// ── Helpers ───────────────────────────────────────────────────────
function statusClass(status) {
if (!status) return "unknown";
if (status === "active") return "active";
if (status === "inactive") return "inactive";
if (status === "failed") return "failed";
if (status === "disabled") return "disabled";
if (STATUS_LOADING_STATES.has(status)) return "loading";
return "unknown";
}
function statusText(status, enabled) {
if (!enabled) return "disabled";
if (!status || status === "unknown") return "unknown";
return status;
}
// ── Fetch wrappers ────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const res = await fetch(path, options);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
// ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) {
_servicesCache = services;
// Group by category
const grouped = {};
for (const svc of services) {
const cat = svc.category || "other";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(svc);
}
$tilesArea.innerHTML = "";
const orderedKeys = [
...CATEGORY_ORDER.filter(k => grouped[k]),
...Object.keys(grouped).filter(k => !CATEGORY_ORDER.includes(k)),
];
for (const catKey of orderedKeys) {
const entries = grouped[catKey];
if (!entries || entries.length === 0) continue;
const label = categoryLabels[catKey] || catKey;
const 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>
`;
const grid = section.querySelector(".tiles-grid");
for (const svc of entries) {
grid.appendChild(buildTile(svc));
}
$tilesArea.appendChild(section);
}
if ($tilesArea.children.length === 0) {
$tilesArea.innerHTML = `<div class="empty-state"><p>No services configured.</p></div>`;
}
}
function buildTile(svc) {
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dis = !svc.enabled;
const isOn = svc.status === "active";
const tile = document.createElement("div");
tile.className = "service-tile" + (dis ? " disabled" : "");
tile.dataset.unit = svc.unit;
if (dis) tile.title = `${svc.name} is not enabled in custom.nix`;
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">${escHtml(st)}</span>
</div>
<div class="tile-spacer"></div>
<div class="tile-controls">
<label class="toggle-label${dis ? " disabled-toggle" : ""}" title="${dis ? "Not enabled in custom.nix" : (isOn ? "Stop" : "Start")}">
<input type="checkbox" class="tile-toggle" data-unit="${escHtml(svc.unit)}"
${isOn ? "checked" : ""} ${dis ? "disabled" : ""}>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<button class="tile-restart-btn" data-unit="${escHtml(svc.unit)}"
title="Restart" ${dis ? "disabled" : ""}>↺</button>
</div>
`;
// Toggle handler
const chk = tile.querySelector(".tile-toggle");
if (!dis) {
chk.addEventListener("change", async (e) => {
const action = e.target.checked ? "start" : "stop";
chk.disabled = true;
try {
await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" });
} catch (_) {}
setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY);
});
}
// Restart handler
const restartBtn = tile.querySelector(".tile-restart-btn");
if (!dis) {
restartBtn.addEventListener("click", async () => {
restartBtn.disabled = true;
try {
await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" });
} catch (_) {}
setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY);
});
}
return tile;
}
// ── Render: live update (no DOM rebuild) ──────────────────────────
function updateTiles(services) {
_servicesCache = services;
for (const svc of services) {
const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`);
if (!tile) continue;
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dot = tile.querySelector(".status-dot");
const text = tile.querySelector(".status-text");
const chk = tile.querySelector(".tile-toggle");
if (dot) { dot.className = `status-dot ${sc}`; }
if (text) { text.textContent = st; }
if (chk && !chk.disabled) {
chk.checked = svc.status === "active";
}
}
}
// ── HTML escape ───────────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ── Service polling ───────────────────────────────────────────────
let _firstLoad = true;
async function refreshServices() {
try {
const 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 {
const data = await apiFetch("/api/network");
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
} catch (_) {
if ($internalIp) $internalIp.textContent = "—";
if ($externalIp) $externalIp.textContent = "—";
}
}
// ── Update check ──────────────────────────────────────────────────
async function checkUpdates() {
try {
const data = await apiFetch("/api/updates/check");
if ($updateBadge) {
$updateBadge.classList.toggle("visible", !!data.available);
}
} catch (_) {}
}
// ── Update modal ──────────────────────────────────────────────────
function openUpdateModal() {
if (!$modal) return;
_updateLog = "";
if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Updating…";
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");
startUpdateStream();
}
function closeUpdateModal() {
if (!$modal) return;
$modal.classList.remove("open");
if (_updateSource) {
_updateSource.close();
_updateSource = null;
}
}
function appendLog(text) {
_updateLog += text + "\n";
if ($modalLog) {
$modalLog.textContent += text + "\n";
$modalLog.scrollTop = $modalLog.scrollHeight;
}
}
function startUpdateStream() {
// Trigger the update via POST first, then listen via SSE
fetch("/api/updates/run", { method: "POST" }).then(response => {
if (!response.ok || !response.body) {
const detail = response.ok ? "no body" : `HTTP ${response.status} ${response.statusText}`;
appendLog(`[Error: failed to start update — ${detail}]`);
onUpdateDone(false);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
function read() {
reader.read().then(({ done, value }) => {
if (done) {
onUpdateDone(true);
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep incomplete line
for (const line of lines) {
if (line.startsWith("data: ")) {
appendLog(line.slice(6));
} else if (line.startsWith("event: done")) {
// success event will follow in data:
} else if (line.startsWith("event: error")) {
// error event will follow in data:
}
}
read();
}).catch(err => {
appendLog(`[Stream error: ${err}]`);
onUpdateDone(false);
});
}
read();
}).catch(err => {
appendLog(`[Request error: ${err}]`);
onUpdateDone(false);
});
}
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";
}
}
function saveErrorReport() {
const blob = new Blob([_updateLog], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const 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);
}
function doReboot() {
fetch("/api/reboot", { method: "POST" }).catch(() => {});
if ($modalStatus) $modalStatus.textContent = "Rebooting…";
}
// ── Event listeners ───────────────────────────────────────────────
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices());
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
// Close modal on overlay click
if ($modal) {
$modal.addEventListener("click", (e) => {
if (e.target === $modal) closeUpdateModal();
});
}
// ── Init ──────────────────────────────────────────────────────────
async function init() {
// Load config to get category labels
try {
const cfg = await apiFetch("/api/config");
if (cfg.category_order) {
for (const [key, label] of cfg.category_order) {
_categoryLabels[key] = label;
}
}
// Update role badge
const badge = document.getElementById("role-badge");
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
} catch (_) {}
// Initial data loads
await refreshServices();
loadNetwork();
checkUpdates();
// Polling
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -0,0 +1,530 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet
Dark theme matching the Adwaita dark aesthetic */
*, *::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;
}
/* ── Header bar ─────────────────────────────────────────────────── */
.header-bar {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: 10px 24px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
}
.header-bar .title {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
flex: 1;
}
.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;
}
/* ── 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;
}
.btn-update {
background-color: var(--green);
color: #fff;
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.btn-update: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);
}
/* ── 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);
}
/* ── Main content ───────────────────────────────────────────────── */
.main-content {
max-width: 980px;
margin: 0 auto;
padding: 24px 16px 48px;
}
/* ── 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;
}
/* ── Service tile card ──────────────────────────────────────────── */
.service-tile {
width: 180px;
min-height: 210px;
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;
padding: 18px 12px 14px;
gap: 0;
transition: box-shadow 0.2s, border-color 0.2s;
position: relative;
}
.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: 8px;
}
.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: 8px;
}
.tile-name {
font-size: 0.88rem;
font-weight: 600;
text-align: center;
color: var(--text-primary);
line-height: 1.3;
max-width: 156px;
word-break: break-word;
hyphens: auto;
min-height: 2.6em;
display: flex;
align-items: center;
justify-content: center;
}
.tile-status {
font-size: 0.75rem;
margin-top: 6px;
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); }
.tile-spacer {
flex: 1;
}
/* ── Tile controls ──────────────────────────────────────────────── */
.tile-controls {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
/* CSS-only toggle switch */
.toggle-label {
display: flex;
align-items: center;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
display: none;
}
.toggle-track {
width: 40px;
height: 22px;
background-color: var(--border-color);
border-radius: 11px;
position: relative;
transition: background-color 0.2s;
}
.toggle-label input:checked + .toggle-track {
background-color: var(--green);
}
.toggle-label.disabled-toggle {
cursor: not-allowed;
opacity: 0.5;
}
.toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background-color: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.toggle-label input:checked + .toggle-track .toggle-thumb {
transform: translateX(18px);
}
.tile-restart-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 6px;
border-radius: 50%;
font-size: 0.95rem;
line-height: 1;
transition: background-color 0.15s, color 0.15s;
}
.tile-restart-btn:hover:not(:disabled) {
background-color: var(--border-color);
color: var(--text-primary);
}
.tile-restart-btn:disabled {
opacity: 0.35;
cursor: default;
}
/* ── 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;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--border-color);
}
.btn-reboot {
background-color: var(--red);
color: #fff;
}
.btn-reboot:hover:not(:disabled) {
background-color: #c0181f;
}
.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;
}
/* ── Empty state ────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 64px 24px;
color: var(--text-dim);
}
.empty-state p {
font-size: 1rem;
margin-bottom: 8px;
}
/* ── Responsive ─────────────────────────────────────────────────── */
@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;
}
.main-content {
padding: 16px 12px 40px;
}
.tiles-grid {
justify-content: center;
}
.service-tile {
width: 160px;
min-height: 200px;
}
}

View File

@@ -0,0 +1,40 @@
"""Thin wrapper around the systemctl CLI for Sovran_SystemsOS_Hub."""
from __future__ import annotations
import subprocess
from typing import Literal
def _run(cmd: list[str]) -> str:
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return result.stdout.strip()
except Exception:
return ""
def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
def run_action(
action: str,
unit: str,
scope: Literal["system", "user"] = "system",
method: str = "systemctl",
) -> bool:
base_cmd = ["systemctl", f"--{scope}", action, unit]
if scope == "system" and method == "pkexec":
cmd = ["pkexec", "--user", "root"] + base_cmd
else:
cmd = base_cmd
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result.returncode == 0
except Exception:
return False

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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" />
</head>
<body>
<!-- Header bar -->
<header class="header-bar">
<span class="title">Sovran_SystemsOS Hub</span>
<span class="role-badge" id="role-badge">Loading…</span>
<button class="btn btn-update" id="btn-update" title="Run system update">
<span class="update-badge" id="update-badge"></span>
Update System
</button>
<button class="btn-icon" id="btn-refresh" title="Refresh service status"></button>
</header>
<!-- IP bar -->
<div class="ip-bar">
<span>
<span class="ip-label">Internal IP:</span>
<span class="ip-value" id="ip-internal"></span>
</span>
<span class="ip-separator">|</span>
<span>
<span class="ip-label">External IP:</span>
<span class="ip-value" id="ip-external"></span>
</span>
</div>
<!-- Service tiles -->
<main class="main-content">
<div id="tiles-area"></div>
</main>
<!-- Update modal -->
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
<div class="modal-dialog">
<div class="modal-header">
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
<div class="modal-spinner" id="modal-spinner"></div>
<span class="modal-status" id="modal-status">Updating…</span>
</div>
<div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>