Files
zone/index.html
2026-03-20 23:53:21 +00:00

916 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #03050d;
--surface: #080e1e;
--border: #0f1f3d;
--border-hi: #1a3560;
--accent: #00d4ff;
--accent2: #7c3aed;
--accent3: #10b981;
--warn: #f59e0b;
--danger: #ef4444;
--text: #c8d8f0;
--muted: #4a6080;
--glow: 0 0 20px rgba(0,212,255,0.3);
--glow-sm: 0 0 8px rgba(0,212,255,0.2);
}
html { scroll-behavior: smooth; }
body {
font-family: 'Space Mono', monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed; inset: 0;
background:
radial-gradient(ellipse 80% 50% at 20% 20%, rgba(0,212,255,0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 60% at 80% 80%, rgba(124,58,237,0.05) 0%, transparent 60%);
pointer-events: none; z-index: 0;
}
#grid-overlay {
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,212,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,255,0.025) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none; z-index: 0;
}
body::after {
content: '';
position: fixed; inset: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.07) 2px, rgba(0,0,0,0.07) 4px);
pointer-events: none; z-index: 9999;
}
.container {
position: relative; z-index: 1;
max-width: 960px; margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
/* ── HEADER ── */
header {
display: flex; align-items: flex-start;
justify-content: space-between;
margin-bottom: 2.5rem; gap: 1rem; flex-wrap: wrap;
}
.site-title {
font-family: 'Syne', sans-serif; font-weight: 800;
font-size: clamp(1.6rem, 4vw, 2.4rem);
letter-spacing: -0.02em; line-height: 1;
}
.site-title .line1 { color: var(--text); }
.site-title .line2 { color: var(--accent); display: block; text-shadow: var(--glow); animation: flicker 6s infinite; }
@keyframes flicker { 0%,98%,100%{opacity:1} 99%{opacity:0.7} }
.header-meta { text-align: right; font-size: 0.72rem; color: var(--muted); line-height: 1.8; }
.clock-display { font-size: 1.5rem; font-weight: 700; color: var(--accent); text-shadow: var(--glow-sm); letter-spacing: 0.05em; }
/* ── GLOBAL STATUS ── */
#globalStatus {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 1.2rem; border-radius: 6px;
font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase;
margin-bottom: 1.5rem; border: 1px solid; transition: all 0.4s ease;
}
.global-ok { background: rgba(16,185,129,0.08); border-color: rgba(16,185,129,0.3); color: var(--accent3); box-shadow: 0 0 30px rgba(16,185,129,0.08); }
.global-error { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.3); color: var(--danger); box-shadow: 0 0 30px rgba(239,68,68,0.08); }
.status-pulse { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; animation: pulse-ring 2s ease infinite; }
@keyframes pulse-ring { 0%{box-shadow:0 0 0 0 currentColor;opacity:1} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent;opacity:1} }
/* ── OVERVIEW ── */
.overview { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 1rem 1.2rem;
display: flex; align-items: center; gap: 0.8rem;
transition: border-color 0.3s, box-shadow 0.3s;
position: relative; overflow: hidden;
}
.stat-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0; transition: opacity 0.3s;
}
.stat-card:hover { border-color: var(--border-hi); }
.stat-card:hover::before { opacity: 0.6; }
.stat-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; }
.stat-value { font-size: 0.85rem; font-weight: 700; margin-top: 0.15rem; }
/* ── DOTS ── */
.dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; transition: background 0.4s; }
.dot.green { background: var(--accent3); box-shadow: 0 0 8px var(--accent3); }
.dot.red { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.dot.yellow { background: var(--warn); box-shadow: 0 0 8px var(--warn); animation: blink 0.8s step-end infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* ── CARDS ── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.2rem;
transition: border-color 0.3s; position: relative; overflow: hidden;
}
.card:hover { border-color: var(--border-hi); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.2rem; }
.card-title {
font-family: 'Syne', sans-serif; font-size: 0.95rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.12em; color: var(--text);
display: flex; align-items: center; gap: 0.5rem;
}
.card-title span { color: var(--accent); }
/* ── BADGES ── */
.badge { font-size: 0.7rem; padding: 0.25rem 0.6rem; border-radius: 4px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; border: 1px solid; }
.badge.ok { color: var(--accent3); border-color: rgba(16,185,129,0.4); background: rgba(16,185,129,0.1); }
.badge.error { color: var(--danger); border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.1); }
.badge.neutral { color: var(--muted); border-color: var(--border); background: transparent; }
.badge.warn { color: var(--warn); border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.1); }
/* ── QUICK LINKS ── */
.quick-links { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem; }
.link-card {
background: rgba(255,255,255,0.02); border: 1px solid var(--border);
padding: 0.9rem 0.6rem; border-radius: 8px; text-align: center;
text-decoration: none; color: var(--text); font-size: 0.78rem; font-weight: 700;
letter-spacing: 0.04em; transition: all 0.2s;
display: flex; flex-direction: column; gap: 0.4rem;
position: relative; overflow: hidden;
}
.link-card .icon { font-size: 1.4rem; }
.link-card::after { content: ''; position: absolute; inset: 0; background: linear-gradient(135deg, rgba(0,212,255,0.06), transparent); opacity: 0; transition: opacity 0.2s; }
.link-card:hover { border-color: rgba(0,212,255,0.4); color: var(--accent); transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,212,255,0.08); }
.link-card:hover::after { opacity: 1; }
/* ── COUNTDOWN ── */
.countdown-display { display: flex; gap: 1rem; flex-wrap: wrap; }
.time-unit { text-align: center; background: rgba(0,212,255,0.05); border: 1px solid rgba(0,212,255,0.15); border-radius: 8px; padding: 0.6rem 1rem; min-width: 64px; transition: all 0.3s; }
.time-unit:hover { border-color: rgba(0,212,255,0.4); background: rgba(0,212,255,0.1); }
.time-number { font-family: 'Syne', sans-serif; font-size: 1.8rem; font-weight: 800; color: var(--accent); text-shadow: var(--glow-sm); line-height: 1; display: block; }
.time-label { font-size: 0.6rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-top: 0.3rem; }
/* ── DAILY MESSAGE ── */
.daily-message { font-style: italic; font-size: 1rem; color: var(--text); border-left: 2px solid var(--accent2); padding-left: 1rem; line-height: 1.6; }
/* ── BUTTONS ── */
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.55rem 1.1rem; border-radius: 6px; border: 1px solid;
cursor: pointer; font-family: 'Space Mono', monospace;
font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; transition: all 0.2s; background: transparent;
}
.btn-primary { color: var(--accent); border-color: rgba(0,212,255,0.4); background: rgba(0,212,255,0.05); }
.btn-primary:hover:not(:disabled) { background: rgba(0,212,255,0.12); border-color: var(--accent); box-shadow: 0 0 16px rgba(0,212,255,0.2); }
.btn-secondary { color: var(--accent2); border-color: rgba(124,58,237,0.4); background: rgba(124,58,237,0.05); }
.btn-secondary:hover:not(:disabled) { background: rgba(124,58,237,0.12); border-color: var(--accent2); box-shadow: 0 0 16px rgba(124,58,237,0.2); }
.btn-danger { color: var(--danger); border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.05); }
.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.12); border-color: var(--danger); box-shadow: 0 0 16px rgba(239,68,68,0.2); }
.btn-combined { color: var(--warn); border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.05); }
.btn-combined:hover:not(:disabled) { background: rgba(245,158,11,0.12); border-color: var(--warn); box-shadow: 0 0 16px rgba(245,158,11,0.2); }
.btn-ghost { color: var(--muted); border-color: transparent; font-size: 0.72rem; padding: 0.4rem 0.8rem; }
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--border); }
button:disabled, .btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-row { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.8rem; }
.kill-btn { display: none; }
.kill-btn.visible { display: inline-flex; }
/* ── STATUS TEXT ── */
.status-text { font-size: 0.8rem; color: var(--muted); margin: 0.5rem 0; min-height: 1.2em; transition: color 0.3s; }
.status-text.running { color: var(--warn); }
.status-text.success { color: var(--accent3); }
.status-text.failed { color: var(--danger); }
/* ── LAST RUN META ── */
.last-run-meta { font-size: 0.68rem; color: var(--muted); margin-top: 0.3rem; display: flex; gap: 1rem; flex-wrap: wrap; }
.last-run-meta span { display: flex; align-items: center; gap: 0.3rem; }
/* ── LOG BOX ── */
.log-box {
background: #030810;
border: 1px solid var(--border);
padding: 1rem;
height: 240px;
overflow-y: auto;
border-radius: 6px;
font-size: 0.73rem;
line-height: 1.6;
font-family: 'Space Mono', monospace;
margin-top: 0.5rem;
/* white-space keeps indentation; word-break prevents horizontal scroll */
white-space: pre-wrap;
word-break: break-all;
/* default text colour — ANSI spans override this */
color: #8ba0b8;
}
.log-box::-webkit-scrollbar { width: 4px; }
.log-box::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 2px; }
/* Each line is a block-level span so they stack vertically */
.log-line { display: block; }
/* ── ANSI colour classes (set by BuildController.ansiToHtml) ── */
.ansi-bold { font-weight: 700; }
.ansi-dim { opacity: 0.55; }
.ansi-red { color: #f14c4c; }
.ansi-green { color: #4ec994; }
.ansi-yellow { color: #e5c07b; }
.ansi-blue { color: #61afef; }
.ansi-magenta { color: #c678dd; }
.ansi-cyan { color: #56b6c2; }
.ansi-white { color: #d4d4d4; }
.ansi-reset { color: inherit; font-weight: inherit; opacity: inherit; }
.hidden { display: none !important; }
/* ── UPTIME DISPLAY ── */
.uptime-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.8rem; margin-bottom: 1rem; }
.uptime-item { background: rgba(0,212,255,0.04); border: 1px solid var(--border); border-radius: 6px; padding: 0.6rem 0.8rem; }
.uptime-item-label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; }
.uptime-item-value { font-size: 0.95rem; font-weight: 700; color: var(--accent); margin-top: 0.2rem; font-family: 'Syne', sans-serif; }
/* ── PING HISTORY ── */
.uptime-bar-wrap { margin-top: 1rem; }
.uptime-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.4rem; display: flex; justify-content: space-between; }
.uptime-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.uptime-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent3), var(--accent)); border-radius: 2px; transition: width 1.5s ease; }
.ping-history { display: flex; gap: 3px; margin-top: 0.8rem; align-items: flex-end; height: 32px; }
.ping-bar { width: 6px; background: var(--accent3); border-radius: 1px; opacity: 0.7; transition: height 0.4s ease, background 0.3s; flex-shrink: 0; }
/* ── COMBINED PIPELINE STEPS ── */
.pipeline-steps { display: flex; align-items: center; gap: 0; margin: 1rem 0 0.5rem; }
.pipeline-step { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--muted); padding: 0.4rem 0.8rem; border: 1px solid var(--border); border-radius: 4px; transition: all 0.3s; }
.pipeline-step.active { color: var(--warn); border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.07); }
.pipeline-step.done { color: var(--accent3); border-color: rgba(16,185,129,0.4); background: rgba(16,185,129,0.07); }
.pipeline-step.failed { color: var(--danger); border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.07); }
.pipeline-arrow { color: var(--muted); font-size: 0.7rem; padding: 0 0.3rem; }
/* ── SECTION LABELS ── */
.section-label {
font-size: 0.65rem; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.15em; margin: 1.8rem 0 0.8rem;
display: flex; align-items: center; gap: 0.8rem;
}
.section-label::before, .section-label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ── PARTICLES ── */
#particles { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; opacity: 0.4; }
/* ── TOAST ── */
#toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; }
.toast { padding: 0.7rem 1.1rem; border-radius: 6px; font-size: 0.78rem; border: 1px solid; animation: toast-in 0.3s ease; max-width: 280px; }
@keyframes toast-in { from{transform:translateX(120%);opacity:0} to{transform:translateX(0);opacity:1} }
.toast.success { background: rgba(16,185,129,0.12); border-color: rgba(16,185,129,0.4); color: var(--accent3); }
.toast.error { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.4); color: var(--danger); }
.toast.info { background: rgba(0,212,255,0.08); border-color: rgba(0,212,255,0.3); color: var(--accent); }
.toast.warn { background: rgba(245,158,11,0.08); border-color: rgba(245,158,11,0.3); color: var(--warn); }
/* ── MODAL ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(3,5,13,0.85); backdrop-filter: blur(4px); z-index: 9000; display: flex; align-items: center; justify-content: center; animation: fade-in 0.15s ease; }
@keyframes fade-in { from{opacity:0} to{opacity:1} }
.modal { background: var(--surface); border: 1px solid var(--border-hi); border-radius: 10px; padding: 1.8rem; max-width: 360px; width: 90%; box-shadow: 0 24px 80px rgba(0,0,0,0.6), var(--glow); animation: modal-up 0.2s ease; }
@keyframes modal-up { from{transform:translateY(12px);opacity:0} to{transform:none;opacity:1} }
.modal h3 { font-family: 'Syne', sans-serif; font-size: 1rem; color: var(--text); margin-bottom: 0.5rem; }
.modal p { font-size: 0.8rem; color: var(--muted); line-height: 1.6; margin-bottom: 1.2rem; }
.modal-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
/* ── ENTRY ANIMATIONS ── */
.fade-up { opacity: 0; transform: translateY(16px); animation: fade-up 0.5s ease forwards; }
@keyframes fade-up { to{opacity:1;transform:none} }
/* ── MOBILE ── */
@media (max-width: 700px) {
.overview { grid-template-columns: 1fr 1fr; }
.countdown-display { gap: 0.6rem; }
.time-number { font-size: 1.4rem; }
.btn-row { flex-direction: column; }
.btn { width: 100%; justify-content: center; }
header { flex-direction: column; }
.header-meta { text-align: left; }
.pipeline-steps { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div id="grid-overlay"></div>
<canvas id="particles"></canvas>
<div id="toast-container"></div>
<div class="container">
<!-- HEADER -->
<header class="fade-up">
<div class="site-title">
<div class="line1">Home</div>
<span class="line2">Server Control_</span>
</div>
<div class="header-meta">
<div class="clock-display" id="clockDisplay">--:--:--</div>
<div id="dateDisplay" style="margin-top:0.2rem"></div>
<div style="margin-top:0.3rem" id="lastUpdated">Checking status...</div>
</div>
</header>
<!-- GLOBAL STATUS -->
<div id="globalStatus" class="global-ok fade-up" style="animation-delay:0.1s">
<div class="status-pulse"></div>
<span>All Systems Running Normally</span>
</div>
<!-- OVERVIEW STATS -->
<div class="overview fade-up" style="animation-delay:0.15s">
<div class="stat-card">
<span id="serverDot" class="dot green"></span>
<div>
<div class="stat-label">Server</div>
<div class="stat-value" id="health">Online</div>
</div>
</div>
<div class="stat-card">
<span id="webDot" class="dot green"></span>
<div>
<div class="stat-label">Website</div>
<div class="stat-value" id="webStatus">Idle</div>
</div>
</div>
<div class="stat-card">
<span id="roamDot" class="dot green"></span>
<div>
<div class="stat-label">Org Notes</div>
<div class="stat-value" id="roamStatus">Idle</div>
</div>
</div>
<div class="stat-card">
<span class="dot green" style="background:var(--accent2);box-shadow:0 0 8px var(--accent2)"></span>
<div>
<div class="stat-label">Uptime</div>
<div class="stat-value" id="uptimeStat"></div>
</div>
</div>
</div>
<!-- PERSONAL -->
<div class="section-label">Personal</div>
<div class="card fade-up">
<div class="card-header">
<div class="card-title">💌 &nbsp;<span>Daily</span>&nbsp;Message</div>
</div>
<p id="dailyMessage" class="daily-message"></p>
</div>
<div class="card fade-up">
<div class="card-header">
<div class="card-title">&nbsp;<span>Countdown</span></div>
<span id="countdownLabel" class="badge neutral"></span>
</div>
<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>
<!-- SERVICES -->
<div class="section-label">Services</div>
<div class="card fade-up">
<div class="card-header">
<div class="card-title">🚀 &nbsp;<span>Quick</span>&nbsp;Links</div>
</div>
<div class="quick-links">
<a href="https://notes.zainezq.com" target="_blank" class="link-card"><span class="icon">📚</span>Notes</a>
<a href="https://zainezq.com" target="_blank" class="link-card"><span class="icon">🌍</span>Website</a>
<a href="https://filebrowser.zainezq.com" target="_blank" class="link-card"><span class="icon">📂</span>Files</a>
<a href="https://calibre.zainezq.com" target="_blank" class="link-card"><span class="icon">📖</span>Library</a>
<a href="https://miniflux.zainezq.com" target="_blank" class="link-card"><span class="icon">📰</span>News</a>
<a href="https://guac.zainezq.com" target="_blank" class="link-card"><span class="icon">🖥️</span>RDP</a>
<a href="https://portainer.zainezq.com" target="_blank" class="link-card"><span class="icon">🐳</span>Portainer</a>
<a href="https://nextcloud.zainezq.com" target="_blank" class="link-card"><span class="icon">☁️</span>Nextcloud</a>
<a href="https://pgadmin.zainezq.com" target="_blank" class="link-card"><span class="icon">🗄️</span>Databases</a>
</div>
</div>
<!-- MONITORING -->
<div class="section-label">Monitoring</div>
<div class="card fade-up">
<div class="card-header">
<div class="card-title">📡 &nbsp;<span>Server</span>&nbsp;Status</div>
<span id="healthBadge" class="badge neutral">Checking</span>
</div>
<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" style="font-size:0.72rem"></div></div>
</div>
<div class="uptime-bar-wrap">
<div class="uptime-label"><span>Ping History (30 checks)</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"></div>
</div>
<!-- ACTIONS -->
<div class="section-label">Actions</div>
<!-- WEBSITE -->
<div class="card fade-up">
<div class="card-header">
<div class="card-title">🌍 &nbsp;<span>Website</span>&nbsp;Deploy</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="buildBtn">▶ Update Website</button>
<button class="btn btn-danger kill-btn" id="killWebBtn">✕ Cancel</button>
</div>
<p id="buildResult" class="status-text">💤 Waiting for action</p>
<div class="last-run-meta" id="webLastRunMeta"></div>
<button class="btn btn-ghost toggleLogs" data-target="logOutput">Show logs</button>
<!-- div instead of pre — innerHTML is set directly, pre would double-escape -->
<div id="logOutput" class="log-box hidden"></div>
</div>
<!-- KNOWLEDGE BASE -->
<div class="card fade-up">
<div class="card-header">
<div class="card-title">📚 &nbsp;<span>Knowledge</span>&nbsp;Base</div>
</div>
<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"></div>
<div class="pipeline-step" id="pipeStep2">▶ Notes</div>
</div>
<p id="orgRoamResult" class="status-text">💤 Waiting for action</p>
<div class="last-run-meta" id="roamLastRunMeta"></div>
<button class="btn btn-ghost toggleLogs" data-target="orgRoamLogs">Show logs</button>
<div id="orgRoamLogs" class="log-box hidden"></div>
</div>
</div>
<!-- MODAL -->
<div id="modalOverlay" class="modal-overlay hidden">
<div class="modal">
<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>
/* ── PARTICLES ── */
(function(){
const canvas = document.getElementById('particles');
const ctx = canvas.getContext('2d');
let W, H, dots = [];
function resize(){ W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
function init(){ dots = Array.from({length:60},()=>({ x:Math.random()*W, y:Math.random()*H, r:Math.random()*1.2+0.3, vx:(Math.random()-0.5)*0.2, vy:(Math.random()-0.5)*0.2, a:Math.random()*0.4+0.1 })); }
function draw(){
ctx.clearRect(0,0,W,H);
dots.forEach(d=>{
d.x+=d.vx; d.y+=d.vy;
if(d.x<0)d.x=W; if(d.x>W)d.x=0;
if(d.y<0)d.y=H; if(d.y>H)d.y=0;
ctx.beginPath(); ctx.arc(d.x,d.y,d.r,0,Math.PI*2);
ctx.fillStyle=`rgba(0,212,255,${d.a})`; ctx.fill();
});
for(let i=0;i<dots.length;i++) for(let j=i+1;j<dots.length;j++){
const dx=dots[i].x-dots[j].x, dy=dots[i].y-dots[j].y, dist=Math.sqrt(dx*dx+dy*dy);
if(dist<100){ ctx.beginPath(); ctx.moveTo(dots[i].x,dots[i].y); ctx.lineTo(dots[j].x,dots[j].y); ctx.strokeStyle=`rgba(0,212,255,${0.06*(1-dist/100)})`; ctx.lineWidth=0.5; ctx.stroke(); }
}
requestAnimationFrame(draw);
}
window.addEventListener('resize',()=>{resize();init();});
resize(); init(); draw();
})();
/* ── CLOCK ── */
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);
/* ── TOAST ── */
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);
}
/* ── MODAL ── */
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);
});
}
/* ── PING HISTORY ── */
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?'24px':'8px'; b.style.background=r?'var(--accent3)':'var(--danger)'; 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+'%';
}
/* ── UPTIME ── */
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 { /* server offline */ }
}
/* ── LAST RUN META ── */
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(--accent3)':'var(--danger)'}">exit ${code}</span>`
: '';
el.innerHTML = `<span>Last run: ${t}</span>${codeHtml}`;
}
/* ── HEALTH ── */
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);
/* ── LOG TOGGLE ── */
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';
});
});
/* ── LOG STREAMING ─────────────────────────────────────────────────────────
The server sends two named SSE event types:
"log" one line already converted from ANSI → HTML <span> elements
"done" build finished; close the EventSource
Each line is appended as a block-level <span> inside a div.log-box so
that innerHTML renders the colour spans correctly. The old approach used
pre.textContent += line which (a) stripped the spans and (b) couldn't
show colours, and (c) used the default onmessage which only fires for
unnamed events — it would never have received our named "log" events.
────────────────────────────────────────────────────────────────────────── */
let emacsES=null, webES=null, roamES=null, combinedES=null;
function startLogStream(url, container, setter) {
// Clear previous content
container.innerHTML = '';
const es = new EventSource(url);
// Named "log" event — server sends one line of ANSI→HTML converted output
es.addEventListener('log', e => {
const line = document.createElement('span');
line.className = 'log-line';
line.innerHTML = e.data; // safe: server HTML-escaped raw text first
container.appendChild(line);
container.scrollTop = container.scrollHeight;
});
// Named "done" event — stream has ended, close cleanly
es.addEventListener('done', () => {
es.close();
setter(null);
});
// Network error / server closed without "done"
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(); }
/* ── KILL HELPERS ── */
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'); }
}
/* ── WEBSITE BUILD ── */
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');
}
}
/* ── ROAM BUILD ── */
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');
}
}
/* ── EMACS ── */
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');
}
}
/* ── COMBINED RUN ── */
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 → 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();
// Detect step transition from rendered log text
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);
}
/* ── GENERIC POLL ── */
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);
}
/* ── DAILY MESSAGE ── */
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();
/* ── COUNTDOWN ── */
function startCountdown(){
const target = new Date('2026-03-20T00: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(--accent);font-family:'Syne',sans-serif;font-size:1.2rem;font-weight:800">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();
/* ── KILL BUTTONS ── */
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');
});
/* ── WIRE UP ── */
document.getElementById('buildBtn').addEventListener('click', runWebBuild);
document.getElementById('orgRoamBtn').addEventListener('click', runOrgRoamBuild);
document.getElementById('orgRoamBtn2').addEventListener('click', rerunEmacs);
document.getElementById('combinedBtn').addEventListener('click', runCombined);
/* ── INIT ── */
fetchHealth();
</script>
</body>
</html>