18 Commits

Author SHA1 Message Date
Aaron
471e242427 Onboarding: rely on https cookie redirect, remove DietPi mentions 2025-12-13 14:13:51 -05:00
Aaron
357453eed4 Onboarding: set https cookie + auto redirect 2025-12-13 14:07:56 -05:00
Aaron
25cc888b86 Onboarding: accordion + distro list updates 2025-12-13 14:01:12 -05:00
Aaron
17ae87563f Onboarding: fix CSS path and keep HTTP CA link 2025-12-13 13:56:21 -05:00
Aaron
32503424e8 Onboarding: explain HTTPS, allow HTTP CA download 2025-12-13 13:54:10 -05:00
Aaron
50be46df45 Revamp onboarding page styling 2025-12-13 13:51:25 -05:00
Aaron
8c06962f62 Add HTTPS onboarding page; prefer .local host for service URLs 2025-12-13 13:44:01 -05:00
Aaron
2a439321d0 Add tooltips to diagnostics controls and service form inputs 2025-12-13 12:27:57 -05:00
Aaron
e993d19886 Clear diagnostics UI immediately, then refresh 2025-12-13 12:24:58 -05:00
Aaron
0e3b144cd7 Hide/disable diagnostics log button when diag is off 2025-12-13 12:20:52 -05:00
Aaron
98fbe1b96e Diagnostics: improve error surfacing and busy state handling 2025-12-13 12:10:50 -05:00
Aaron
8864df2b2c Keep last good status on fetch failure; retry and log to diagnostics 2025-12-13 12:07:02 -05:00
Aaron
d49218409d Mirror updater log lines into diagnostics log 2025-12-13 12:02:37 -05:00
Aaron
35c83a918b Retry release status quietly during update/rollback 2025-12-13 12:00:19 -05:00
Aaron
c182eb179d Show diagnostics log modal via header button; toggle visibility when enabled 2025-12-13 11:46:01 -05:00
Aaron
650175913e Move diagnostics log to modal; add header Log button 2025-12-13 11:41:13 -05:00
Aaron
5ee183d607 Harden diagnostics UI (busy states, retry refresh) 2025-12-13 11:33:04 -05:00
Aaron
48be7a1c61 Add diagnostics logging (RAM), UI viewer, and toggles 2025-12-13 11:16:57 -05:00
10 changed files with 858 additions and 26 deletions

View File

