14 Commits

7 changed files with 393 additions and 25 deletions

View File

@@ -174,6 +174,14 @@ def normalize_path(path: str | None) -> str:
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):
# Legacy debug file logging (when /boot/pikit-debug exists)
if DEBUG_FLAG:
@@ -226,7 +234,7 @@ def load_services():
try:
data = json.loads(SERVICE_JSON.read_text())
# Normalize entries: ensure url built from port if missing
host = socket.gethostname()
host = default_host()
for svc in data:
svc_path = normalize_path(svc.get("path"))
if svc_path:
@@ -506,16 +514,8 @@ def set_updates_config(opts: dict):
def detect_https(host, port):
try:
import ssl
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
"""Heuristic: known HTTPS ports or .local certs."""
return int(port) in HTTPS_PORTS or str(host).lower().endswith(".local") or str(host).lower() == "pikit"
def factory_reset():
@@ -1176,6 +1176,12 @@ class Handler(BaseHTTPRequestHandler):
if port:
svc["online"] = port_online("127.0.0.1", 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)
self._send(200, {"services": services})
elif self.path.startswith("/api/updates/auto"):
@@ -1294,7 +1300,7 @@ class Handler(BaseHTTPRequestHandler):
services = load_services()
if any(s.get("port") == port for s in services):
return self._send(400, {"error": "port already exists"})
host = socket.gethostname()
host = default_host()
scheme = payload.get("scheme")
if scheme not in ("http", "https"):
scheme = "https" if detect_https(host, port) else "http"
@@ -1368,7 +1374,7 @@ class Handler(BaseHTTPRequestHandler):
svc["port"] = new_port_int
target_port = new_port_int
port_changed = True
host = socket.gethostname()
host = default_host()
if new_path is not None:
path = normalize_path(new_path)
if path:

View File

@@ -107,8 +107,8 @@ export async function initDiagUI({ elements, toast }) {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed (after retry)", "success");
} catch {
// swallow
} catch (err2) {
toast?.(err2.error || "Diagnostics still failing", "error");
}
} finally {
setBusy(false);
@@ -126,9 +126,13 @@ export async function initDiagUI({ elements, toast }) {
} 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 () => {
@@ -155,6 +159,9 @@ export async function initDiagUI({ elements, toast }) {
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");

View File

@@ -6,7 +6,7 @@ import { initServiceControls, renderServices } from "./services.js";
import { initSettings } from "./settings.js";
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
import { initReleaseUI } from "./releases.js?v=20251213h";
import { initDiagUI, logUi } from "./diaglog.js?v=20251213i";
import { initDiagUI, logUi } from "./diaglog.js?v=20251213j";
const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats");
@@ -133,6 +133,7 @@ let toastDurationMs = 5000;
let toastSpeedMs = 300;
let fontChoice = "redhat";
let releaseUI = null;
let lastStatusData = null;
function applyToastSettings() {
if (!toastContainer) return;
@@ -318,6 +319,7 @@ function setUpdatesUI(enabled) {
async function loadStatus() {
try {
const data = await getStatus();
lastStatusData = data;
renderStats(heroStats, data);
renderServices(servicesGrid, data.services, { openAddService });
const updatesEnabled =
@@ -358,7 +360,11 @@ async function loadStatus() {
releaseUI?.refreshStatus();
} catch (e) {
console.error(e);
renderStats(heroStats, placeholderStatus);
logUi(`Status refresh failed: ${e?.message || e}`, "error");
if (!lastStatusData) {
renderStats(heroStats, placeholderStatus);
}
setTimeout(loadStatus, 2000);
}
}

View File

@@ -64,6 +64,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
releaseLog.textContent = releaseLogLines.join("\n");
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) {
@@ -174,12 +177,17 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
showBusy("Working on update…", progress || "This can take up to a minute.");
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);
setReleaseChip({ status: "error", message: "Failed to load" });
// surface via toast/log only; avoid inline red flashes
showToast("Failed to load release status", "error");
// surface via toast/log only once
logRelease("Error: failed to load release status");
showToast("Failed to load release status", "error");
}
}

View File

@@ -118,10 +118,10 @@
</button>
</div>
<div class="control-actions wrap gap">
<button id="diagRefreshBtn" class="ghost">Refresh</button>
<button id="diagClearBtn" class="ghost">Clear</button>
<button id="diagCopyBtn" class="ghost">Copy</button>
<button id="diagDownloadBtn" class="ghost">Download</button>
<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>
@@ -276,6 +276,7 @@
type="text"
id="svcName"
placeholder="Service name"
title="Service name"
maxlength="32"
/>
<p class="hint quiet">Service name: max 32 characters.</p>
@@ -285,11 +286,13 @@
placeholder="Port (e.g. 8080)"
min="1"
max="65535"
title="Service port"
/>
<input
type="text"
id="svcPath"
placeholder="Optional path (e.g. /admin)"
title="Optional path (e.g. /admin)"
/>
<div class="control-row split">
<label class="checkbox-row">
@@ -308,11 +311,13 @@
id="svcNotice"
rows="3"
placeholder="Optional notice (shown on card)"
title="Optional notice shown on the service card"
></textarea>
<input
type="text"
id="svcNoticeLink"
placeholder="Optional link for more info"
title="Optional link for more info"
/>
<div class="control-actions">
<button id="svcAddBtn" title="Add service and open port on LAN">
@@ -777,7 +782,7 @@
</div>
</div>
<script type="module" src="assets/main.js?v=20251213i"></script>
<script type="module" src="assets/main.js?v=20251213j"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</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;
}