diff --git a/docs/assets/js/outage-widget.js b/docs/assets/js/outage-widget.js new file mode 100644 index 00000000..519b7bc6 --- /dev/null +++ b/docs/assets/js/outage-widget.js @@ -0,0 +1,349 @@ +/* ========================================================================== + * RCAC System Status / Outage Widget + * -------------------------------------------------------------------------- + * Floating status pill for the RCAC-Docs MkDocs Material site. + * Data source: the Halcyon CMS JSON API on the main RCAC website + * https://www.rcac.purdue.edu/api/news (articles) + * https://www.rcac.purdue.edu/api/news/types (news types) + * + * No build step, no dependencies. Registered via `extra_javascript` + * in mkdocs.yml. Compatible with Material's instant navigation. + * + * Behavior: + * - By default the widget only appears on pages containing an element + * with id="outage-widget-anchor" (the homepage). Set + * CONFIG.homepageOnly = false to show it on every page. + * - Green = no active or upcoming items + * - Amber = maintenance/outage scheduled within `upcomingWindowDays` + * - Red = outage/maintenance active right now + * ========================================================================== */ + +(function () { + "use strict"; + + var CONFIG = { + apiBase: "https://www.rcac.purdue.edu/api", + // Where "view all" links go: + newsPage: "https://www.rcac.purdue.edu/news/outages-and-maintenance", + // News type whose alias matches this pattern is treated as outage news: + typeAliasPattern: /outage/i, + homepageOnly: true, // require #outage-widget-anchor on the page + upcomingWindowDays: 14, // how far ahead to surface scheduled work + staleActiveDays: 7, // open-ended items older than this are ignored + cacheMinutes: 5, // sessionStorage cache TTL + refreshMinutes: 5, // background refresh while page is open + maxItems: 5, // max entries listed in the panel + timeZone: "America/Indiana/Indianapolis" + }; + + var CACHE_KEY = "rcac-outage-widget-v1"; + var TYPE_KEY = "rcac-outage-typeid-v1"; + var OPEN_KEY = "rcac-outage-widget-open"; + var refreshTimer = null; + + /* ------------------------------------------------------------------ * + * Time handling: Halcyon returns naive timestamps in Eastern time, + * e.g. "2026-06-09 10:00:00". Convert to a real Date in the user's + * local clock by computing the Eastern UTC offset for that instant. + * ------------------------------------------------------------------ */ + function parseEastern(str) { + if (!str) return null; + var m = String(str).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/); + if (!m) return null; + // Pretend the wall-clock time is UTC, then correct by the zone offset. + var asUTC = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); + var offset = easternOffsetMs(new Date(asUTC)); + return new Date(asUTC - offset); + } + + function easternOffsetMs(date) { + try { + var dtf = new Intl.DateTimeFormat("en-US", { + timeZone: CONFIG.timeZone, + hour12: false, + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit" + }); + var parts = {}; + dtf.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; }); + var asIfUTC = Date.UTC( + +parts.year, +parts.month - 1, +parts.day, + (+parts.hour) % 24, +parts.minute, +parts.second + ); + return asIfUTC - date.getTime(); + } catch (e) { + return -5 * 3600 * 1000; // EST fallback + } + } + + /* ------------------------------------------------------------------ * + * Data fetching + * ------------------------------------------------------------------ */ + function getCached() { + try { + var raw = sessionStorage.getItem(CACHE_KEY); + if (!raw) return null; + var obj = JSON.parse(raw); + if (Date.now() - obj.t > CONFIG.cacheMinutes * 60 * 1000) return null; + return obj.data; + } catch (e) { return null; } + } + + function setCached(data) { + try { + sessionStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), data: data })); + } catch (e) { /* storage full or unavailable — ignore */ } + } + + function fetchJSON(url) { + return fetch(url, { headers: { Accept: "application/json" } }).then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status + " for " + url); + return r.json(); + }); + } + + function resolveOutageTypeId() { + try { + var cached = JSON.parse(localStorage.getItem(TYPE_KEY) || "null"); + if (cached && Date.now() - cached.t < 24 * 3600 * 1000) { + return Promise.resolve(cached.id); + } + } catch (e) { /* fall through to fetch */ } + + return fetchJSON(CONFIG.apiBase + "/news/types?limit=100").then(function (json) { + var types = json.data || json; + var match = (types || []).find(function (t) { + return CONFIG.typeAliasPattern.test(t.alias || "") || + CONFIG.typeAliasPattern.test(t.name || ""); + }); + if (!match) throw new Error("Outage news type not found"); + try { + localStorage.setItem(TYPE_KEY, JSON.stringify({ t: Date.now(), id: match.id })); + } catch (e) { /* ignore */ } + return match.id; + }); + } + + function fetchStatus() { + var cached = getCached(); + if (cached) return Promise.resolve(cached); + + return resolveOutageTypeId() + .then(function (typeId) { + var url = CONFIG.apiBase + "/news?type=" + typeId + + "&limit=25&order=datetimecreated&order_dir=desc"; + return fetchJSON(url); + }) + .then(function (json) { + var data = classify(json.data || []); + setCached(data); + return data; + }); + } + + /* ------------------------------------------------------------------ * + * Classification + * ------------------------------------------------------------------ */ + function classify(articles) { + var now = new Date(); + var horizon = new Date(now.getTime() + CONFIG.upcomingWindowDays * 86400000); + var staleCutoff = new Date(now.getTime() - CONFIG.staleActiveDays * 86400000); + var active = [], upcoming = []; + + articles.forEach(function (a) { + if (!a || a.published === 0) return; + var start = parseEastern(a.datetimenews); + var end = parseEastern(a.datetimenewsend); + if (!start) return; + + var item = { + headline: a.headline || "Untitled", + date: a.formatteddate || "", + url: a.uri || CONFIG.newsPage, + resources: (a.associations || []) + .map(function (x) { return x && (x.name || x.associd); }) + .filter(Boolean) + }; + + if (start <= now) { + if (end ? end >= now : start >= staleCutoff) active.push(item); + } else if (start <= horizon) { + upcoming.push(item); + } + }); + + return { + active: active.slice(0, CONFIG.maxItems), + upcoming: upcoming.slice(0, CONFIG.maxItems), + checked: now.toISOString() + }; + } + + /* ------------------------------------------------------------------ * + * Rendering + * ------------------------------------------------------------------ */ + function h(tag, attrs, children) { + var el = document.createElement(tag); + if (attrs) Object.keys(attrs).forEach(function (k) { + if (k === "text") el.textContent = attrs[k]; + else el.setAttribute(k, attrs[k]); + }); + (children || []).forEach(function (c) { el.appendChild(c); }); + return el; + } + + function removeWidget() { + var existing = document.getElementById("rcac-outage-widget"); + if (existing) existing.remove(); + if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } + } + + function render(status) { + removeWidget(); + + var level = status.active.length ? "error" + : status.upcoming.length ? "warn" + : "ok"; + + var label = level === "error" + ? (status.active.length === 1 ? "1 active outage" + : status.active.length + " active outages") + : level === "warn" + ? "Maintenance scheduled" + : "All systems normal"; + + var root = h("div", { id: "rcac-outage-widget", "data-level": level }); + + /* --- panel --------------------------------------------------- */ + var panel = h("div", { + class: "rcac-ow-panel", + role: "dialog", + "aria-label": "RCAC system status", + hidden: "" + }); + + panel.appendChild(h("div", { class: "rcac-ow-head" }, [ + h("span", { class: "rcac-ow-title", text: "System status" }), + h("button", { + class: "rcac-ow-close", + type: "button", + "aria-label": "Close status panel", + text: "\u00d7" + }) + ])); + + var body = h("div", { class: "rcac-ow-body" }); + + function section(titleText, items, cls) { + body.appendChild(h("p", { class: "rcac-ow-section " + cls, text: titleText })); + var ul = h("ul", { class: "rcac-ow-list" }); + items.forEach(function (it) { + var a = h("a", { href: it.url, target: "_blank", rel: "noopener" }); + a.appendChild(h("span", { class: "rcac-ow-item-title", text: it.headline })); + var meta = it.date; + if (it.resources.length) { + meta += (meta ? " \u00b7 " : "") + it.resources.slice(0, 6).join(", "); + } + if (meta) a.appendChild(h("span", { class: "rcac-ow-item-meta", text: meta })); + ul.appendChild(h("li", null, [a])); + }); + body.appendChild(ul); + } + + if (status.active.length) section("Happening now", status.active, "is-error"); + if (status.upcoming.length) { + section("Upcoming (next " + CONFIG.upcomingWindowDays + " days)", + status.upcoming, "is-warn"); + } + if (!status.active.length && !status.upcoming.length) { + body.appendChild(h("p", { + class: "rcac-ow-empty", + text: "No outages reported and no maintenance scheduled in the next " + + CONFIG.upcomingWindowDays + " days." + })); + } + + panel.appendChild(body); + panel.appendChild(h("div", { class: "rcac-ow-foot" }, [ + h("a", { + href: CONFIG.newsPage, target: "_blank", rel: "noopener", + text: "All outages & maintenance \u2192" + }) + ])); + + /* --- pill ----------------------------------------------------- */ + var pill = h("button", { + class: "rcac-ow-pill", + type: "button", + "aria-expanded": "false", + "aria-label": "RCAC system status: " + label + }, [ + h("span", { class: "rcac-ow-dot", "aria-hidden": "true" }), + h("span", { class: "rcac-ow-label", text: label }) + ]); + + root.appendChild(panel); + root.appendChild(pill); + document.body.appendChild(root); + + function setOpen(open) { + if (open) panel.removeAttribute("hidden"); + else panel.setAttribute("hidden", ""); + pill.setAttribute("aria-expanded", String(open)); + try { sessionStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch (e) {} + } + + pill.addEventListener("click", function () { + setOpen(panel.hasAttribute("hidden")); + }); + panel.querySelector(".rcac-ow-close").addEventListener("click", function () { + setOpen(false); + }); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape") setOpen(false); + }); + + // Re-open if it was open before an instant-navigation reload, + // or auto-open once per session when there is an active outage. + var wasOpen = null; + try { wasOpen = sessionStorage.getItem(OPEN_KEY); } catch (e) {} + if (wasOpen === "1" || (level === "error" && wasOpen === null)) setOpen(true); + } + + /* ------------------------------------------------------------------ * + * Bootstrapping (works with and without navigation.instant) + * ------------------------------------------------------------------ */ + function shouldShowOnThisPage() { + if (!CONFIG.homepageOnly) return true; + return !!document.getElementById("outage-widget-anchor"); + } + + function init() { + if (!shouldShowOnThisPage()) { removeWidget(); return; } + if (document.getElementById("rcac-outage-widget")) return; + + fetchStatus() + .then(render) + .catch(function (err) { + // Fail quietly: a docs site must never break because the + // status API is unreachable. + console.warn("[outage-widget]", err); + removeWidget(); + }); + + refreshTimer = setInterval(function () { + if (document.hidden) return; + try { sessionStorage.removeItem(CACHE_KEY); } catch (e) {} + fetchStatus().then(render).catch(function () {}); + }, CONFIG.refreshMinutes * 60 * 1000); + } + + if (window.document$ && typeof window.document$.subscribe === "function") { + // Material instant navigation: fires on initial load and every page swap. + window.document$.subscribe(init); + } else if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/docs/index.md b/docs/index.md index 05a9a2cd..c51c2f11 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,10 +12,13 @@ meta: # Welcome to RCAC Documentation + +
+ -!!! info "Important: Use Microsoft Authenticator to Access Purdue RCAC Resources Starting May 11, 2026" +??? info "Important: Use Microsoft Authenticator to Access Purdue RCAC Resources Starting May 11, 2026" **Beginning May 11, 2026, RCAC will move from Duo Mobile to Microsoft Authenticator for logging in to Purdue’s research computing and supercomputing systems (excluding Anvil).** To ensure uninterrupted access, please complete the steps below before May 11. What you need to do: diff --git a/docs/stylesheets/outage-widget.css b/docs/stylesheets/outage-widget.css new file mode 100644 index 00000000..c89f584b --- /dev/null +++ b/docs/stylesheets/outage-widget.css @@ -0,0 +1,197 @@ +/* ========================================================================== + * RCAC System Status / Outage Widget — styles + * Inherits Material for MkDocs design tokens (colors, fonts, shadows), + * so it follows the site's light/dark scheme automatically. + * ========================================================================== */ + +#rcac-outage-widget { + position: fixed; + right: 1rem; + bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); + z-index: 5; /* above content, below Material dialogs/search overlay */ + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + font-family: var(--md-text-font-family, "Roboto", sans-serif); + -webkit-font-smoothing: antialiased; +} + +/* Status colors (WCAG-checked on white and on slate backgrounds) */ +#rcac-outage-widget { + --ow-ok: #2e7d32; + --ow-warn: #b26a00; + --ow-error: #c62828; +} +[data-md-color-scheme="slate"] #rcac-outage-widget { + --ow-ok: #66bb6a; + --ow-warn: #ffb74d; + --ow-error: #ef5350; +} + +#rcac-outage-widget[data-level="ok"] { --ow-status: var(--ow-ok); } +#rcac-outage-widget[data-level="warn"] { --ow-status: var(--ow-warn); } +#rcac-outage-widget[data-level="error"] { --ow-status: var(--ow-error); } + +/* --- Pill ----------------------------------------------------------- */ +.rcac-ow-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.9rem; + border: 1px solid color-mix(in srgb, var(--ow-status) 45%, transparent); + border-radius: 999px; + background: var(--md-default-bg-color, #fff); + color: var(--md-typeset-color, #000); + font: inherit; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + box-shadow: var(--md-shadow-z2, 0 2px 8px rgba(0, 0, 0, 0.2)); + transition: box-shadow 0.15s, transform 0.15s; +} +.rcac-ow-pill:hover, +.rcac-ow-pill:focus-visible { + box-shadow: var(--md-shadow-z3, 0 4px 12px rgba(0, 0, 0, 0.25)); + transform: translateY(-1px); +} +.rcac-ow-pill:focus-visible { + outline: 2px solid var(--md-accent-fg-color, #526cfe); + outline-offset: 2px; +} + +.rcac-ow-dot { + width: 0.55rem; + height: 0.55rem; + flex: none; + border-radius: 50%; + background: var(--ow-status); +} +#rcac-outage-widget[data-level="error"] .rcac-ow-dot { + animation: rcac-ow-pulse 1.6s ease-in-out infinite; +} +@keyframes rcac-ow-pulse { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ow-status) 45%, transparent); } + 50% { box-shadow: 0 0 0 5px transparent; } +} + +/* --- Panel ----------------------------------------------------------- */ +.rcac-ow-panel { + width: min(21rem, calc(100vw - 2rem)); + max-height: min(24rem, 70vh); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 0.4rem; + border-top: 3px solid var(--ow-status); + background: var(--md-default-bg-color, #fff); + color: var(--md-typeset-color, #000); + box-shadow: var(--md-shadow-z3, 0 4px 16px rgba(0, 0, 0, 0.25)); + font-size: 0.65rem; + line-height: 1.45; +} +.rcac-ow-panel[hidden] { display: none; } + +.rcac-ow-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.8rem 0.3rem; +} +.rcac-ow-title { + font-size: 0.75rem; + font-weight: 700; +} +.rcac-ow-close { + border: 0; + background: none; + color: var(--md-default-fg-color--light, #0009); + font-size: 1rem; + line-height: 1; + padding: 0.1rem 0.3rem; + cursor: pointer; + border-radius: 0.2rem; +} +.rcac-ow-close:hover { color: var(--md-typeset-color, #000); } +.rcac-ow-close:focus-visible { + outline: 2px solid var(--md-accent-fg-color, #526cfe); +} + +.rcac-ow-body { + padding: 0 0.8rem 0.4rem; + overflow-y: auto; +} + +.rcac-ow-section { + margin: 0.5rem 0 0.2rem; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.rcac-ow-section.is-error { color: var(--ow-error); } +.rcac-ow-section.is-warn { color: var(--ow-warn); } + +.rcac-ow-list { + list-style: none; + margin: 0; + padding: 0; +} +.rcac-ow-list li + li { + border-top: 1px solid var(--md-default-fg-color--lightest, #0000001f); +} +.rcac-ow-list a { + display: block; + padding: 0.45rem 0.2rem; + color: inherit; + text-decoration: none; + border-radius: 0.2rem; +} +.rcac-ow-list a:hover .rcac-ow-item-title, +.rcac-ow-list a:focus-visible .rcac-ow-item-title { + color: var(--md-accent-fg-color, #526cfe); + text-decoration: underline; +} +.rcac-ow-item-title { + display: block; + font-weight: 600; +} +.rcac-ow-item-meta { + display: block; + margin-top: 0.1rem; + color: var(--md-default-fg-color--light, #0009); + font-size: 0.6rem; +} + +.rcac-ow-empty { + margin: 0.4rem 0 0.6rem; + color: var(--md-default-fg-color--light, #0009); +} + +.rcac-ow-foot { + padding: 0.45rem 0.8rem 0.6rem; + border-top: 1px solid var(--md-default-fg-color--lightest, #0000001f); +} +.rcac-ow-foot a { + font-size: 0.62rem; + font-weight: 600; + color: var(--md-accent-fg-color, #526cfe); + text-decoration: none; +} +.rcac-ow-foot a:hover { text-decoration: underline; } + +/* --- Small screens & motion ----------------------------------------- */ +@media (max-width: 30em) { + .rcac-ow-label { max-width: 9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +} + +@media (prefers-reduced-motion: reduce) { + .rcac-ow-pill { transition: none; } + #rcac-outage-widget[data-level="error"] .rcac-ow-dot { animation: none; } +} + +/* Keep clear of Material's back-to-top button on print */ +@media print { + #rcac-outage-widget { display: none; } +} diff --git a/mkdocs.yml b/mkdocs.yml index 76106d4f..eff519a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,11 +48,13 @@ theme: extra_css: - stylesheets/extra.css + - stylesheets/outage-widget.css extra_javascript: - assets/js/tablefilter.js - assets/js/init-tablefilter.js - assets/js/breadcrumbs.js - assets/js/external-links.js + - assets/js/outage-widget.js plugins: - search