@@ -8,6 +8,8 @@ import urllib.parse
import fcntl import fcntl
from functools import partial from functools import partial
import json as jsonlib import json as jsonlib
import io
from collections import deque
HOST = "127.0.0.1" HOST = "127.0.0.1"
PORT = 4000 PORT = 4000
@@ -49,6 +51,99 @@ API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
BACKUP_ROOT = pathlib.Path("/var/backups/pikit") BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update") TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
# Diagnostics logging (RAM-only)
DIAG_STATE_FILE = pathlib.Path("/dev/shm/pikit-diag.state") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.state")
DIAG_LOG_FILE = pathlib.Path("/dev/shm/pikit-diag.log") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.log")
DIAG_MAX_BYTES = 1_048_576 # 1 MiB cap in RAM
DIAG_MAX_ENTRY_CHARS = 2048
DIAG_DEFAULT_STATE = {"enabled": False, "level": "normal"} # level: normal|debug
_diag_state = None
def _load_diag_state():
global _diag_state
if _diag_state is not None:
return _diag_state
try:
if DIAG_STATE_FILE.exists():
_diag_state = json.loads(DIAG_STATE_FILE.read_text())
return _diag_state
except Exception:
pass
_diag_state = DIAG_DEFAULT_STATE.copy()
return _diag_state
def _save_diag_state(enabled=None, level=None):
state = _load_diag_state()
if enabled is not None:
state["enabled"] = bool(enabled)
if level in ("normal", "debug"):
state["level"] = level
try:
DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
DIAG_STATE_FILE.write_text(json.dumps(state))
except Exception:
pass
return state
def diag_log(level: str, message: str, meta: dict | None = None):
"""
Append a diagnostic log line to RAM-backed file.
Skips when disabled or when debug level is off.
"""
state = _load_diag_state()
if not state.get("enabled"):
return
if level == "debug" and state.get("level") != "debug":
return
try:
ts = datetime.datetime.utcnow().isoformat() + "Z"
entry = {"ts": ts, "level": level, "msg": message}
if meta:
entry["meta"] = meta
line = json.dumps(entry, separators=(",", ":"))
if len(line) > DIAG_MAX_ENTRY_CHARS:
entry.pop("meta", None)
entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + ""
line = json.dumps(entry, separators=(",", ":"))
line_bytes = (line + "\n").encode()
DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with DIAG_LOG_FILE.open("ab") as f:
f.write(line_bytes)
# Trim file if above cap
if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES:
with DIAG_LOG_FILE.open("rb") as f:
f.seek(-DIAG_MAX_BYTES, io.SEEK_END)
tail = f.read()
# drop partial first line to keep JSON lines clean
if b"\n" in tail:
tail = tail.split(b"\n", 1)[1]
with DIAG_LOG_FILE.open("wb") as f:
f.write(tail)
except Exception:
# Never break caller
pass
def diag_read(limit=500):
"""Return latest log entries (dicts), newest first."""
if not DIAG_LOG_FILE.exists():
return []
try:
data = DIAG_LOG_FILE.read_bytes()
except Exception:
return []
lines = data.splitlines()[-limit:]
out = []
for line in lines:
try:
out.append(json.loads(line.decode("utf-8", errors="ignore")))
except Exception:
continue
return out[::-1]
def ensure_dir(path: pathlib.Path): def ensure_dir(path: pathlib.Path):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
@@ -79,13 +174,26 @@ def normalize_path(path: str | None) -> str:
return p return p
def default_host():
"""Return preferred hostname (append .local if bare)."""
host = socket.gethostname()
if "." not in host:
host = f"{host}.local"
return host
def dbg(msg): def dbg(msg):
if not DEBUG_FLAG: # Legacy debug file logging (when /boot/pikit-debug exists)
return if DEBUG_FLAG:
API_LOG.parent.mkdir(parents=True, exist_ok=True) API_LOG.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.utcnow().isoformat() ts = datetime.datetime.utcnow().isoformat()
with API_LOG.open("a") as f: with API_LOG.open("a") as f:
f.write(f"[{ts}] {msg}\n") f.write(f"[{ts}] {msg}\n")
# Mirror into diagnostics if enabled
try:
diag_log("debug", msg)
except Exception:
pass
def set_ssh_password_auth(allow: bool): def set_ssh_password_auth(allow: bool):
@@ -126,7 +234,7 @@ def load_services():
try: try:
data = json.loads(SERVICE_JSON.read_text()) data = json.loads(SERVICE_JSON.read_text())
# Normalize entries: ensure url built from port if missing # Normalize entries: ensure url built from port if missing
host = socket.gethostname() host = default_host()
for svc in data: for svc in data:
svc_path = normalize_path(svc.get("path")) svc_path = normalize_path(svc.get("path"))
if svc_path: if svc_path:
@@ -406,16 +514,8 @@ def set_updates_config(opts: dict):
def detect_https(host, port): def detect_https(host, port):
try: """Heuristic: known HTTPS ports or .local certs."""
import ssl return int(port) in HTTPS_PORTS or str(host).lower().endswith(".local") or str(host).lower() == "pikit"
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, int(port)), timeout=1.5) as sock:
with ctx.wrap_socket(sock, server_hostname=host):
return True
except Exception:
return False
def factory_reset(): def factory_reset():
@@ -677,6 +777,7 @@ def check_for_update():
state["message"] = "Another update is running" state["message"] = "Another update is running"
save_update_state(state) save_update_state(state)
return state return state
diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"})
state["in_progress"] = True state["in_progress"] = True
state["progress"] = "Checking for updates…" state["progress"] = "Checking for updates…"
save_update_state(state) save_update_state(state)
@@ -696,10 +797,12 @@ def check_for_update():
else: else:
state["status"] = "up_to_date" state["status"] = "up_to_date"
state["message"] = "Up to date" state["message"] = "Up to date"
diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)})
except Exception as e: except Exception as e:
state["status"] = "up_to_date" state["status"] = "up_to_date"
state["message"] = f"Could not reach update server: {e}" state["message"] = f"Could not reach update server: {e}"
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
diag_log("error", "Update check failed", {"error": str(e)})
finally: finally:
state["in_progress"] = False state["in_progress"] = False
state["progress"] = None state["progress"] = None
@@ -729,6 +832,7 @@ def apply_update_stub():
state["status"] = "in_progress" state["status"] = "in_progress"
state["progress"] = "Starting update…" state["progress"] = "Starting update…"
save_update_state(state) save_update_state(state)
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
try: try:
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev") channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
@@ -763,6 +867,7 @@ def apply_update_stub():
state["progress"] = "Downloading release…" state["progress"] = "Downloading release…"
save_update_state(state) save_update_state(state)
download_file(bundle_url, bundle_path) download_file(bundle_url, bundle_path)
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
# Verify hash if provided # Verify hash if provided
expected_hash = None expected_hash = None
@@ -774,6 +879,7 @@ def apply_update_stub():
got = sha256_file(bundle_path) got = sha256_file(bundle_path)
if got.lower() != expected_hash.lower(): if got.lower() != expected_hash.lower():
raise RuntimeError("Bundle hash mismatch") raise RuntimeError("Bundle hash mismatch")
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
state["progress"] = "Staging files…" state["progress"] = "Staging files…"
save_update_state(state) save_update_state(state)
@@ -804,14 +910,17 @@ def apply_update_stub():
state["message"] = "Update installed" state["message"] = "Update installed"
state["progress"] = None state["progress"] = None
save_update_state(state) save_update_state(state)
diag_log("info", "Update applied", {"version": str(latest)})
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
state["status"] = "error" state["status"] = "error"
state["message"] = f"No release available ({e.code})" state["message"] = f"No release available ({e.code})"
diag_log("error", "Update apply HTTP error", {"code": e.code})
except Exception as e: except Exception as e:
state["status"] = "error" state["status"] = "error"
state["message"] = f"Update failed: {e}" state["message"] = f"Update failed: {e}"
state["progress"] = None state["progress"] = None
save_update_state(state) save_update_state(state)
diag_log("error", "Update apply failed", {"error": str(e)})
# Attempt rollback if backup exists # Attempt rollback if backup exists
backup = choose_rollback_backup() backup = choose_rollback_backup()
if backup: if backup:
@@ -820,9 +929,11 @@ def apply_update_stub():
state["current_version"] = read_current_version() state["current_version"] = read_current_version()
state["message"] += f" (rolled back to backup {backup.name})" state["message"] += f" (rolled back to backup {backup.name})"
save_update_state(state) save_update_state(state)
diag_log("info", "Rollback after failed update", {"backup": backup.name})
except Exception as re: except Exception as re:
state["message"] += f" (rollback failed: {re})" state["message"] += f" (rollback failed: {re})"
save_update_state(state) save_update_state(state)
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
finally: finally:
state["in_progress"] = False state["in_progress"] = False
state["progress"] = None state["progress"] = None
@@ -844,6 +955,7 @@ def rollback_update_stub():
state["status"] = "in_progress" state["status"] = "in_progress"
state["progress"] = "Rolling back…" state["progress"] = "Rolling back…"
save_update_state(state) save_update_state(state)
diag_log("info", "Rollback started")
backup = choose_rollback_backup() backup = choose_rollback_backup()
if not backup: if not backup:
state["status"] = "error" state["status"] = "error"
@@ -861,9 +973,11 @@ def rollback_update_stub():
ver = get_backup_version(backup) ver = get_backup_version(backup)
suffix = f" (version {ver})" if ver else "" suffix = f" (version {ver})" if ver else ""
state["message"] = f"Rolled back to backup {backup.name}{suffix}" state["message"] = f"Rolled back to backup {backup.name}{suffix}"
diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver})
except Exception as e: except Exception as e:
state["status"] = "error" state["status"] = "error"
state["message"] = f"Rollback failed: {e}" state["message"] = f"Rollback failed: {e}"
diag_log("error", "Rollback failed", {"error": str(e)})
state["in_progress"] = False state["in_progress"] = False
state["progress"] = None state["progress"] = None
save_update_state(state) save_update_state(state)
@@ -1062,6 +1176,12 @@ class Handler(BaseHTTPRequestHandler):
if port: if port:
svc["online"] = port_online("127.0.0.1", port) svc["online"] = port_online("127.0.0.1", port)
svc["firewall_open"] = ufw_status_allows(port) svc["firewall_open"] = ufw_status_allows(port)
# Rebuild URL with preferred host (adds .local)
host = default_host()
path = normalize_path(svc.get("path"))
scheme = svc.get("scheme") or ("https" if detect_https(host, port) else "http")
svc["scheme"] = scheme
svc["url"] = f"{scheme}://{host}:{port}{path}"
services.append(svc) services.append(svc)
self._send(200, {"services": services}) self._send(200, {"services": services})
elif self.path.startswith("/api/updates/auto"): elif self.path.startswith("/api/updates/auto"):
@@ -1090,6 +1210,10 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, {"text": text}) return self._send(200, {"text": text})
except Exception as e: except Exception as e:
return self._send(500, {"error": str(e)}) return self._send(500, {"error": str(e)})
elif self.path.startswith("/api/diag/log"):
entries = diag_read()
state = _load_diag_state()
return self._send(200, {"entries": entries, "state": state})
else: else:
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})
@@ -1100,6 +1224,7 @@ class Handler(BaseHTTPRequestHandler):
if payload.get("confirm") == "YES": if payload.get("confirm") == "YES":
self._send(200, {"message": "Resetting and rebooting..."}) self._send(200, {"message": "Resetting and rebooting..."})
dbg("Factory reset triggered via API") dbg("Factory reset triggered via API")
diag_log("info", "Factory reset requested")
factory_reset() factory_reset()
else: else:
self._send(400, {"error": "type YES to confirm"}) self._send(400, {"error": "type YES to confirm"})
@@ -1109,14 +1234,17 @@ class Handler(BaseHTTPRequestHandler):
set_auto_updates(enable) set_auto_updates(enable)
dbg(f"Auto updates set to {enable}") dbg(f"Auto updates set to {enable}")
state = auto_updates_state() state = auto_updates_state()
diag_log("info", "Auto updates toggled", {"enabled": enable})
return self._send(200, {"enabled": state.get("enabled", False), "details": state}) return self._send(200, {"enabled": state.get("enabled", False), "details": state})
if self.path.startswith("/api/updates/config"): if self.path.startswith("/api/updates/config"):
try: try:
cfg = set_updates_config(payload or {}) cfg = set_updates_config(payload or {})
dbg(f"Update settings applied: {cfg}") dbg(f"Update settings applied: {cfg}")
diag_log("info", "Update settings saved", cfg)
return self._send(200, cfg) return self._send(200, cfg)
except Exception as e: except Exception as e:
dbg(f"Failed to apply updates config: {e}") dbg(f"Failed to apply updates config: {e}")
diag_log("error", "Update settings save failed", {"error": str(e)})
return self._send(500, {"error": str(e)}) return self._send(500, {"error": str(e)})
if self.path.startswith("/api/update/check"): if self.path.startswith("/api/update/check"):
state = check_for_update() state = check_for_update()
@@ -1140,6 +1268,7 @@ class Handler(BaseHTTPRequestHandler):
state = load_update_state() state = load_update_state()
state["auto_check"] = bool(payload.get("enable")) state["auto_check"] = bool(payload.get("enable"))
save_update_state(state) save_update_state(state)
diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]})
return self._send(200, state) return self._send(200, state)
if self.path.startswith("/api/update/channel"): if self.path.startswith("/api/update/channel"):
chan = payload.get("channel", "dev") chan = payload.get("channel", "dev")
@@ -1148,7 +1277,19 @@ class Handler(BaseHTTPRequestHandler):
state = load_update_state() state = load_update_state()
state["channel"] = chan state["channel"] = chan
save_update_state(state) save_update_state(state)
diag_log("info", "Release channel set", {"channel": chan})
return self._send(200, state) return self._send(200, state)
if self.path.startswith("/api/diag/log/level"):
state = _save_diag_state(payload.get("enabled"), payload.get("level"))
diag_log("info", "Diag level updated", state)
return self._send(200, {"state": state})
if self.path.startswith("/api/diag/log/clear"):
try:
DIAG_LOG_FILE.unlink(missing_ok=True)
except Exception:
pass
diag_log("info", "Diag log cleared")
return self._send(200, {"cleared": True, "state": _load_diag_state()})
if self.path.startswith("/api/services/add"): if self.path.startswith("/api/services/add"):
name = payload.get("name") name = payload.get("name")
port = int(payload.get("port", 0)) port = int(payload.get("port", 0))
@@ -1159,7 +1300,7 @@ class Handler(BaseHTTPRequestHandler):
services = load_services() services = load_services()
if any(s.get("port") == port for s in services): if any(s.get("port") == port for s in services):
return self._send(400, {"error": "port already exists"}) return self._send(400, {"error": "port already exists"})
host = socket.gethostname() host = default_host()
scheme = payload.get("scheme") scheme = payload.get("scheme")
if scheme not in ("http", "https"): if scheme not in ("http", "https"):
scheme = "https" if detect_https(host, port) else "http" scheme = "https" if detect_https(host, port) else "http"
@@ -1182,6 +1323,7 @@ class Handler(BaseHTTPRequestHandler):
allow_port_lan(port) allow_port_lan(port)
except FirewallToolMissing as e: except FirewallToolMissing as e:
return self._send(500, {"error": str(e)}) return self._send(500, {"error": str(e)})
diag_log("info", "Service added", {"name": name, "port": port, "scheme": scheme})
return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"}) return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"})
if self.path.startswith("/api/services/remove"): if self.path.startswith("/api/services/remove"):
port = int(payload.get("port", 0)) port = int(payload.get("port", 0))
@@ -1195,6 +1337,7 @@ class Handler(BaseHTTPRequestHandler):
except FirewallToolMissing as e: except FirewallToolMissing as e:
return self._send(500, {"error": str(e)}) return self._send(500, {"error": str(e)})
save_services(services) save_services(services)
diag_log("info", "Service removed", {"port": port})
return self._send(200, {"services": services, "message": f"Removed service on port {port}"}) return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
if self.path.startswith("/api/services/update"): if self.path.startswith("/api/services/update"):
port = int(payload.get("port", 0)) port = int(payload.get("port", 0))
@@ -1231,7 +1374,7 @@ class Handler(BaseHTTPRequestHandler):
svc["port"] = new_port_int svc["port"] = new_port_int
target_port = new_port_int target_port = new_port_int
port_changed = True port_changed = True
host = socket.gethostname() host = default_host()
if new_path is not None: if new_path is not None:
path = normalize_path(new_path) path = normalize_path(new_path)
if path: if path:
@@ -1272,6 +1415,7 @@ class Handler(BaseHTTPRequestHandler):
if not updated: if not updated:
return self._send(404, {"error": "service not found"}) return self._send(404, {"error": "service not found"})
save_services(services) save_services(services)
diag_log("info", "Service updated", {"port": target_port, "name": new_name or None, "scheme": scheme})
return self._send(200, {"services": services, "message": "Service updated"}) return self._send(200, {"services": services, "message": "Service updated"})
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})

