フォーラム通知ミニステータス
<!doctype html> <html data-status="loading"> <head> <title>Notifier - status indicator</title> <script> function setStatus(status, successRate, detail) { void { indeterminate() { setRootStatus("indeterminate") setStatusDetails("ステータスの取得に失敗しました。") }, success() { setRootStatus("success") setStatusDetails( `最後のチェックは<b>${Math.floor((detail % 3600) / 60)}</b>分前`, `次のチェック予定時刻まで ${nextRunDue()}`, `${uptime(successRate)}`, ) }, waiting() { setRootStatus("success") setStatusDetails( "現在、チェック中!", `Estimated <b>~${Math.ceil(detail / 60)}</b> m`, `${uptime(successRate)}`, ) }, missedSeveral() { setRootStatus("failure") setStatusDetails( `Missed <b>${detail}</b> run${detail > 1 ? "s" : ""}.`, `次のチェック予定時刻まで ${nextRunDue()}`, `${uptime(successRate)}`, ) }, missedLots() { setRootStatus("failure") setStatusDetails( "一時的なオフライン", `最後のチェックは ${new Date(detail * 1000).toISOString().split("T")[0]}`, `${uptime(successRate)}`, ) }, missedAll() { setRootStatus("failure") setStatusDetails("オフライン") }, }[status]() } function setStatusDetails(...details) { const statusDetails = document.getElementById("status-details") statusDetails.innerHTML = details.join(" <br> ") } function setRootStatus(status) { document.documentElement.dataset.status = status } function nextRunDue() { const secondsRemaining = 3600 - ((new Date().getTime() / 1000) % 3600) let minutesRemaining = "< 1" if (secondsRemaining > 60) minutesRemaining = Math.ceil(secondsRemaining / 60) if (secondsRemaining > 10) return `in <b>${minutesRemaining}</b> m` return "<b>now</b>" } function uptime(rate) { return `<span class="uptime">30日間の稼働率: <b>${(rate * 100).toFixed(1)}%</b>` } function nearestHour(timestamp) { return Math.floor(timestamp / 3600) * 3600 } async function retrieveStats() { const abortController = new AbortController() const abortTimer = setTimeout(() => abortController.abort(), 10000) let recentNotifications try { const response = await fetch( "https://d30oi1o0d99sd7.cloudfront.net/logs/recent_notifications.json", // "http://notifier.rossjrw.com/assets/sample-notifications/standard.json", { signal: abortController.signal }, ) clearTimeout(abortTimer) if (!response.ok) { console.error(response) throw new Error(response.statusText) } recentNotifications = await response.json() } catch (error) { console.error("Unable to retrieve notifier stats") setStatus("indeterminate") throw error } return recentNotifications } function setStatusBasedOnStats(recentNotifications) { // Use the current timestamp to work out how many missed activations there are const now = Math.floor(Date.now() / 1000) const latestNotificationTimestamp = Math.max( 0, ...recentNotifications.activations .filter( // Don't count activations that didn't complete (a) => a["notify_end_timestamp"] != null && a["end_timestamp"] != null, ) .map((a) => a["end_timestamp"]), ) let successRate30Day = (() => { const timestamp30DaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000 // Count the number of activations since 30 days ago const recentActivations = recentNotifications.activations.filter( (a) => a["start_timestamp"] > timestamp30DaysAgo / 1000 && // Don't count activations that didn't complete a["notify_end_timestamp"] != null && a["end_timestamp"] != null, ) const perfectActivationsCount = 30 * 24 return recentActivations.length / perfectActivationsCount })() let missedActivations = (nearestHour(now) - nearestHour(latestNotificationTimestamp)) / 3600 // If there are 0 missed notifications, success if (missedActivations === 0) return setStatus( "success", successRate30Day, now - latestNotificationTimestamp, ) // If there is 1 missed notification, but is currently within the first ~quarter hour, we might just be waiting for it - show success but indicate waiting if (missedActivations === 1) { // Take the average of recent notifications to guess the ETA const avgRunTime = 1.2 * (() => { const recentActivations = recentNotifications.activations.slice(-24) const durations = recentActivations.map((a) => Math.round(a["end_timestamp"] - a["start_timestamp"]), ) return durations.reduce((a, b) => a + b) / durations.length })() if (now - nearestHour(now) < avgRunTime) { // Include this run in the success rate successRate30Day = Math.min(1, successRate30Day + (1 / (30 * 24))) return setStatus( "waiting", successRate30Day, avgRunTime - (now - nearestHour(now)), ) } // Run might overrun - allow up to 15 mins if (now - nearestHour(now) < 900) return setStatus( "waiting", successRate30Day, 900 - (now - nearestHour(now)), ) // There was legitimately a missed run return setStatus("missedSeveral", successRate30Day, 1) } // If there are less than X missed notifications, show that number and indicate failure const missedActivationsThreshold = 24 if ( missedActivations > 1 && missedActivations <= missedActivationsThreshold ) { // If in first 15 mins, there could be one running right now if (now - nearestHour(now) < 900) missedActivations -= 1 return setStatus("missedSeveral", successRate30Day, missedActivations) } // If there are more than X missed notifications, indicate that notifier is offline if (missedActivations > missedActivationsThreshold) { // Use last known notification to display date if (latestNotificationTimestamp) return setStatus( "missedLots", successRate30Day, latestNotificationTimestamp, ) // If no last notification present (not currently possible?) imply permanently offline else return setStatus("missedAll") } // That should match all conditions, but fallback just in case return setStatus("indeterminate") } addEventListener("DOMContentLoaded", async () => { let recentNotifications = await retrieveStats() setStatusBasedOnStats(recentNotifications) // Refresh the stats each minute while (true) { await new Promise((resolve) => setTimeout(resolve, 60000 - (new Date().getTime() % 60000)), ) // If the most recent entry is not in the current hour, attempt to redownload it const latestNotificationTimestamp = Math.max( ...recentNotifications.activations.map((a) => a["end_timestamp"]), ) if ( new Date(latestNotificationTimestamp * 1000).getHours() !== new Date().getHours() ) { setRootStatus("loading") recentNotifications = await retrieveStats() } setStatusBasedOnStats(recentNotifications) } }) // Temporary, for testing // setTimeout(() => setStatus("indeterminate"), 3000) </script> <style> :root { font-family: sans-serif; color: white; --status-color-loading: hsl(0, 0%, 53%); --status-color-success: hsl(109, 75%, 43%); --status-color-failure: hsl(0, 75%, 43%); --status-icon-loading: url(http://notifier.rossjrw.com/assets/loading.svg); --status-icon-success: url(http://notifier.rossjrw.com/assets/check.svg); --status-icon-failure: url(http://notifier.rossjrw.com/assets/close.svg); --status-icon-indeterminate: url(http://notifier.rossjrw.com/assets/indeterminate.svg); --status-color: var(--status-color-loading); --status-icon: var(--status-icon-loading); --live-status-color: transparent; } :root[data-status="success"] { --status-color: var(--status-color-success); --status-icon: var(--status-icon-success); --live-status-color: var(--status-color-success); } :root[data-status="failure"] { --status-color: var(--status-color-failure); --status-icon: var(--status-icon-failure); } :root[data-status="indeterminate"] { --status-color: var(--status-color-indeterminate); --status-icon: var(--status-icon-indeterminate); } .status-container { display: grid; grid-template-areas: "status header" "status footer"; grid-template-columns: 3.25rem 10.75rem; grid-template-rows: 1.5rem 3.5rem; width: 14rem; border-radius: 0.3rem; background-color: #555; box-shadow: 0 0.1rem 0.1rem rgba(0, 0, 0, 0.5); overflow: hidden; /* The status container is a link */ text-decoration: none; color: white; } .status-container h1::after { content: ""; background-image: url(http://notifier.rossjrw.com/assets/arrow.svg); background-size: contain; background-position: center; background-repeat: no-repeat; height: 0.8em; width: 1.6em; display: inline-block; transition: opacity ease-in 0.08s, transform ease-in 0.1s; opacity: 0; transform: translateX(-100%); } .status-container:hover h1 { text-decoration: underline 1px; } .status-container:hover h1::after { transition: opacity ease-out 0.1s, transform ease-out 0.1s; opacity: 1; transform: none; } aside { grid-area: status; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.125rem; background-image: linear-gradient( to bottom, rgb(238, 238, 238, 0.2), transparent, rgb(12, 12, 12, 0.1) ); background-color: var(--status-color); box-shadow: 0.1rem 0 0.2rem rgba(0, 0, 0, 0.25); transition: background-color 0.3s ease; } header { display: flex; align-items: center; padding: 0.6rem 0.4rem 0.4rem 0.5rem; grid-area: header; background-image: linear-gradient( to bottom, rgb(238, 238, 238, 0.1), transparent ); justify-content: space-between; } .live-indicator { font-size: 0.6rem; letter-spacing: 0.25px; text-decoration: none !important; color: var(--live-status-color); display: flex; align-items: center; } .live-indicator::after { content: ""; border-radius: 50%; background-color: var(--status-color); width: 0.75rem; height: 0.75rem; background-image: linear-gradient( to bottom, rgb(238, 238, 238, 0.2), transparent ); box-shadow: 0 0rem 0.2rem rgba(0, 0, 0, 0.25); display: inline-block; margin: 0 0 0.15rem 0.1rem; } footer { display: flex; align-items: center; padding: 0.5rem; grid-area: footer; background-image: linear-gradient( to bottom, rgb(12, 12, 12, 0.1), transparent ); box-shadow: inset 0 0.1rem 0.2rem rgba(0, 0, 0, 0.25); } h1 { margin: 0; line-height: 1; font-size: 0.875rem; font-weight: 600; text-shadow: 0.1rem 0.1rem rgba(0, 0, 0, 0.25); white-space: nowrap; } p { width: 100%; margin: 0; line-height: 1.1; white-space: nowrap; font-size: 0.8125rem; font-weight: 400; text-shadow: 0.1rem 0.1rem rgba(0, 0, 0, 0.25); } .uptime { opacity: 0.6; display: block; font-size: 0.8125rem; font-style: italic; text-align: center; padding-top: 0.125rem; } .icon-logo, .icon-status { background-size: contain; background-position: center; } .icon-logo, .icon-status-wrapper { filter: drop-shadow(0.05rem 0.1rem rgba(0, 0, 0, 0.25)); } .icon-logo { height: 1rem; width: 1rem; background-image: url(http://notifier.rossjrw.com/assets/envelope.svg); } .icon-status { height: 2.25rem; width: 2.25rem; background-image: var(--status-icon); } @media (prefers-reduced-motion: no-preference) { :root[data-status="loading"] .icon-status { animation: spin 1.5s infinite linear; } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style> </head> <body> <a class="status-container" href="//notifications.wikidot.com/status" target="_top" > <aside> <div class="icon-logo"></div> <div class="icon-status-wrapper"> <div class="icon-status"></div> </div> </aside> <header> <h1>Notifierのステータス</h1> <span class="live-indicator">ライブ</span> </header> <footer> <p id="status-details">チェック中…</p> </footer> </a> </body> </html>
