commit c1184eee917bcdf666378d4a35cdba56055dd7ac Author: Klein Date: Tue Feb 17 10:22:45 2026 -0500 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 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b992b69 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(../bin/caddy/caddy.exe run:*)", + "Bash(curl:*)", + "WebFetch(domain:myedinsight.com)", + "WebSearch", + "WebFetch(domain:status.mcgrawhill.com)", + "Bash(git add:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae2593a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Executables (provided manually, not tracked in git) +bin/caddy/*.exe +bin/nssm/*.exe + +# Node +backend/node_modules/ + +# OS files +Thumbs.db +Desktop.ini diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a79827 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# Infrastructure Monitoring Dashboard + +## Project Overview + +A web-based status feed aggregator for a K-12 school district IT department. Provides a single-pane-of-glass view of vendor service health, replacing the need to manually check multiple status pages during incidents. + +## Target Vendors + +| Vendor | Type | Status Source | +|---|---|---| +| Microsoft 365 | Productivity suite | Service Communications API (Graph API) | +| SpamTitan | Email security | TBD — likely status page scrape or synthetic check | +| PowerSchool | Student Information System | TBD — status page scrape | +| Classlink | SSO / Identity | TBD — status page or API | +| Apple | Device ecosystem | Apple System Status page (JSON feed) | +| DRC | Assessment / Testing | TBD — status page scrape | +| FinalSite | School website CMS | TBD — status page scrape or synthetic check | +| Google Workspace | Productivity suite | Google Workspace Status Dashboard (JSON feed) | +| Follett | Library management | TBD — status page scrape or synthetic check | +| EdInsight | Data analytics (Harris Education Solutions) | TBD — status page or synthetic check | +| Raptor | Visitor management | TBD — status page scrape | +| SchoolMessenger | Communication platform | TBD — status page scrape | +| McGraw Hill | Curriculum / assessment | status.mcgrawhill.com (JS-rendered status page) | +| Fortinet | Network security | TBD — FortiCloud API or status page | +| Local Infrastructure | On-prem hardware | Direct monitoring (SNMP, API, or synthetic checks) | + +Note: Exchange Online is intentionally excluded — it is a component of M365 Service Health and would be redundant. + +New vendors should be added incrementally, not speculatively. + +## Hosting + +- **Web server**: Caddy +- **URL**: https://status.nhsd.net:8443 (port 8443 to avoid conflict with existing Caddy instance on this machine) +- **Access**: Local network only (DNS A record points to the host machine) +- **TLS**: Caddy internal TLS (self-signed). IT staff only — browser cert warnings are acceptable. + +## Architecture + +- **Frontend**: HTML/CSS/JS dashboard — lightweight, no heavy framework. Designed to work on a wall-mounted monitor or quick browser check. +- **Backend**: Node.js service that polls vendor status on a schedule and caches results. +- **Web server**: Caddy reverse-proxies to the backend API and serves the static frontend. +- **Services**: NSSM runs both Caddy and the Node backend as Windows services. +- **Data flow**: Backend polls vendors → caches to local store → frontend fetches from backend API → auto-refreshes on interval. + +## Directory Structure + +``` +infrastructure-monitoring-dashboard/ +├── CLAUDE.md +├── .gitignore +├── bin/ +│ ├── caddy/ # Drop caddy.exe here (git-ignored) +│ └── nssm/ # Drop nssm.exe here (git-ignored) +├── config/ +│ └── Caddyfile # Caddy server configuration +├── frontend/ +│ ├── index.html +│ ├── css/ +│ └── js/ +├── backend/ +│ ├── package.json +│ ├── server.js +│ └── providers/ # One module per vendor +└── scripts/ # NSSM service install/uninstall helpers +``` + +## Development Approach + +Frontend-first with mock data. Build the dashboard UI and layout with realistic fake data, then replace mocks with real vendor integrations one at a time. + +## Design Principles + +- Keep it simple. This is a status board, not a monitoring platform. +- Degrade gracefully — if a vendor check fails, show "unknown" rather than crashing. +- Each vendor integration should be a self-contained module so they can be added/removed independently. +- Optimize for glanceability — status should be obvious from across the room (color-coded, large indicators). diff --git a/bin/caddy/.gitkeep b/bin/caddy/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/bin/nssm/.gitkeep b/bin/nssm/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/Caddyfile b/config/Caddyfile new file mode 100644 index 0000000..88ecb6d --- /dev/null +++ b/config/Caddyfile @@ -0,0 +1,17 @@ +{ + admin localhost:2020 + auto_https disable_redirects +} + +https://status.nhsd.net:8443, https://localhost:8443 { + tls internal + + # Static frontend + root * ../frontend + file_server + + # Proxy API requests to Node backend + handle /api/* { + reverse_proxy localhost:3000 + } +} diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..b62e85a --- /dev/null +++ b/frontend/css/style.css @@ -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)); + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..196fd29 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,27 @@ + + + + + + NHSD Service Status + + + + + + + +
+
+

NHSD SERVICE STATUS

+
+
+ + +
+
+
+
+ + + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..59dce03 --- /dev/null +++ b/frontend/js/app.js @@ -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(`
+ + ${counts.outage} Outage +
`); + } + if (counts.degraded > 0) { + items.push(`
+ + ${counts.degraded} Degraded +
`); + } + items.push(`
+ + ${counts.operational} Operational +
`); + if (counts.unknown > 0) { + items.push(`
+ + ${counts.unknown} Unknown +
`); + } + + 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 `
+
+
+ + ${esc(v.name)} +
+ ${esc(v.status)} +
+

${esc(v.message)}

+ Updated ${esc(formatTime(v.lastUpdated))} +
`; + }).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);