Files
zone/index.html
2026-05-08 12:55:08 +01:00

1220 lines
41 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home Server Control</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #19150f;
--ink: #f4e7bc;
--muted: #a69262;
--dim: #6e613f;
--line: #51452c;
--line-strong: #8b7544;
--panel: #241e14;
--panel-hi: #302717;
--green: #5cff9b;
--green-dim: #245b34;
--amber: #ffbf47;
--red: #ff5d54;
--blue: #6ac8ff;
--violet: #c59cff;
--shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
}
html { scroll-behavior: smooth; }
body {
min-height: 100vh;
background:
linear-gradient(180deg, rgba(255, 191, 71, 0.04), transparent 32rem),
radial-gradient(circle at top left, rgba(92, 255, 155, 0.08), transparent 26rem),
var(--bg);
color: var(--ink);
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.035) 0 1px, transparent 1px 4px),
linear-gradient(90deg, rgba(255, 191, 71, 0.05), transparent 18%, transparent 82%, rgba(255, 191, 71, 0.04));
mix-blend-mode: screen;
opacity: 0.36;
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 191, 71, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 191, 71, 0.035) 1px, transparent 1px);
background-size: 24px 24px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
a { color: inherit; }
button {
font: inherit;
}
.page {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 48px;
}
.topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: end;
border: 2px solid var(--line-strong);
border-radius: 8px;
padding: 18px;
background: linear-gradient(135deg, rgba(48, 39, 23, 0.95), rgba(25, 21, 15, 0.95));
box-shadow: var(--shadow);
}
.brand-kicker {
color: var(--green);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.site-title {
margin-top: 6px;
font-family: "Space Grotesk", system-ui, sans-serif;
font-size: clamp(2rem, 5vw, 4rem);
line-height: 0.9;
letter-spacing: 0;
text-transform: uppercase;
}
.header-meta {
min-width: 230px;
text-align: right;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.7;
}
.clock-display {
color: var(--amber);
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 0.06em;
}
#globalStatus {
margin: 16px 0;
display: flex;
align-items: center;
gap: 10px;
border: 2px solid;
border-radius: 8px;
padding: 12px 14px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.global-ok {
color: var(--green);
background: rgba(36, 91, 52, 0.32);
border-color: var(--green-dim);
}
.global-error {
color: var(--red);
background: rgba(255, 93, 84, 0.12);
border-color: rgba(255, 93, 84, 0.5);
}
.status-pulse {
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 4px rgba(92, 255, 155, 0.12);
}
.dashboard-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.rail {
position: sticky;
top: 16px;
display: grid;
gap: 16px;
}
.main-stack {
display: grid;
gap: 16px;
}
.panel {
border: 2px solid var(--line);
border-radius: 8px;
background: rgba(36, 30, 20, 0.94);
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--panel-hi);
border-bottom: 2px solid var(--line);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--amber);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.panel-title::before {
content: ">";
color: var(--green);
}
.panel-body {
padding: 14px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.stat-card {
min-height: 84px;
display: grid;
align-content: space-between;
gap: 10px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
background: rgba(25, 21, 15, 0.62);
}
.stat-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stat-value {
font-size: 1rem;
font-weight: 700;
color: var(--ink);
}
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 1px solid rgba(244, 231, 188, 0.35);
flex: 0 0 auto;
}
.dot.green { background: var(--green); box-shadow: 0 0 10px rgba(92, 255, 155, 0.55); }
.dot.red { background: var(--red); box-shadow: 0 0 10px rgba(255, 93, 84, 0.55); }
.dot.yellow { background: var(--amber); box-shadow: 0 0 10px rgba(255, 191, 71, 0.55); animation: blink 0.8s step-end infinite; }
@keyframes blink { 50% { opacity: 0.35; } }
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.badge.ok { color: var(--green); border-color: var(--green-dim); background: rgba(36, 91, 52, 0.2); }
.badge.error { color: var(--red); border-color: rgba(255, 93, 84, 0.45); background: rgba(255, 93, 84, 0.12); }
.badge.neutral { color: var(--muted); }
.badge.warn { color: var(--amber); border-color: rgba(255, 191, 71, 0.45); background: rgba(255, 191, 71, 0.12); }
.quick-links {
display: grid;
gap: 8px;
}
.link-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 42px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: rgba(25, 21, 15, 0.62);
color: var(--ink);
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
}
.link-card:hover,
.link-card:focus-visible {
outline: none;
border-color: var(--amber);
color: var(--amber);
}
.link-card .icon {
color: var(--green);
font-weight: 700;
}
.utility-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(240px, 0.8fr);
gap: 16px;
}
.daily-message {
color: var(--ink);
font-size: 1.05rem;
line-height: 1.6;
border-left: 4px solid var(--violet);
padding-left: 12px;
}
.countdown-display {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.time-unit {
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px;
background: rgba(25, 21, 15, 0.62);
text-align: center;
}
.time-number {
display: block;
color: var(--green);
font-size: clamp(1.35rem, 3vw, 2rem);
font-weight: 700;
line-height: 1;
}
.time-label {
margin-top: 5px;
color: var(--muted);
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.uptime-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.uptime-item {
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
background: rgba(25, 21, 15, 0.62);
}
.uptime-item-label {
color: var(--muted);
font-size: 0.65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.uptime-item-value {
margin-top: 6px;
color: var(--blue);
font-size: 1rem;
font-weight: 700;
}
.uptime-bar-wrap { margin-top: 14px; }
.uptime-label {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.uptime-bar {
height: 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: #15110b;
overflow: hidden;
}
.uptime-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--green), var(--blue));
transition: width 1s ease;
}
.ping-history {
display: flex;
align-items: end;
gap: 4px;
min-height: 36px;
margin-top: 10px;
overflow: hidden;
}
.ping-bar {
width: 8px;
flex: 0 0 8px;
border-radius: 2px 2px 0 0;
opacity: 0.82;
}
.run-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.run-card .panel-body {
display: grid;
gap: 12px;
}
.run-summary {
display: grid;
gap: 8px;
padding: 12px;
border: 1px dashed var(--line);
border-radius: 6px;
background: rgba(25, 21, 15, 0.5);
}
.status-text {
min-height: 1.3em;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
.status-text.running { color: var(--amber); }
.status-text.success { color: var(--green); }
.status-text.failed { color: var(--red); }
.last-run-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
color: var(--muted);
font-size: 0.68rem;
}
.last-run-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
padding: 8px 11px;
border: 2px solid;
border-radius: 6px;
background: #15110b;
color: var(--ink);
cursor: pointer;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.btn:hover:not(:disabled),
.btn:focus-visible:not(:disabled) {
outline: none;
transform: translateY(-1px);
background: #20190e;
}
.btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.btn-primary { border-color: var(--green-dim); color: var(--green); }
.btn-secondary { border-color: rgba(106, 200, 255, 0.5); color: var(--blue); }
.btn-danger { border-color: rgba(255, 93, 84, 0.55); color: var(--red); }
.btn-combined { border-color: rgba(255, 191, 71, 0.55); color: var(--amber); }
.btn-ghost { min-height: 32px; padding: 6px 9px; border-color: var(--line); color: var(--muted); }
.kill-btn { display: none; }
.kill-btn.visible { display: inline-flex; }
.log-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.log-label {
color: var(--dim);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.log-box {
height: 260px;
overflow-y: auto;
border: 2px solid var(--line);
border-radius: 6px;
padding: 12px;
background: #0b0f0a;
color: #c6f7b7;
font-size: 0.74rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
box-shadow: inset 0 0 22px rgba(92, 255, 155, 0.08);
}
.log-box::-webkit-scrollbar { width: 8px; }
.log-box::-webkit-scrollbar-track { background: #15110b; }
.log-box::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 8px; }
.log-line { display: block; }
.ansi-bold { font-weight: 700; }
.ansi-dim { opacity: 0.6; }
.ansi-red { color: #ff6b61; }
.ansi-green { color: #5cff9b; }
.ansi-yellow { color: #ffbf47; }
.ansi-blue { color: #6ac8ff; }
.ansi-magenta { color: #c59cff; }
.ansi-cyan { color: #77f0d4; }
.ansi-white { color: #f4e7bc; }
.ansi-reset { color: inherit; font-weight: inherit; opacity: inherit; }
.pipeline-steps {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.pipeline-step {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
}
.pipeline-step.active { color: var(--amber); border-color: rgba(255, 191, 71, 0.55); background: rgba(255, 191, 71, 0.1); }
.pipeline-step.done { color: var(--green); border-color: var(--green-dim); background: rgba(36, 91, 52, 0.2); }
.pipeline-step.failed { color: var(--red); border-color: rgba(255, 93, 84, 0.55); background: rgba(255, 93, 84, 0.1); }
.pipeline-arrow { color: var(--dim); }
#toast-container {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
display: grid;
gap: 8px;
}
.toast {
max-width: 320px;
border: 2px solid var(--line);
border-radius: 6px;
padding: 10px 12px;
background: var(--panel);
color: var(--ink);
font-size: 0.78rem;
box-shadow: var(--shadow);
}
.toast.success { border-color: var(--green-dim); color: var(--green); }
.toast.error { border-color: rgba(255, 93, 84, 0.55); color: var(--red); }
.toast.info { border-color: rgba(106, 200, 255, 0.5); color: var(--blue); }
.toast.warn { border-color: rgba(255, 191, 71, 0.55); color: var(--amber); }
.modal-overlay {
position: fixed;
inset: 0;
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(10, 8, 5, 0.82);
backdrop-filter: blur(3px);
}
.modal {
width: min(380px, 100%);
border: 2px solid var(--line-strong);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.modal h3 {
padding: 12px 14px;
border-bottom: 2px solid var(--line);
color: var(--amber);
font-size: 0.95rem;
text-transform: uppercase;
}
.modal p {
padding: 14px;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.6;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 14px 14px;
}
.hidden { display: none !important; }
@media (max-width: 940px) {
.dashboard-grid,
.utility-grid,
.run-grid {
grid-template-columns: 1fr;
}
.rail { position: static; }
.quick-links { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.page { width: min(100% - 20px, 1180px); padding-top: 10px; }
.topbar { grid-template-columns: 1fr; align-items: start; }
.header-meta { min-width: 0; text-align: left; }
.status-grid,
.quick-links,
.uptime-grid,
.countdown-display {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.btn { width: 100%; }
.log-tools { align-items: stretch; flex-direction: column; }
}
</style>
</head>
<body>
<div id="toast-container"></div>
<main class="page">
<header class="topbar">
<div>
<div class="brand-kicker">Local console / build deck</div>
<h1 class="site-title">Home Server Control</h1>
</div>
<div class="header-meta">
<div class="clock-display" id="clockDisplay">--:--:--</div>
<div id="dateDisplay"></div>
<div id="lastUpdated">Checking status...</div>
</div>
</header>
<div id="globalStatus" class="global-ok">
<div class="status-pulse"></div>
<span>All systems running normally</span>
</div>
<div class="dashboard-grid">
<aside class="rail">
<section class="panel" aria-labelledby="statusTitle">
<div class="panel-header">
<h2 class="panel-title" id="statusTitle">System</h2>
<span id="healthBadge" class="badge neutral">Checking</span>
</div>
<div class="panel-body">
<div class="status-grid">
<div class="stat-card">
<div class="stat-head"><span>Server</span><span id="serverDot" class="dot green"></span></div>
<div class="stat-value" id="health">Online</div>
</div>
<div class="stat-card">
<div class="stat-head"><span>Website</span><span id="webDot" class="dot green"></span></div>
<div class="stat-value" id="webStatus">Idle</div>
</div>
<div class="stat-card">
<div class="stat-head"><span>Roam</span><span id="roamDot" class="dot green"></span></div>
<div class="stat-value" id="roamStatus">Idle</div>
</div>
<div class="stat-card">
<div class="stat-head"><span>Uptime</span><span class="dot green"></span></div>
<div class="stat-value" id="uptimeStat">--</div>
</div>
</div>
</div>
</section>
<section class="panel" aria-labelledby="linksTitle">
<div class="panel-header">
<h2 class="panel-title" id="linksTitle">Links</h2>
</div>
<div class="panel-body">
<nav class="quick-links" aria-label="Services">
<a href="https://notes.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Notes</span><span class="icon">01</span></a>
<a href="https://zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Website</span><span class="icon">02</span></a>
<a href="https://filebrowser.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Files</span><span class="icon">03</span></a>
<a href="https://calibre.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Library</span><span class="icon">04</span></a>
<a href="https://miniflux.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>News</span><span class="icon">05</span></a>
<a href="https://guac.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>RDP</span><span class="icon">06</span></a>
<a href="https://portainer.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Portainer</span><span class="icon">07</span></a>
<a href="https://nextcloud.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Nextcloud</span><span class="icon">08</span></a>
<a href="https://pgadmin.zainezq.com" target="_blank" class="link-card" rel="noreferrer"><span>Databases</span><span class="icon">09</span></a>
</nav>
</div>
</section>
</aside>
<div class="main-stack">
<section class="utility-grid" aria-label="Personal utilities">
<div class="panel">
<div class="panel-header">
<h2 class="panel-title">Daily Message</h2>
</div>
<div class="panel-body">
<p id="dailyMessage" class="daily-message"></p>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2 class="panel-title">Countdown</h2>
<span id="countdownLabel" class="badge neutral"></span>
</div>
<div class="panel-body">
<div class="countdown-display" id="countdownDisplay">
<div class="time-unit"><span class="time-number" id="cd-days">--</span><div class="time-label">Days</div></div>
<div class="time-unit"><span class="time-number" id="cd-hours">--</span><div class="time-label">Hours</div></div>
<div class="time-unit"><span class="time-number" id="cd-mins">--</span><div class="time-label">Mins</div></div>
<div class="time-unit"><span class="time-number" id="cd-secs">--</span><div class="time-label">Secs</div></div>
</div>
</div>
</div>
</section>
<section class="panel" aria-labelledby="monitorTitle">
<div class="panel-header">
<h2 class="panel-title" id="monitorTitle">Uptime Monitor</h2>
<span class="badge neutral">30 checks</span>
</div>
<div class="panel-body">
<div class="uptime-grid" id="uptimeGrid">
<div class="uptime-item"><div class="uptime-item-label">Days up</div><div class="uptime-item-value" id="uptimeDays">--</div></div>
<div class="uptime-item"><div class="uptime-item-label">Hours</div><div class="uptime-item-value" id="uptimeHours">--</div></div>
<div class="uptime-item"><div class="uptime-item-label">Minutes</div><div class="uptime-item-value" id="uptimeMins">--</div></div>
<div class="uptime-item"><div class="uptime-item-label">Started at</div><div class="uptime-item-value" id="uptimeStart">--</div></div>
</div>
<div class="uptime-bar-wrap">
<div class="uptime-label"><span>Ping success</span><span id="uptimePct">--</span></div>
<div class="uptime-bar"><div class="uptime-fill" id="uptimeFill"></div></div>
</div>
<div class="ping-history" id="pingHistory" aria-label="Recent ping history"></div>
</div>
</section>
<section class="run-grid" aria-label="Build runs">
<article class="panel run-card">
<div class="panel-header">
<h2 class="panel-title">Website Build</h2>
<span class="badge neutral">Web</span>
</div>
<div class="panel-body">
<div class="btn-row">
<button class="btn btn-primary" id="buildBtn">Run website build</button>
<button class="btn btn-danger kill-btn" id="killWebBtn">Cancel</button>
</div>
<div class="run-summary">
<p id="buildResult" class="status-text">Waiting for action</p>
<div class="last-run-meta" id="webLastRunMeta"></div>
</div>
<div class="log-tools">
<span class="log-label">Website run log</span>
<button class="btn btn-ghost toggleLogs" data-target="logOutput">Show logs</button>
</div>
<div id="logOutput" class="log-box hidden" aria-live="polite"></div>
</div>
</article>
<article class="panel run-card">
<div class="panel-header">
<h2 class="panel-title">Roam Build</h2>
<span class="badge neutral">Roam</span>
</div>
<div class="panel-body">
<div class="btn-row">
<button class="btn btn-primary" id="orgRoamBtn">Refresh notes</button>
<button class="btn btn-secondary" id="orgRoamBtn2">Rerun Emacs</button>
<button class="btn btn-combined" id="combinedBtn">Emacs + notes</button>
<button class="btn btn-danger kill-btn" id="killRoamBtn">Cancel</button>
</div>
<div class="pipeline-steps hidden" id="pipelineSteps">
<div class="pipeline-step" id="pipeStep1">Emacs</div>
<div class="pipeline-arrow">to</div>
<div class="pipeline-step" id="pipeStep2">Notes</div>
</div>
<div class="run-summary">
<p id="orgRoamResult" class="status-text">Waiting for action</p>
<div class="last-run-meta" id="roamLastRunMeta"></div>
</div>
<div class="log-tools">
<span class="log-label">Roam run log</span>
<button class="btn btn-ghost toggleLogs" data-target="orgRoamLogs">Show logs</button>
</div>
<div id="orgRoamLogs" class="log-box hidden" aria-live="polite"></div>
</div>
</article>
</section>
</div>
</div>
</main>
<div id="modalOverlay" class="modal-overlay hidden">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h3 id="modalTitle">Confirm Action</h3>
<p id="modalBody">Are you sure?</p>
<div class="modal-actions">
<button class="btn btn-ghost" id="modalCancel">Cancel</button>
<button class="btn btn-primary" id="modalConfirm">Confirm</button>
</div>
</div>
</div>
<script>
function updateClock(){
const now = new Date();
document.getElementById('clockDisplay').textContent = now.toTimeString().slice(0,8);
document.getElementById('dateDisplay').textContent = now.toLocaleDateString('en-GB',{weekday:'short',day:'numeric',month:'short',year:'numeric'});
}
updateClock(); setInterval(updateClock, 1000);
function showToast(msg, type='info', duration=3500){
const el = document.createElement('div');
el.className = `toast ${type}`; el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(()=>{ el.style.transition='opacity 0.3s,transform 0.3s'; el.style.opacity='0'; el.style.transform='translateX(120%)'; setTimeout(()=>el.remove(),300); }, duration);
}
function showConfirm(title, body){
return new Promise(resolve=>{
const overlay = document.getElementById('modalOverlay');
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').textContent = body;
overlay.classList.remove('hidden');
function cleanup(r){ overlay.classList.add('hidden'); document.getElementById('modalConfirm').removeEventListener('click',onY); document.getElementById('modalCancel').removeEventListener('click',onN); resolve(r); }
const onY=()=>cleanup(true), onN=()=>cleanup(false);
document.getElementById('modalConfirm').addEventListener('click',onY);
document.getElementById('modalCancel').addEventListener('click',onN);
});
}
const PING_MAX = 30;
let pingResults = [];
function addPingResult(ok){
pingResults.push(ok);
if(pingResults.length > PING_MAX) pingResults.shift();
const container = document.getElementById('pingHistory');
container.innerHTML = '';
pingResults.forEach(r=>{ const b=document.createElement('div'); b.className='ping-bar'; b.style.height=r?'28px':'10px'; b.style.background=r?'var(--green)':'var(--red)'; container.appendChild(b); });
const pct = Math.round((pingResults.filter(Boolean).length/pingResults.length)*100);
document.getElementById('uptimePct').textContent = pct+'%';
document.getElementById('uptimeFill').style.width = pct+'%';
}
async function fetchUptime(){
try {
const res = await fetch('/api/uptime');
const data = await res.json();
const ms = data.uptimeMs;
const d = Math.floor(ms/86400000);
const h = Math.floor((ms%86400000)/3600000);
const m = Math.floor((ms%3600000)/60000);
document.getElementById('uptimeDays').textContent = d;
document.getElementById('uptimeHours').textContent = h;
document.getElementById('uptimeMins').textContent = m;
document.getElementById('uptimeStart').textContent = new Date(data.serverStart).toLocaleTimeString('en-GB',{hour:'2-digit',minute:'2-digit'});
document.getElementById('uptimeStat').textContent = `${d}d ${h}h ${m}m`;
} catch {}
}
async function fetchLastRuns(){
try {
const res = await fetch('/api/last-runs');
const data = await res.json();
renderLastRun('webLastRunMeta', data.web);
renderLastRun('roamLastRunMeta', data.roam);
} catch {}
}
function renderLastRun(elId, info){
const el = document.getElementById(elId);
if(!el) return;
const t = info.lastRun && info.lastRun !== 'never'
? new Date(info.lastRun).toLocaleString('en-GB',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})
: 'Never';
const code = info.exitCode !== undefined && info.exitCode !== 'never' ? info.exitCode : null;
const codeHtml = code !== null
? `<span style="color:${code===0?'var(--green)':'var(--red)'}">exit ${code}</span>`
: '';
el.innerHTML = `<span>Last run: ${t}</span>${codeHtml}`;
}
async function fetchHealth(){
try {
await fetch('/api/health');
document.getElementById('health').textContent = 'Online';
document.getElementById('healthBadge').textContent = 'Online';
document.getElementById('healthBadge').className = 'badge ok';
document.getElementById('serverDot').className = 'dot green';
document.getElementById('globalStatus').className = 'global-ok';
document.getElementById('globalStatus').innerHTML = '<div class="status-pulse"></div><span>All systems running normally</span>';
addPingResult(true);
fetchUptime();
fetchLastRuns();
} catch {
document.getElementById('health').textContent = 'Offline';
document.getElementById('healthBadge').textContent = 'Offline';
document.getElementById('healthBadge').className = 'badge error';
document.getElementById('serverDot').className = 'dot red';
document.getElementById('globalStatus').className = 'global-error';
document.getElementById('globalStatus').innerHTML = '<div class="status-pulse"></div><span>Attention required</span>';
addPingResult(false);
}
document.getElementById('lastUpdated').textContent = 'Last checked: '+new Date().toLocaleTimeString();
}
setInterval(fetchHealth, 10000);
document.querySelectorAll('.toggleLogs').forEach(btn=>{
btn.addEventListener('click',()=>{
const t = document.getElementById(btn.dataset.target);
t.classList.toggle('hidden');
btn.textContent = t.classList.contains('hidden') ? 'Show logs' : 'Hide logs';
});
});
let emacsES=null, webES=null, roamES=null, combinedES=null;
function startLogStream(url, container, setter) {
container.innerHTML = '';
const es = new EventSource(url);
es.addEventListener('log', e => {
const line = document.createElement('span');
line.className = 'log-line';
line.innerHTML = e.data;
container.appendChild(line);
container.scrollTop = container.scrollHeight;
});
es.addEventListener('done', () => {
es.close();
setter(null);
});
es.onerror = () => {
es.close();
setter(null);
const line = document.createElement('span');
line.className = 'log-line ansi-dim';
line.textContent = '-- stream closed --';
container.appendChild(line);
};
setter(es);
}
function closeStream(s){ if(s) s.close(); }
async function killEndpoint(url, label){
try {
const res = await fetch(url, {method:'DELETE'});
if(res.ok) showToast(`${label} cancelled`, 'warn');
else showToast('Nothing to cancel', 'info');
} catch { showToast('Kill request failed','error'); }
}
async function runWebBuild(){
const ok = await showConfirm('Update Website','This will rebuild and deploy the website. Continue?');
if(!ok) return;
document.getElementById('buildBtn').disabled = true;
document.getElementById('killWebBtn').classList.add('visible');
const res = document.getElementById('buildResult');
res.textContent='Updating website...'; res.className='status-text running';
document.getElementById('webDot').className='dot yellow';
document.getElementById('webStatus').textContent='Deploying';
showToast('Website build started','info');
try {
const r = await fetch('/api/build-web',{method:'POST'});
if(!r.ok) throw new Error();
closeStream(webES);
startLogStream('/api/build-web/logs', document.getElementById('logOutput'), s=>webES=s);
pollStatus('/api/build-web/status','buildResult','buildBtn','killWebBtn','webDot','webStatus',()=>{closeStream(webES);fetchLastRuns();},'Website',null);
} catch {
res.textContent='Could not start update'; res.className='status-text failed';
document.getElementById('buildBtn').disabled=false;
document.getElementById('killWebBtn').classList.remove('visible');
document.getElementById('webDot').className='dot red';
showToast('Failed to start website build','error');
}
}
async function runOrgRoamBuild(){
const ok = await showConfirm('Refresh Knowledge Base','This will refresh your Org Roam notes. Continue?');
if(!ok) return;
document.getElementById('orgRoamBtn').disabled=true;
document.getElementById('killRoamBtn').classList.add('visible');
const res = document.getElementById('orgRoamResult');
res.textContent='Refreshing notes...'; res.className='status-text running';
document.getElementById('roamDot').className='dot yellow';
document.getElementById('roamStatus').textContent='Syncing';
showToast('Knowledge base refresh started','info');
try {
const r = await fetch('/api/build-roam',{method:'POST'});
if(!r.ok) throw new Error();
closeStream(roamES);
startLogStream('/api/build-roam/logs', document.getElementById('orgRoamLogs'), s=>roamES=s);
pollStatus('/api/build-roam/status','orgRoamResult','orgRoamBtn','killRoamBtn','roamDot','roamStatus',()=>{closeStream(roamES);fetchLastRuns();},'Notes',null);
} catch {
res.textContent='Could not start refresh'; res.className='status-text failed';
document.getElementById('orgRoamBtn').disabled=false;
document.getElementById('killRoamBtn').classList.remove('visible');
document.getElementById('roamDot').className='dot red';
showToast('Failed to start notes refresh','error');
}
}
async function rerunEmacs(){
const ok = await showConfirm('Rerun Emacs','This will rerun Emacs. Continue?');
if(!ok) return;
document.getElementById('orgRoamBtn2').disabled=true;
document.getElementById('killRoamBtn').classList.add('visible');
const res = document.getElementById('orgRoamResult');
res.textContent='Running Emacs...'; res.className='status-text running';
document.getElementById('roamDot').className='dot yellow';
document.getElementById('roamStatus').textContent='Running';
showToast('Emacs starting','info');
try {
const r = await fetch('/api/rerun-emacs',{method:'POST'});
if(!r.ok) throw new Error();
closeStream(emacsES);
startLogStream('/api/rerun-emacs/logs', document.getElementById('orgRoamLogs'), s=>emacsES=s);
pollStatus('/api/rerun-emacs/status','orgRoamResult','orgRoamBtn2','killRoamBtn','roamDot','roamStatus',()=>{closeStream(emacsES);},'Emacs',[0,124]);
} catch {
res.textContent='Could not start Emacs'; res.className='status-text failed';
document.getElementById('orgRoamBtn2').disabled=false;
document.getElementById('killRoamBtn').classList.remove('visible');
document.getElementById('roamDot').className='dot red';
showToast('Failed to start Emacs','error');
}
}
async function runCombined(){
const ok = await showConfirm('Emacs + Notes Refresh','This will run Emacs then rebuild your Org Roam notes sequentially. Continue?');
if(!ok) return;
['orgRoamBtn','orgRoamBtn2','combinedBtn'].forEach(id=>document.getElementById(id).disabled=true);
document.getElementById('killRoamBtn').classList.add('visible');
const res = document.getElementById('orgRoamResult');
res.textContent='Running Emacs to Notes...'; res.className='status-text running';
document.getElementById('roamDot').className='dot yellow';
document.getElementById('roamStatus').textContent='Pipeline';
const steps = document.getElementById('pipelineSteps');
steps.classList.remove('hidden');
document.getElementById('pipeStep1').className='pipeline-step active';
document.getElementById('pipeStep2').className='pipeline-step';
showToast('Emacs + Notes pipeline started','warn');
try {
const r = await fetch('/api/run-combined',{method:'POST'});
if(!r.ok) throw new Error();
closeStream(combinedES);
startLogStream('/api/run-combined/logs', document.getElementById('orgRoamLogs'), s=>combinedES=s);
pollCombinedStatus();
} catch {
res.textContent='Could not start pipeline'; res.className='status-text failed';
['orgRoamBtn','orgRoamBtn2','combinedBtn'].forEach(id=>document.getElementById(id).disabled=false);
document.getElementById('killRoamBtn').classList.remove('visible');
document.getElementById('roamDot').className='dot red';
steps.classList.add('hidden');
showToast('Failed to start pipeline','error');
}
}
function pollCombinedStatus(){
const logEl = document.getElementById('orgRoamLogs');
const interval = setInterval(async()=>{
const r = await fetch('/api/run-combined/status');
const s = await r.json();
if(logEl.textContent.includes('[2/2]')){
document.getElementById('pipeStep1').className='pipeline-step done';
document.getElementById('pipeStep2').className='pipeline-step active';
}
if(!s.running){
clearInterval(interval);
closeStream(combinedES);
['orgRoamBtn','orgRoamBtn2','combinedBtn'].forEach(id=>document.getElementById(id).disabled=false);
document.getElementById('killRoamBtn').classList.remove('visible');
const res = document.getElementById('orgRoamResult');
if(s.lastExitCode===0){
res.textContent='Emacs + Notes completed'; res.className='status-text success';
document.getElementById('roamDot').className='dot green';
document.getElementById('roamStatus').textContent='Done';
document.getElementById('pipeStep2').className='pipeline-step done';
showToast('Pipeline complete','success');
} else {
res.textContent=`Pipeline finished with issue (code ${s.lastExitCode})`; res.className='status-text failed';
document.getElementById('roamDot').className='dot red';
document.getElementById('roamStatus').textContent='Error';
showToast('Pipeline finished with errors','error');
}
fetchLastRuns();
setTimeout(()=>document.getElementById('pipelineSteps').classList.add('hidden'), 4000);
}
}, 2000);
}
function pollStatus(statusUrl, resultId, btnId, killBtnId, dotId, statusId, onDone, label, successCodes){
const interval = setInterval(async()=>{
const r = await fetch(statusUrl);
const s = await r.json();
if(!s.running){
clearInterval(interval);
document.getElementById(btnId).disabled=false;
document.getElementById(killBtnId).classList.remove('visible');
const res = document.getElementById(resultId);
const ok = successCodes ? successCodes.includes(s.lastExitCode) : s.lastExitCode===0;
if(ok){
res.textContent=`${label} completed`; res.className='status-text success';
document.getElementById(dotId).className='dot green';
document.getElementById(statusId).textContent='Done';
showToast(`${label} complete`,'success');
} else {
res.textContent=`${label} finished with issue (code ${s.lastExitCode})`; res.className='status-text failed';
document.getElementById(dotId).className='dot red';
document.getElementById(statusId).textContent='Error';
showToast(`${label} finished with errors`,'error');
}
if(onDone) onDone();
}
}, 2000);
}
const messages = ["You are my favourite person."];
function setDailyMessage(){
const today = new Date().toDateString();
const index = today.split('').reduce((a,c)=>a+c.charCodeAt(0),0)%messages.length;
document.getElementById('dailyMessage').textContent = messages[index];
}
setDailyMessage();
function startCountdown(){
const target = new Date('2026-05-27T00:00:00');
document.getElementById('countdownLabel').textContent = 'Eid';
function update(){
const diff = target-new Date();
if(diff<=0){ document.getElementById('countdownDisplay').innerHTML=`<p style="color:var(--green);font-weight:700">Eid is now</p>`; return; }
document.getElementById('cd-days').textContent = String(Math.floor(diff/86400000)).padStart(3,'0');
document.getElementById('cd-hours').textContent = String(Math.floor((diff/3600000)%24)).padStart(2,'0');
document.getElementById('cd-mins').textContent = String(Math.floor((diff/60000)%60)).padStart(2,'0');
document.getElementById('cd-secs').textContent = String(Math.floor((diff/1000)%60)).padStart(2,'0');
}
update(); setInterval(update,1000);
}
startCountdown();
document.getElementById('killWebBtn').addEventListener('click', async()=>{
const ok = await showConfirm('Cancel Build','Force-kill the running website build?');
if(ok) killEndpoint('/api/build-web','Web build');
});
document.getElementById('killRoamBtn').addEventListener('click', async()=>{
const ok = await showConfirm('Cancel Process','Force-kill the running process?');
if(ok) killEndpoint('/api/run-combined','Process');
});
document.getElementById('buildBtn').addEventListener('click', runWebBuild);
document.getElementById('orgRoamBtn').addEventListener('click', runOrgRoamBuild);
document.getElementById('orgRoamBtn2').addEventListener('click', rerunEmacs);
document.getElementById('combinedBtn').addEventListener('click', runCombined);
fetchHealth();
</script>
</body>
</html>