View File

@@ -117,3 +117,15 @@ export const removeService = ({ port }) =>
method: "POST", method: "POST",
body: JSON.stringify({ port }), body: JSON.stringify({ port }),
}); });
// Diagnostics
export const getDiagLog = () => api("/api/diag/log");
export const setDiagLevel = ({ enabled, level }) =>
api("/api/diag/log/level", {
method: "POST",
body: JSON.stringify({ enabled, level }),
});
export const clearDiagLog = () =>
api("/api/diag/log/clear", {
method: "POST",
});

View File

@@ -0,0 +1,4 @@
.diag-log-modal .log-box {
max-height: 60vh;
min-height: 300px;
}

225
pikit-web/assets/diaglog.js Normal file
View File

@@ -0,0 +1,225 @@
// Diagnostic logging (frontend side)
// Maintains a client-side ring buffer, fetches server logs, and wires UI controls.
import { getDiagLog, setDiagLevel, clearDiagLog } from "./api.js";
const UI_MAX = 500;
const uiBuffer = [];
let uiEnabled = false;
let uiLevel = "normal";
let clickListenerAttached = false;
let loading = false;
function appendUi(level, msg, meta = null) {
if (!uiEnabled) return;
if (level === "debug" && uiLevel !== "debug") return;
const ts = new Date().toISOString();
const entry = { ts, level, msg, meta, source: "ui" };
uiBuffer.unshift(entry);
if (uiBuffer.length > UI_MAX) uiBuffer.length = UI_MAX;
}
function attachClickTracker() {
if (clickListenerAttached) return;
clickListenerAttached = true;
document.addEventListener(
"click",
(e) => {
if (!uiEnabled || uiLevel !== "debug") return;
const el = e.target.closest("button,input,select,textarea,label");
if (!el) return;
const label =
el.getAttribute("aria-label") ||
el.getAttribute("title") ||
el.textContent?.trim()?.slice(0, 60) ||
el.id ||
el.tagName.toLowerCase();
appendUi("debug", `UI click: ${label || el.tagName}`, {
id: el.id || null,
type: el.tagName.toLowerCase(),
});
},
true,
);
}
export function logUi(msg, level = "info", meta) {
appendUi(level, msg, meta);
}
export async function initDiagUI({ elements, toast }) {
const {
enableToggle,
debugToggle,
refreshBtn,
clearBtn,
copyBtn,
downloadBtn,
logBox,
statusEl,
logButton,
modal,
modalClose,
} = elements;
const setBusy = (on) => {
loading = on;
[refreshBtn, clearBtn, copyBtn, downloadBtn, enableToggle, debugToggle].forEach((el) => {
if (el) el.disabled = !!on;
});
};
async function syncState() {
const data = await getDiagLog();
const state = data.state || {};
uiEnabled = !!state.enabled;
uiLevel = state.level || "normal";
if (enableToggle) enableToggle.checked = uiEnabled;
if (debugToggle) debugToggle.checked = uiLevel === "debug";
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
if (modal && !uiEnabled) modal.classList.add("hidden");
return data.entries || [];
}
function render(entries) {
if (!logBox) return;
const merged = [
...(entries || []).map((e) => ({ ...e, source: "api" })),
...uiBuffer,
].sort((a, b) => (a.ts < b.ts ? 1 : -1));
logBox.textContent = merged
.map((e) => `${new Date(e.ts).toLocaleTimeString()} [${e.source || "api"} ${e.level}] ${e.msg}`)
.join("\n");
if (statusEl) statusEl.textContent = `${merged.length} entries`;
}
async function refresh() {
if (loading) return;
setBusy(true);
try {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed", "success");
} catch (e) {
toast?.(e.error || "Failed to load diagnostics", "error");
// retry once if failed
try {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed (after retry)", "success");
} catch (err2) {
toast?.(err2.error || "Diagnostics still failing", "error");
}
} finally {
setBusy(false);
}
}
enableToggle?.addEventListener("change", async () => {
try {
setBusy(true);
uiEnabled = enableToggle.checked;
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
appendUi("info", `Diagnostics ${uiEnabled ? "enabled" : "disabled"}`);
if (uiEnabled) attachClickTracker();
await refresh();
} catch (e) {
toast?.(e.error || "Failed to save diagnostics setting", "error");
enableToggle.checked = !enableToggle.checked;
setBusy(false);
return;
} finally {
setBusy(false);
}
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
if (!uiEnabled && modal) modal.classList.add("hidden");
});
debugToggle?.addEventListener("change", async () => {
try {
setBusy(true);
uiLevel = debugToggle.checked ? "debug" : "normal";
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
appendUi("info", `Diagnostics level set to ${uiLevel}`);
if (uiEnabled) attachClickTracker();
await refresh();
} catch (e) {
toast?.(e.error || "Failed to save level", "error");
debugToggle.checked = uiLevel === "debug";
} finally {
setBusy(false);
}
});
refreshBtn?.addEventListener("click", refresh);
clearBtn?.addEventListener("click", async () => {
try {
setBusy(true);
await clearDiagLog();
uiBuffer.length = 0;
appendUi("info", "Cleared diagnostics");
// Immediately reflect empty log in UI, then refresh from server
if (logBox) logBox.textContent = "";
render([]);
await refresh();
} catch (e) {
toast?.(e.error || "Failed to clear log", "error");
} finally {
setBusy(false);
}
});
copyBtn?.addEventListener("click", async () => {
try {
const text = logBox?.textContent || "";
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text || "No log entries.");
} else {
const ta = document.createElement("textarea");
ta.value = text || "No log entries.";
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
toast?.("Diagnostics copied", "success");
} catch (e) {
toast?.("Copy failed", "error");
}
});
downloadBtn?.addEventListener("click", () => {
try {
const blob = new Blob([logBox?.textContent || "No log entries."], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "pikit-diagnostics.txt";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast?.("Download failed", "error");
}
});
// initial load
attachClickTracker();
await refresh();
logButton?.addEventListener("click", () => {
if (!uiEnabled) return;
modal?.classList.remove("hidden");
});
modalClose?.addEventListener("click", () => modal?.classList.add("hidden"));
modal?.addEventListener("click", (e) => {
if (e.target === modal) e.stopPropagation(); // prevent accidental close
});
return {
logUi,
refresh,
};
}

