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:
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user