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