View File

@@ -5,7 +5,8 @@ import { placeholderStatus, renderStats } from "./status.js";
import { initServiceControls, renderServices } from "./services.js"; import { initServiceControls, renderServices } from "./services.js";
import { initSettings } from "./settings.js"; import { initSettings } from "./settings.js";
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js"; import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
import { initReleaseUI } from "./releases.js?v=20251213g"; import { initReleaseUI } from "./releases.js?v=20251213h";
import { initDiagUI, logUi } from "./diaglog.js?v=20251213j";
const servicesGrid = document.getElementById("servicesGrid"); const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats"); const heroStats = document.getElementById("heroStats");
@@ -97,6 +98,18 @@ const changelogModal = document.getElementById("changelogModal");
const changelogTitle = document.getElementById("changelogTitle"); const changelogTitle = document.getElementById("changelogTitle");
const changelogBody = document.getElementById("changelogBody"); const changelogBody = document.getElementById("changelogBody");
const changelogClose = document.getElementById("changelogClose"); const changelogClose = document.getElementById("changelogClose");
const diagEnableToggle = document.getElementById("diagEnableToggle");
const diagDebugToggle = document.getElementById("diagDebugToggle");
const diagRefreshBtn = document.getElementById("diagRefreshBtn");
const diagClearBtn = document.getElementById("diagClearBtn");
const diagCopyBtn = document.getElementById("diagCopyBtn");
const diagDownloadBtn = document.getElementById("diagDownloadBtn");
const diagLogBox = document.getElementById("diagLogBox");
const diagStatus = document.getElementById("diagStatus");
const diagLogBtn = document.getElementById("diagLogBtn");
const diagModal = document.getElementById("diagModal");
const diagClose = document.getElementById("diagClose");
const diagStatusModal = document.getElementById("diagStatusModal");
const TOAST_POS_KEY = "pikit-toast-pos"; const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim"; const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -120,6 +133,7 @@ let toastDurationMs = 5000;
let toastSpeedMs = 300; let toastSpeedMs = 300;
let fontChoice = "redhat"; let fontChoice = "redhat";
let releaseUI = null; let releaseUI = null;
let lastStatusData = null;
function applyToastSettings() { function applyToastSettings() {
if (!toastContainer) return; if (!toastContainer) return;
@@ -305,6 +319,7 @@ function setUpdatesUI(enabled) {
async function loadStatus() { async function loadStatus() {
try { try {
const data = await getStatus(); const data = await getStatus();
lastStatusData = data;
renderStats(heroStats, data); renderStats(heroStats, data);
renderServices(servicesGrid, data.services, { openAddService }); renderServices(servicesGrid, data.services, { openAddService });
const updatesEnabled = const updatesEnabled =
@@ -345,7 +360,11 @@ async function loadStatus() {
releaseUI?.refreshStatus(); releaseUI?.refreshStatus();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
renderStats(heroStats, placeholderStatus); logUi(`Status refresh failed: ${e?.message || e}`, "error");
if (!lastStatusData) {
renderStats(heroStats, placeholderStatus);
}
setTimeout(loadStatus, 2000);
} }
} }
@@ -495,6 +514,7 @@ function main() {
showBusy, showBusy,
hideBusy, hideBusy,
confirmAction, confirmAction,
logUi,
}); });
loadToastSettings(); loadToastSettings();
@@ -558,6 +578,26 @@ function main() {
}, },
}); });
// Diagnostics
initDiagUI({
elements: {
enableToggle: diagEnableToggle,
debugToggle: diagDebugToggle,
refreshBtn: diagRefreshBtn,
clearBtn: diagClearBtn,
copyBtn: diagCopyBtn,
downloadBtn: diagDownloadBtn,
logBox: diagLogBox,
statusEl: diagStatusModal || diagStatus,
logButton: diagLogBtn,
modal: diagModal,
modalClose: diagClose,
},
toast: showToast,
}).catch((e) => {
console.error("Diag init failed", e);
});
// Toast controls // Toast controls
toastPosSelect?.addEventListener("change", () => { toastPosSelect?.addEventListener("change", () => {
const val = toastPosSelect.value; const val = toastPosSelect.value;

View File

@@ -15,7 +15,7 @@ function shorten(text, max = 90) {
return text.length > max ? `${text.slice(0, max - 3)}...` : text; return text.length > max ? `${text.slice(0, max - 3)}...` : text;
} }
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction }) { export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) {
const releaseFlagTop = document.getElementById("releaseFlagTop"); const releaseFlagTop = document.getElementById("releaseFlagTop");
const releaseBtn = document.getElementById("releaseBtn"); const releaseBtn = document.getElementById("releaseBtn");
const releaseModal = document.getElementById("releaseModal"); const releaseModal = document.getElementById("releaseModal");
@@ -64,6 +64,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
releaseLog.textContent = releaseLogLines.join("\n"); releaseLog.textContent = releaseLogLines.join("\n");
releaseLog.scrollTop = 0; // keep most recent in view releaseLog.scrollTop = 0; // keep most recent in view
} }
// Mirror into global diagnostics log (frontend side)
const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info";
logUi(`Update: ${msg}`, lvl);
} }
function setReleaseChip(state) { function setReleaseChip(state) {
@@ -174,12 +177,17 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
showBusy("Working on update…", progress || "This can take up to a minute."); showBusy("Working on update…", progress || "This can take up to a minute.");
pollReleaseStatus(); pollReleaseStatus();
} }
} catch (e) { } catch (e) {
// During an update/rollback the API may restart; retry quietly.
if (releaseBusyActive) {
setTimeout(() => loadReleaseStatus(true), 1000);
return;
}
console.error("Failed to load release status", e); console.error("Failed to load release status", e);
setReleaseChip({ status: "error", message: "Failed to load" }); setReleaseChip({ status: "error", message: "Failed to load" });
// surface via toast/log only; avoid inline red flashes // surface via toast/log only once
showToast("Failed to load release status", "error");
logRelease("Error: failed to load release status"); logRelease("Error: failed to load release status");
showToast("Failed to load release status", "error");
} }
} }
@@ -236,6 +244,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
releaseCheckBtn?.addEventListener("click", async () => { releaseCheckBtn?.addEventListener("click", async () => {
try { try {
logRelease("Checking for updates…"); logRelease("Checking for updates…");
logUi("Update check requested");
await checkRelease(); await checkRelease();
await loadReleaseStatus(true); await loadReleaseStatus(true);
const state = window.__lastReleaseState || {}; const state = window.__lastReleaseState || {};
@@ -254,6 +263,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
releaseApplyBtn?.addEventListener("click", async () => { releaseApplyBtn?.addEventListener("click", async () => {
try { try {
lastReleaseToastKey = null; lastReleaseToastKey = null;
logUi("Update apply requested");
const state = window.__lastReleaseState || {}; const state = window.__lastReleaseState || {};
const { current_version, latest_version } = state; const { current_version, latest_version } = state;
const sameVersion = const sameVersion =
@@ -287,6 +297,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
releaseRollbackBtn?.addEventListener("click", async () => { releaseRollbackBtn?.addEventListener("click", async () => {
try { try {
lastReleaseToastKey = null; lastReleaseToastKey = null;
logUi("Rollback requested");
releaseBusyActive = true; releaseBusyActive = true;
showBusy("Rolling back…", "Restoring previous backup."); showBusy("Rolling back…", "Restoring previous backup.");
logRelease("Starting rollback…"); logRelease("Starting rollback…");

View File

@@ -422,6 +422,10 @@ body {
min-height: 220px; min-height: 220px;
overflow-y: auto; overflow-y: auto;
} }
#diagModal pre.log-box {
max-height: 60vh;
min-height: 300px;
}
#releaseProgress { #releaseProgress {
display: none; display: none;
} }

View File

@@ -33,6 +33,9 @@
> >
<span id="themeToggleIcon" aria-hidden="true">&#127769;</span> <span id="themeToggleIcon" aria-hidden="true">&#127769;</span>
</button> </button>
<button id="diagLogBtn" class="ghost hidden" title="Diagnostics log (visible when enabled)">
Log
</button>
<button id="releaseBtn" class="ghost" title="Pi-Kit updates"> <button id="releaseBtn" class="ghost" title="Pi-Kit updates">
Update Update
</button> </button>
@@ -102,6 +105,29 @@
</div> </div>
</div> </div>
<div id="diagModal" class="modal hidden diag-log-modal">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Diagnostics</p>
<h3>Diagnostics log</h3>
<p class="hint">RAM-only; cleared on reboot/clear. Use toggles in Settings → Diagnostics to enable.</p>
</div>
<button id="diagClose" class="ghost icon-btn close-btn" title="Close diagnostics log">
&times;
</button>
</div>
<div class="control-actions wrap gap">
<button id="diagRefreshBtn" class="ghost" title="Refresh diagnostics log">Refresh</button>
<button id="diagClearBtn" class="ghost" title="Clear diagnostics log">Clear</button>
<button id="diagCopyBtn" class="ghost" title="Copy diagnostics to clipboard">Copy</button>
<button id="diagDownloadBtn" class="ghost" title="Download diagnostics as text">Download</button>
<span id="diagStatusModal" class="hint quiet"></span>
</div>
<pre id="diagLogBox" class="log-box" aria-live="polite"></pre>
</div>
</div>
<div id="releaseModal" class="modal hidden"> <div id="releaseModal" class="modal hidden">
<div class="modal-card wide"> <div class="modal-card wide">
<div class="panel-header sticky"> <div class="panel-header sticky">
@@ -250,6 +276,7 @@
type="text" type="text"
id="svcName" id="svcName"
placeholder="Service name" placeholder="Service name"
title="Service name"
maxlength="32" maxlength="32"
/> />
<p class="hint quiet">Service name: max 32 characters.</p> <p class="hint quiet">Service name: max 32 characters.</p>
@@ -259,11 +286,13 @@
placeholder="Port (e.g. 8080)" placeholder="Port (e.g. 8080)"
min="1" min="1"
max="65535" max="65535"
title="Service port"
/> />
<input <input
type="text" type="text"
id="svcPath" id="svcPath"
placeholder="Optional path (e.g. /admin)" placeholder="Optional path (e.g. /admin)"
title="Optional path (e.g. /admin)"
/> />
<div class="control-row split"> <div class="control-row split">
<label class="checkbox-row"> <label class="checkbox-row">
@@ -282,11 +311,13 @@
id="svcNotice" id="svcNotice"
rows="3" rows="3"
placeholder="Optional notice (shown on card)" placeholder="Optional notice (shown on card)"
title="Optional notice shown on the service card"
></textarea> ></textarea>
<input <input
type="text" type="text"
id="svcNoticeLink" id="svcNoticeLink"
placeholder="Optional link for more info" placeholder="Optional link for more info"
title="Optional link for more info"
/> />
<div class="control-actions"> <div class="control-actions">
<button id="svcAddBtn" title="Add service and open port on LAN"> <button id="svcAddBtn" title="Add service and open port on LAN">
@@ -522,6 +553,31 @@
</div> </div>
</div> </div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-diag">
Diagnostics
</button>
<div class="accordion-body" id="acc-diag">
<p class="hint">
Temporary, RAM-only logs for debugging. Toggle on, choose debug for extra detail. Logs reset on reboot or clear. Use the Log button in the top bar (visible when diagnostics is enabled) to view, copy, download, or clear entries.
</p>
<div class="control-actions split-row">
<label class="checkbox-row inline tight">
<input type="checkbox" id="diagEnableToggle" />
<span>Enable diagnostics</span>
</label>
<label class="checkbox-row inline tight">
<input type="checkbox" id="diagDebugToggle" />
<span>Debug detail (includes UI clicks)</span>
</label>
</div>
<div class="control-actions">
<span id="diagStatus" class="hint quiet"></span>
</div>
<p class="hint quiet">Stored in RAM (max ~1MB server, ~500 entries client). No data written to disk.</p>
</div>
</div>
<div class="accordion"> <div class="accordion">
<button class="accordion-toggle danger-btn" data-target="acc-reset"> <button class="accordion-toggle danger-btn" data-target="acc-reset">
Factory reset Factory reset
@@ -726,7 +782,7 @@
</div> </div>
</div> </div>
<script type="module" src="assets/main.js?v=20251213g"></script> <script type="module" src="assets/main.js?v=20251213j"></script>
<div id="toastContainer" class="toast-container"></div> <div id="toastContainer" class="toast-container"></div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,142 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to your Pi-Kit</title>
<link rel="stylesheet" href="/style.css?v=2" />
</head>
<body>
<main class="card">
<header>
<div class="dot"></div>
<h1>Welcome to your Pi-Kit</h1>
</header>
<p class="welcome">Great news — youre already on your Pi-Kit and its responding.</p>
<p class="subtle">
Everything stays on your local network. Lets move you to the secure (HTTPS) dashboard so you
can manage Pi-Kit safely.
</p>
<div class="badges">
<span class="badge"><span class="dot"></span> Local-only traffic</span>
<span class="badge"><span class="dot"></span> Covers the Pi-Kit dashboard</span>
<span class="badge"><span class="dot"></span> HTTPS ready once trusted</span>
</div>
<section class="actions">
<button id="continueBtn">Continue to secure dashboard</button>
<a class="ghost" id="downloadCa" href="http://pikit.local/assets/pikit-ca.crt" download>
Download Pi-Kit CA
</a>
</section>
<section class="steps">
<h3>Why switch to HTTPS?</h3>
<ul>
<li>Encrypts traffic on your LAN so no one can snoop your Pi-Kit dashboard.</li>
<li>Stops mixed-content / “not secure” browser warnings.</li>
<li>Needed for some browser features (clipboard, notifications, service workers).</li>
</ul>
</section>
<section class="steps">
<h3>If you see a warning</h3>
<ul>
<li>Brave/Chrome: click <strong>Advanced</strong><strong>Proceed</strong>.</li>
<li>Firefox: click <strong>Advanced</strong><strong>Accept the Risk & Continue</strong>.</li>
</ul>
<p>This warning is expected the first time. Its safe for your own Pi on your own network.</p>
</section>
<section class="steps">
<h3>Install the Pi-Kit CA (recommended, one-time)</h3>
<p>This removes future warnings for the Pi-Kit dashboard.</p>
<details>
<summary>Windows</summary>
<p>Run <strong>mmc</strong> → Add/Remove Snap-in → Certificates (Computer) → Trusted Root CAs → Import <em>pikit-ca.crt</em>.</p>
</details>
<details>
<summary>macOS</summary>
<p>Double-click <em>pikit-ca.crt</em> → Always Trust.</p>
</details>
<details>
<summary>Linux (Arch / Manjaro / Garuda, etc.)</summary>
<code id="archCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo trust extract-compat</code>
<button class="copy" data-target="archCmd">Copy</button>
</details>
<details>
<summary>Linux (Debian / Ubuntu)</summary>
<code id="debCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /usr/local/share/ca-certificates/pikit-ca.crt && sudo update-ca-certificates</code>
<button class="copy" data-target="debCmd">Copy</button>
</details>
<details>
<summary>Linux (Fedora)</summary>
<code id="fedoraCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /etc/pki/ca-trust/source/anchors/pikit-ca.crt && sudo update-ca-trust</code>
<button class="copy" data-target="fedoraCmd">Copy</button>
</details>
<details>
<summary>BSD (FreeBSD / OpenBSD)</summary>
<code id="bsdCmd">fetch -o /tmp/pikit-ca.crt http://pikit.local/assets/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /usr/local/share/certs/pikit-ca.crt && sudo certctl rehash</code>
<button class="copy" data-target="bsdCmd">Copy</button>
</details>
</section>
<p class="footnote">Once trusted, this page will auto-forward you to the secure dashboard.</p>
</main>
<script>
(function () {
const target = `https://${location.hostname}`;
const hasCookie = document.cookie.includes("pikit_https_ok=1");
document.getElementById("continueBtn").addEventListener("click", () => {
window.location = target;
});
document.querySelectorAll(".copy").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.target;
const el = document.getElementById(id);
const text = el.textContent.trim();
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
btn.textContent = "Copied";
setTimeout(() => (btn.textContent = "Copy"), 1500);
} catch (err) {
btn.textContent = "Failed";
setTimeout(() => (btn.textContent = "Copy"), 1500);
}
});
});
// Accordion: keep only one platform section open at a time
const detailBlocks = Array.from(document.querySelectorAll("details"));
detailBlocks.forEach((d) => {
d.addEventListener("toggle", () => {
if (!d.open) return;
detailBlocks.forEach((other) => {
if (other !== d) other.removeAttribute("open");
});
});
});
if (hasCookie) {
window.location = target;
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,194 @@
:root {
color-scheme: dark;
--bg: #070b15;
--panel: rgba(13, 18, 28, 0.9);
--panel-2: rgba(18, 26, 40, 0.7);
--text: #e9f0ff;
--muted: #9bb0ca;
--accent: #44d392;
--accent-2: #6cc9ff;
--border: #1b2538;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
--glow: 0 20px 70px rgba(68, 211, 146, 0.14);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 22px;
background: radial-gradient(140% 140% at 12% 18%, #0f1625, #080c14 58%);
color: var(--text);
font-family: "DM Sans", "Inter", system-ui, -apple-system, sans-serif;
}
.card {
max-width: 920px;
width: 100%;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 28px 30px 32px;
box-shadow: var(--shadow), var(--glow);
backdrop-filter: blur(10px);
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
h1 {
margin: 0;
font-size: 1.7rem;
letter-spacing: 0.01em;
}
p {
margin: 10px 0;
color: var(--muted);
line-height: 1.55;
}
.welcome {
font-size: 1.05rem;
color: var(--text);
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 14px var(--accent);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0 4px;
}
.badge {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
}
.badge .dot {
width: 9px;
height: 9px;
box-shadow: none;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin: 18px 0 12px;
}
button,
a.ghost {
border: 1px solid var(--border);
background: linear-gradient(135deg, var(--accent), #2dbb7b);
color: #041008;
padding: 12px 18px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
box-shadow: 0 10px 30px rgba(68, 211, 146, 0.22);
transition: transform 0.1s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
button:hover,
a.ghost:hover {
transform: translateY(-1px);
box-shadow: 0 12px 36px rgba(68, 211, 146, 0.3);
filter: brightness(1.02);
}
button.ghost,
a.ghost {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
box-shadow: none;
}
button.copy {
margin-left: 8px;
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border: 1px solid var(--border);
padding: 7px 11px;
box-shadow: none;
}
.grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
margin-top: 14px;
}
.steps {
padding: 14px 15px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.steps h3 {
margin: 0 0 8px;
letter-spacing: 0.01em;
}
ul {
margin: 6px 0 6px 18px;
color: var(--text);
}
code {
display: block;
background: #0b111c;
border: 1px solid var(--border);
padding: 12px;
border-radius: 12px;
margin-top: 6px;
color: var(--text);
word-break: break-all;
}
summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.footnote {
font-size: 0.93rem;
color: var(--muted);
margin-top: 12px;
}
.subtle {
color: var(--muted);
margin-top: 2px;
}