フォーラム通知ミニステータス
<!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>
特に明記しない限り、このページのコンテンツは次のライセンスの下にあります: Creative Commons Attribution-ShareAlike 3.0 License