Initial project scaffold with frontend dashboard and Caddy config

Frontend-first status board with mock data for 16 vendors.
Caddy configured on port 8443 with internal TLS, coexisting
with an existing Caddy instance (admin on 2020, no HTTP redirects).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Klein
2026-02-17 10:22:45 -05:00
commit c1184eee91
9 changed files with 753 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
/* ===== DESIGN TOKENS ===== */
:root {
/* Surfaces */
--bg: #060a10;
--surface: #0d1117;
--surface-hover: #151b23;
--border: #21262d;
/* Text */
--text-1: #e6edf3;
--text-2: #848d97;
--text-3: #484f58;
/* Status — Operational */
--ok: #3fb950;
--ok-dim: rgba(63, 185, 80, 0.1);
--ok-glow: rgba(63, 185, 80, 0.35);
/* Status — Degraded */
--warn: #d29922;
--warn-dim: rgba(210, 153, 34, 0.1);
--warn-glow: rgba(210, 153, 34, 0.35);
/* Status — Outage */
--crit: #f85149;
--crit-dim: rgba(248, 81, 73, 0.06);
--crit-glow: rgba(248, 81, 73, 0.35);
/* Status — Unknown */
--unk: #484f58;
--unk-dim: rgba(72, 79, 88, 0.1);
--unk-glow: rgba(72, 79, 88, 0.3);
/* Typography */
--font-display: 'Big Shoulders Display', Impact, sans-serif;
--font-mono: 'Martian Mono', Consolas, monospace;
--font-body: 'Karla', 'Segoe UI', sans-serif;
}
/* ===== RESET ===== */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ===== BODY ===== */
body {
font-family: var(--font-body);
background-color: var(--bg);
background-image:
radial-gradient(ellipse at 50% 0%, rgba(25, 35, 55, 0.5) 0%, transparent 60%),
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px);
background-size: 100% 100%, 48px 48px, 48px 48px;
color: var(--text-1);
min-height: 100vh;
overflow-x: hidden;
}
/* ===== HEADER ===== */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.75rem 2.5rem;
border-bottom: 1px solid var(--border);
background: rgba(13, 17, 23, 0.6);
backdrop-filter: blur(12px);
}
.header-brand h1 {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 900;
letter-spacing: 0.08em;
color: var(--text-1);
line-height: 1;
}
.header-meta {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
}
#clock {
font-family: var(--font-mono);
font-size: 1.4rem;
font-weight: 400;
color: var(--text-1);
letter-spacing: 0.05em;
}
#last-refreshed {
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 300;
color: var(--text-3);
letter-spacing: 0.03em;
}
/* ===== SUMMARY BAR ===== */
#summary-bar {
display: flex;
gap: 2rem;
padding: 0.85rem 2.5rem;
border-bottom: 1px solid var(--border);
background: rgba(13, 17, 23, 0.4);
}
.summary-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 400;
color: var(--text-2);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.summary-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--unk);
flex-shrink: 0;
}
.summary-dot.operational {
background: var(--ok);
box-shadow: 0 0 6px var(--ok-glow);
}
.summary-dot.degraded {
background: var(--warn);
box-shadow: 0 0 6px var(--warn-glow);
}
.summary-dot.outage {
background: var(--crit);
box-shadow: 0 0 6px var(--crit-glow);
animation: dotPulse 2s ease-in-out infinite;
}
.summary-count {
font-weight: 500;
color: var(--text-1);
font-size: 0.85rem;
}
/* ===== VENDOR GRID ===== */
#vendor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
padding: 2rem 2.5rem;
}
/* ===== VENDOR CARD ===== */
.vendor-card {
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid var(--unk);
border-radius: 8px;
padding: 1.5rem;
transition: transform 0.2s ease, background 0.2s ease;
}
.vendor-card:hover {
transform: translateY(-2px);
background: var(--surface-hover);
}
/* --- Entrance animation (first render only) --- */
.vendor-card.animate-in {
opacity: 0;
animation: cardEnter 0.45s ease forwards;
animation-delay: var(--d, 0s);
}
/* --- Status variants --- */
.vendor-card.operational {
border-top-color: var(--ok);
}
.vendor-card.degraded {
border-top-color: var(--warn);
background: linear-gradient(180deg, var(--warn-dim) 0%, var(--surface) 50%);
}
.vendor-card.degraded:hover {
background: linear-gradient(180deg, var(--warn-dim) 0%, var(--surface-hover) 50%);
}
.vendor-card.outage {
border-top-color: var(--crit);
background: linear-gradient(180deg, var(--crit-dim) 0%, var(--surface) 40%);
animation: cardPulse 3s ease-in-out infinite;
}
.vendor-card.outage:hover {
background: linear-gradient(180deg, rgba(248, 81, 73, 0.1) 0%, var(--surface-hover) 40%);
}
.vendor-card.animate-in.outage {
animation: cardEnter 0.45s ease forwards, cardPulse 3s ease-in-out infinite;
animation-delay: var(--d, 0s), 1s;
}
.vendor-card.unknown {
border-top-color: var(--unk);
}
/* --- Card layout --- */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.vendor-identity {
display: flex;
align-items: center;
gap: 0.65rem;
}
/* --- Status indicator (LED dot) --- */
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--unk);
flex-shrink: 0;
}
.status-indicator.operational {
background: var(--ok);
box-shadow: 0 0 8px var(--ok-glow);
}
.status-indicator.degraded {
background: var(--warn);
box-shadow: 0 0 8px var(--warn-glow);
animation: dotPulse 2.5s ease-in-out infinite;
}
.status-indicator.outage {
background: var(--crit);
box-shadow: 0 0 10px var(--crit-glow);
animation: dotPulse 1.5s ease-in-out infinite;
}
/* --- Vendor name --- */
.vendor-name {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 800;
color: var(--text-1);
letter-spacing: 0.03em;
line-height: 1;
}
/* --- Status badge --- */
.status-badge {
font-family: var(--font-mono);
font-size: 0.58rem;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.3rem 0.65rem;
border-radius: 4px;
background: var(--unk-dim);
color: var(--unk);
border: 1px solid rgba(72, 79, 88, 0.2);
white-space: nowrap;
}
.status-badge.operational {
background: var(--ok-dim);
color: var(--ok);
border-color: rgba(63, 185, 80, 0.15);
}
.status-badge.degraded {
background: var(--warn-dim);
color: var(--warn);
border-color: rgba(210, 153, 34, 0.15);
}
.status-badge.outage {
background: var(--crit-dim);
color: var(--crit);
border-color: rgba(248, 81, 73, 0.15);
}
/* --- Message & timestamp --- */
.vendor-message {
font-size: 0.875rem;
font-weight: 400;
color: var(--text-2);
line-height: 1.5;
margin-bottom: 1rem;
}
.vendor-updated {
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 300;
color: var(--text-3);
letter-spacing: 0.03em;
}
/* ===== ANIMATIONS ===== */
@keyframes cardEnter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes dotPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes cardPulse {
0%, 100% { box-shadow: 0 -2px 10px rgba(248, 81, 73, 0.06); }
50% { box-shadow: 0 -2px 25px rgba(248, 81, 73, 0.18); }
}
/* ===== RESPONSIVE ===== */
@media (max-width: 640px) {
header {
flex-direction: column;
gap: 0.75rem;
text-align: center;
padding: 1.25rem 1.5rem;
}
.header-meta {
align-items: center;
}
#summary-bar {
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1.5rem;
}
#vendor-grid {
grid-template-columns: 1fr;
padding: 1.25rem 1.5rem;
}
}
@media (min-width: 1600px) {
#vendor-grid {
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
}
}
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NHSD Service Status</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%233fb950' rx='20' width='100' height='100'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Big+Shoulders+Display:wght@600;800;900&family=Karla:wght@400;500&family=Martian+Mono:wght@300;400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<div class="header-brand">
<h1>NHSD SERVICE STATUS</h1>
</div>
<div class="header-meta">
<time id="clock"></time>
<span id="last-refreshed"></span>
</div>
</header>
<div id="summary-bar"></div>
<main id="vendor-grid"></main>
<script src="js/app.js"></script>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
// How often to fetch status (ms)
const REFRESH_INTERVAL = 60_000;
// Status severity for sorting (higher = more severe = shown first)
const SEVERITY = { outage: 3, degraded: 2, unknown: 1, operational: 0 };
// Track first render for entrance animation
let initialLoad = true;
// Simple HTML escaping for template safety
function esc(str) {
const el = document.createElement("span");
el.textContent = str;
return el.innerHTML;
}
// Mock data — will be replaced by /api/status once the backend is wired up
function getMockData() {
return [
{
name: "Microsoft 365",
status: "operational",
message: "All services running normally.",
lastUpdated: new Date(Date.now() - 120_000).toISOString()
},
{
name: "SpamTitan",
status: "operational",
message: "Email filtering operational.",
lastUpdated: new Date(Date.now() - 300_000).toISOString()
},
{
name: "PowerSchool",
status: "degraded",
message: "Slow response times reported. PowerSchool is investigating.",
lastUpdated: new Date(Date.now() - 60_000).toISOString()
},
{
name: "Classlink",
status: "operational",
message: "All services running normally.",
lastUpdated: new Date(Date.now() - 180_000).toISOString()
},
{
name: "Apple",
status: "operational",
message: "All services running normally.",
lastUpdated: new Date(Date.now() - 600_000).toISOString()
},
{
name: "DRC",
status: "outage",
message: "INSIGHT portal is currently unavailable. DRC is aware of the issue.",
lastUpdated: new Date(Date.now() - 45_000).toISOString()
},
{
name: "FinalSite",
status: "operational",
message: "All services running normally.",
lastUpdated: new Date(Date.now() - 240_000).toISOString()
},
{
name: "Google Workspace",
status: "operational",
message: "All services running normally.",
lastUpdated: new Date(Date.now() - 90_000).toISOString()
},
{
name: "Follett",
status: "operational",
message: "All services running normally.",
lastUpdated: new Date(Date.now() - 350_000).toISOString()
},
{
name: "EdInsight",
status: "operational",
message: "Data analytics platform operational.",
lastUpdated: new Date(Date.now() - 200_000).toISOString()
},
{
name: "Raptor",
status: "operational",
message: "Visitor management online.",
lastUpdated: new Date(Date.now() - 400_000).toISOString()
},
{
name: "SchoolMessenger",
status: "operational",
message: "Messaging services operational.",
lastUpdated: new Date(Date.now() - 150_000).toISOString()
},
{
name: "Fortinet",
status: "operational",
message: "FortiGuard and FortiCloud services operational.",
lastUpdated: new Date(Date.now() - 500_000).toISOString()
},
{
name: "McGraw Hill",
status: "operational",
message: "All platform services operational.",
lastUpdated: new Date(Date.now() - 270_000).toISOString()
},
{
name: "Local Infrastructure",
status: "operational",
message: "Firewall uptime 42d. WAN throughput nominal.",
lastUpdated: new Date(Date.now() - 30_000).toISOString()
}
];
}
// Fetch vendor status from the backend API, falling back to mock data
async function fetchStatus() {
try {
const res = await fetch("/api/status");
if (!res.ok) throw new Error(res.statusText);
return await res.json();
} catch {
// Backend not available yet — use mock data
return getMockData();
}
}
// Format an ISO timestamp as a relative or short local time string
function formatTime(iso) {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
}
// Render the summary bar (counts by status)
function renderSummary(vendors) {
const counts = { operational: 0, degraded: 0, outage: 0, unknown: 0 };
vendors.forEach(v => { counts[v.status] = (counts[v.status] || 0) + 1; });
const bar = document.getElementById("summary-bar");
const items = [];
if (counts.outage > 0) {
items.push(`<div class="summary-item">
<span class="summary-dot outage"></span>
<span class="summary-count">${counts.outage}</span> Outage
</div>`);
}
if (counts.degraded > 0) {
items.push(`<div class="summary-item">
<span class="summary-dot degraded"></span>
<span class="summary-count">${counts.degraded}</span> Degraded
</div>`);
}
items.push(`<div class="summary-item">
<span class="summary-dot operational"></span>
<span class="summary-count">${counts.operational}</span> Operational
</div>`);
if (counts.unknown > 0) {
items.push(`<div class="summary-item">
<span class="summary-dot unknown"></span>
<span class="summary-count">${counts.unknown}</span> Unknown
</div>`);
}
bar.innerHTML = items.join("");
}
// Render vendor cards into the grid, sorted by severity
function renderGrid(vendors) {
const sorted = [...vendors].sort((a, b) =>
(SEVERITY[b.status] || 0) - (SEVERITY[a.status] || 0)
);
const grid = document.getElementById("vendor-grid");
grid.innerHTML = sorted.map((v, i) => {
const animClass = initialLoad ? "animate-in" : "";
const animStyle = initialLoad ? `style="--d: ${i * 0.07}s"` : "";
return `<div class="vendor-card ${esc(v.status)} ${animClass}" ${animStyle}>
<div class="card-header">
<div class="vendor-identity">
<span class="status-indicator ${esc(v.status)}"></span>
<span class="vendor-name">${esc(v.name)}</span>
</div>
<span class="status-badge ${esc(v.status)}">${esc(v.status)}</span>
</div>
<p class="vendor-message">${esc(v.message)}</p>
<span class="vendor-updated">Updated ${esc(formatTime(v.lastUpdated))}</span>
</div>`;
}).join("");
if (initialLoad) initialLoad = false;
}
// Orchestrate a full render pass
function render(vendors) {
renderSummary(vendors);
renderGrid(vendors);
document.getElementById("last-refreshed").textContent =
`Refreshed ${new Date().toLocaleTimeString()}`;
}
// Update the live clock display
function updateClock() {
document.getElementById("clock").textContent =
new Date().toLocaleTimeString([], {
hour: "2-digit", minute: "2-digit", second: "2-digit"
});
}
// Fetch and render
async function refresh() {
const vendors = await fetchStatus();
render(vendors);
}
// Initialize
updateClock();
setInterval(updateClock, 1000);
refresh();
setInterval(refresh, REFRESH_INTERVAL);