BareGit

Persist hourly status aggregates

Store hourly worst-status aggregates alongside raw probe history and
render the service history from the aggregate table. Backfill existing
databases during schema initialization and cover migration, concurrency,
and rendering behavior.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Sep 20 14:49:07 2026 -0700
Commit: 56e78600f3acb6a5c763ea78ed2bbc26e24998be

Changes

diff --git a/src/app.cpp b/src/app.cpp
index 853b4a0..8b18855 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -5,6 +5,7 @@
 #include <functional>
 #include <string>
 #include <string_view>
+#include <vector>
 
 #include "embedded_assets.h"
 
@@ -14,6 +15,9 @@
 namespace
 {
 
+constexpr std::int64_t SECONDS_PER_HOUR = 60 * 60;
+constexpr std::int64_t HISTORY_HOUR_COUNT = 48;
+
 enum class UiStatus
 {
     GOOD,
@@ -44,6 +48,20 @@ StatusPresentation present(UiStatus status)
     std::terminate();
 }
 
+UiStatus statusFromProbe(ProbeStatus status)
+{
+    switch(status)
+    {
+    case ProbeStatus::GOOD:
+        return UiStatus::GOOD;
+    case ProbeStatus::BAD:
+        return UiStatus::BAD;
+    case ProbeStatus::OTHER:
+        return UiStatus::OTHER;
+    }
+    return UiStatus::OTHER;
+}
+
 std::int64_t nowUnixSeconds()
 {
     return std::chrono::duration_cast<std::chrono::seconds>(
@@ -75,16 +93,39 @@ UiStatus deriveStatus(const ServiceConfig& config,
     {
         return UiStatus::NA;
     }
-    switch(record->status)
+    return statusFromProbe(record->status);
+}
+
+std::int64_t startOfHour(std::int64_t timestamp)
+{
+    const auto remainder = timestamp % SECONDS_PER_HOUR;
+    return timestamp - (remainder < 0
+        ? remainder + SECONDS_PER_HOUR : remainder);
+}
+
+nlohmann::json makeHistoryData(
+    const std::vector<HourlyStatusRecord>& records,
+    std::int64_t first_hour)
+{
+    nlohmann::json history = nlohmann::json::array();
+    auto record = records.begin();
+    for(std::int64_t index = 0; index < HISTORY_HOUR_COUNT; ++index)
     {
-    case ProbeStatus::GOOD:
-        return UiStatus::GOOD;
-    case ProbeStatus::BAD:
-        return UiStatus::BAD;
-    case ProbeStatus::OTHER:
-        return UiStatus::OTHER;
+        const auto hour_start = first_hour + index * SECONDS_PER_HOUR;
+        while(record != records.end() && record->hour_start < hour_start)
+        {
+            ++record;
+        }
+        const auto status = record != records.end() &&
+                record->hour_start == hour_start
+            ? statusFromProbe(record->status) : UiStatus::NA;
+        const auto presentation = present(status);
+        history.push_back({
+            {"status_class", presentation.class_name},
+            {"status_label", presentation.label}
+        });
     }
-    return UiStatus::OTHER;
+    return history;
 }
 
 const EmbeddedAsset* findAsset(std::string_view path)
@@ -116,11 +157,21 @@ mw::E<nlohmann::json> makePageData(const Configuration& configuration,
         };
         for(const auto& config : group.services)
         {
+            const auto first_hour = startOfHour(now) -
+                (HISTORY_HOUR_COUNT - 1) * SECONDS_PER_HOUR;
+            const auto history_end = first_hour +
+                HISTORY_HOUR_COUNT * SECONDS_PER_HOUR;
             auto latest = data_source.latest(config.id);
             if(!latest)
             {
                 return std::unexpected(latest.error());
             }
+            auto history = data_source.hourlyHistory(config.id, first_hour,
+                                                     history_end);
+            if(!history)
+            {
+                return std::unexpected(history.error());
+            }
             const auto& record = *latest;
             const auto status = deriveStatus(config, record, now);
             const auto presentation = present(status);
@@ -135,7 +186,9 @@ mw::E<nlohmann::json> makePageData(const Configuration& configuration,
                 {"has_result", record.has_value()},
                 {"timestamp", record ? record->timestamp : 0},
                 {"duration_microsecond", record
-                    ? record->duration_microsecond : 0}
+                    ? record->duration_microsecond : 0},
+                {"history_hours", HISTORY_HOUR_COUNT},
+                {"history", makeHistoryData(*history, first_hour)}
             };
             group_data["services"].push_back(std::move(service_data));
         }
diff --git a/src/data_source_interface.h b/src/data_source_interface.h
index 1635498..a070865 100644
--- a/src/data_source_interface.h
+++ b/src/data_source_interface.h
@@ -25,6 +25,22 @@ struct StatusRecord
     bool operator==(const StatusRecord&) const = default;
 };
 
+/// One hourly status aggregate produced from one or more probe results.
+struct HourlyStatusRecord
+{
+    /// Unique service ID from the configuration.
+    std::string service_id;
+    /// UTC Unix timestamp at the start of the hour.
+    std::int64_t hour_start;
+    /// Most severe outcome observed during the hour.
+    ProbeStatus status;
+    /// Number of probe results represented by this aggregate.
+    std::int64_t sample_count;
+
+    /// Compare all aggregate fields.
+    bool operator==(const HourlyStatusRecord&) const = default;
+};
+
 /// Thread-safe access to probe history; failures are returned as mw::E errors.
 /// Missing or stale data is interpreted as NA by the caller.
 class DataSourceInterface
@@ -46,4 +62,10 @@ public:
     virtual mw::E<std::vector<StatusRecord>> history(
         const std::string& service_id, std::int64_t start,
         std::int64_t end) = 0;
+
+    /// Read hourly aggregates in [start, end), ordered by hour.
+    /// Missing hours have no record and are interpreted as N/A by the caller.
+    virtual mw::E<std::vector<HourlyStatusRecord>> hourlyHistory(
+        const std::string& service_id, std::int64_t start,
+        std::int64_t end) = 0;
 };
diff --git a/src/data_source_sqlite.cpp b/src/data_source_sqlite.cpp
index 821691f..154d497 100644
--- a/src/data_source_sqlite.cpp
+++ b/src/data_source_sqlite.cpp
@@ -2,6 +2,27 @@
 
 #include <utility>
 
+namespace
+{
+
+constexpr std::int64_t SECONDS_PER_HOUR = 60 * 60;
+
+std::int64_t startOfHour(std::int64_t timestamp)
+{
+    const auto remainder = timestamp % SECONDS_PER_HOUR;
+    return timestamp - (remainder < 0
+        ? remainder + SECONDS_PER_HOUR : remainder);
+}
+
+std::unexpected<mw::Error> transactionError(mw::SQLite& database,
+                                             const mw::Error& error)
+{
+    database.execute("ROLLBACK;");
+    return std::unexpected(error);
+}
+
+}
+
 DataSourceSqlite::DataSourceSqlite(std::unique_ptr<mw::SQLite> database)
     : database(std::move(database))
 {}
@@ -30,12 +51,63 @@ mw::E<std::unique_ptr<DataSourceSqlite>> DataSourceSqlite::open(
         CREATE INDEX IF NOT EXISTS status_history_service_time
         ON status_history(service_id, timestamp, id)
     )"));
+    DO_OR_RETURN(database->execute(R"(
+        CREATE TABLE IF NOT EXISTS hourly_status (
+            service_id TEXT NOT NULL CHECK(length(service_id) > 0),
+            hour_start INTEGER NOT NULL,
+            status INTEGER NOT NULL CHECK(status BETWEEN 0 AND 2),
+            sample_count INTEGER NOT NULL CHECK(sample_count > 0),
+            PRIMARY KEY(service_id, hour_start)
+        )
+    )"));
+    ASSIGN_OR_RETURN(auto schema_version,
+        database->evalToValue<int>("PRAGMA user_version;"));
+    if(schema_version == 0)
+    {
+        DO_OR_RETURN(database->execute("BEGIN IMMEDIATE;"));
+        auto backfill = database->execute(R"(
+            INSERT INTO hourly_status
+                (service_id, hour_start, status, sample_count)
+            SELECT service_id,
+                   timestamp - ((timestamp % 3600 + 3600) % 3600),
+                   CASE
+                       WHEN SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) > 0
+                           THEN 1
+                       WHEN SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) > 0
+                           THEN 2
+                       ELSE 0
+                   END,
+                   COUNT(*)
+            FROM status_history
+            WHERE 1
+            GROUP BY service_id,
+                     timestamp - ((timestamp % 3600 + 3600) % 3600)
+            ON CONFLICT(service_id, hour_start) DO UPDATE SET
+                status = excluded.status,
+                sample_count = excluded.sample_count
+        )");
+        if(!backfill)
+        {
+            return transactionError(*database, backfill.error());
+        }
+        auto set_version = database->execute("PRAGMA user_version = 1;");
+        if(!set_version)
+        {
+            return transactionError(*database, set_version.error());
+        }
+        auto committed = database->execute("COMMIT;");
+        if(!committed)
+        {
+            return transactionError(*database, committed.error());
+        }
+    }
     return std::unique_ptr<DataSourceSqlite>(
         new DataSourceSqlite(std::move(database)));
 }
 
 mw::E<void> DataSourceSqlite::save(const StatusRecord& record)
 {
+    std::lock_guard lock(mutex);
     const int status = static_cast<int>(record.status);
     if(record.service_id.empty() ||
        record.service_id.find('\0') != std::string::npos ||
@@ -43,19 +115,67 @@ mw::E<void> DataSourceSqlite::save(const StatusRecord& record)
     {
         return std::unexpected(mw::runtimeError("Invalid status record"));
     }
-    ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
+    DO_OR_RETURN(database->execute("BEGIN IMMEDIATE;"));
+    auto statement = database->statementFromStr(R"(
         INSERT INTO status_history
             (service_id, timestamp, duration_microsecond, status)
         VALUES (?, ?, ?, ?)
-    )"));
-    DO_OR_RETURN(statement.bind(record.service_id, record.timestamp,
-                                record.duration_microsecond, status));
-    return database->execute(std::move(statement));
+    )");
+    if(!statement)
+    {
+        return transactionError(*database, statement.error());
+    }
+    auto bound = statement->bind(record.service_id, record.timestamp,
+                                 record.duration_microsecond, status);
+    if(!bound)
+    {
+        return transactionError(*database, bound.error());
+    }
+    auto inserted = database->execute(std::move(*statement));
+    if(!inserted)
+    {
+        return transactionError(*database, inserted.error());
+    }
+
+    auto aggregate = database->statementFromStr(R"(
+        INSERT INTO hourly_status
+            (service_id, hour_start, status, sample_count)
+        VALUES (?, ?, ?, 1)
+        ON CONFLICT(service_id, hour_start) DO UPDATE SET
+            status = CASE
+                WHEN hourly_status.status = 1 OR excluded.status = 1 THEN 1
+                WHEN hourly_status.status = 2 OR excluded.status = 2 THEN 2
+                ELSE 0
+            END,
+            sample_count = hourly_status.sample_count + 1
+    )");
+    if(!aggregate)
+    {
+        return transactionError(*database, aggregate.error());
+    }
+    auto aggregate_bound = aggregate->bind(
+        record.service_id, startOfHour(record.timestamp), status);
+    if(!aggregate_bound)
+    {
+        return transactionError(*database, aggregate_bound.error());
+    }
+    auto aggregated = database->execute(std::move(*aggregate));
+    if(!aggregated)
+    {
+        return transactionError(*database, aggregated.error());
+    }
+    auto committed = database->execute("COMMIT;");
+    if(!committed)
+    {
+        return transactionError(*database, committed.error());
+    }
+    return {};
 }
 
 mw::E<std::optional<StatusRecord>> DataSourceSqlite::latest(
     const std::string& service_id)
 {
+    std::lock_guard lock(mutex);
     ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
         SELECT timestamp, duration_microsecond, status FROM status_history
         WHERE service_id = ? ORDER BY timestamp DESC, id DESC LIMIT 1
@@ -76,6 +196,7 @@ mw::E<std::optional<StatusRecord>> DataSourceSqlite::latest(
 mw::E<std::vector<StatusRecord>> DataSourceSqlite::history(
     const std::string& service_id, std::int64_t start, std::int64_t end)
 {
+    std::lock_guard lock(mutex);
     if(start > end)
     {
         return std::unexpected(mw::runtimeError("Reversed history bounds"));
@@ -98,3 +219,31 @@ mw::E<std::vector<StatusRecord>> DataSourceSqlite::history(
     }
     return records;
 }
+
+mw::E<std::vector<HourlyStatusRecord>> DataSourceSqlite::hourlyHistory(
+    const std::string& service_id, std::int64_t start, std::int64_t end)
+{
+    std::lock_guard lock(mutex);
+    if(start > end)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Reversed hourly history bounds"));
+    }
+    ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
+        SELECT hour_start, status, sample_count FROM hourly_status
+        WHERE service_id = ? AND hour_start >= ? AND hour_start < ?
+        ORDER BY hour_start
+    )"));
+    DO_OR_RETURN(statement.bind(service_id, start, end));
+    ASSIGN_OR_RETURN(auto rows,
+        (database->eval<std::int64_t, int, std::int64_t>(
+            std::move(statement))));
+    std::vector<HourlyStatusRecord> records;
+    records.reserve(rows.size());
+    for(const auto& [hour_start, status, sample_count] : rows)
+    {
+        records.push_back({service_id, hour_start,
+                           static_cast<ProbeStatus>(status), sample_count});
+    }
+    return records;
+}
diff --git a/src/data_source_sqlite.h b/src/data_source_sqlite.h
index 8fe5bd6..2a1e5f6 100644
--- a/src/data_source_sqlite.h
+++ b/src/data_source_sqlite.h
@@ -1,6 +1,7 @@
 #pragma once
 
 #include <memory>
+#include <mutex>
 
 #include <mw/database.hpp>
 
@@ -29,8 +30,14 @@ public:
         const std::string& service_id, std::int64_t start,
         std::int64_t end) override;
 
+    /// Return persisted hourly aggregates in [start, end).
+    mw::E<std::vector<HourlyStatusRecord>> hourlyHistory(
+        const std::string& service_id, std::int64_t start,
+        std::int64_t end) override;
+
 private:
     explicit DataSourceSqlite(std::unique_ptr<mw::SQLite> database);
 
     std::unique_ptr<mw::SQLite> database;
+    mutable std::mutex mutex;
 };
diff --git a/static/index.html b/static/index.html
index 19ef4fb..f0068ef 100644
--- a/static/index.html
+++ b/static/index.html
@@ -34,6 +34,16 @@
               </span>
             </div>
             <p>{{ service.description }}</p>
+            <div class="service-history" role="group"
+                 aria-label="Last {{ service.history_hours }} hours of status"
+                 title="Oldest hour on the left; current hour on the right">
+              {% for hour in service.history %}
+              <span class="history-cell led-{{ hour.status_class }}"
+                    role="img"
+                    title="{{ hour.status_label }}"
+                    aria-label="{{ hour.status_label }}"></span>
+              {% endfor %}
+            </div>
           </li>
           {% endfor %}
         </ul>
diff --git a/static/style.css b/static/style.css
index dff61a0..92e9a3d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -47,6 +47,8 @@ table {
     border-spacing: 0;
 }
 
+* { box-sizing: border-box; }
+
 /* ========== End of reset =======================================> */
 
 :root
@@ -55,6 +57,12 @@ table {
     color-scheme: light dark;
     color: #a4b0be;
     background: black;
+    --pip-size: 4px;
+    --card-padding: 1rem;
+    --color-good: #2ed573;
+    --color-bad: #ff4757;
+    --color-other: #eccc68;
+    --color-na: #2f3542;
 }
 
 body
@@ -97,16 +105,17 @@ h2
 .service-card
 {
     border: 1px solid #57606f;
-    padding: 0.5rem 1rem 1rem 1rem;
-    width: 18rem;
+    padding: calc(0.5 * var(--card-padding)) var(--card-padding) var(--card-padding) var(--card-padding);
+    width: calc(48 * var(--pip-size) + 47px);
     height: 6rem;
     text-align: start;
+    box-sizing: content-box;
 }
 
 .service-heading
 {
     align-items: center;
-    margin: 0.5rem 0;
+    margin: calc(0.5 * var(--card-padding)) 0;
 }
 
 .led
@@ -120,30 +129,37 @@ h2
 
 .led-good
 {
-    background: #1b9e4b;
+    background: var(--color-good);
 }
 
 .led-bad
 {
-    background: #d93434;
+    background: var(--color-bad);
 }
 
 .led-other
 {
-    background: #e0a21a;
+    background: var(--color-other);
 }
 
 .led-na
 {
-    background: #858b94;
-    opacity: 0.45;
+    background: var(--color-na);
 }
 
-.status-label
+.service-history
 {
-    color: #59616d;
-    font-size: 0.9rem;
-    font-weight: 600;
+    display: flex;
+    gap: 1px;
+    height: 2px;
+    margin-top: 0.8rem;
+}
+
+.history-cell
+{
+    display: block;
+    flex: 0 0 var(--pip-size);
+    height: var(--pip-size);
 }
 
 .empty
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index a1c5923..42d1d49 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -156,6 +156,36 @@ TEST(App, RendersConfiguredStatuses)
     EXPECT_NE(response->body.find("Good"), std::string::npos);
 }
 
+TEST(App, RendersHourlyHistory)
+{
+    auto source = openTestSource();
+    ASSERT_TRUE(source);
+    const auto now = std::chrono::duration_cast<std::chrono::seconds>(
+        std::chrono::system_clock::now().time_since_epoch()).count();
+    const auto current_hour = now - now % (60 * 60);
+    ASSERT_TRUE(source->save({"blog", current_hour + 10, 1200,
+                              ProbeStatus::GOOD}));
+    ASSERT_TRUE(source->save({"blog", current_hour + 20, 1200,
+                              ProbeStatus::OTHER}));
+    ASSERT_TRUE(source->save({"blog", current_hour + 30, 1200,
+                              ProbeStatus::BAD}));
+    const auto previous_hour = current_hour - 60 * 60;
+    ASSERT_TRUE(source->save({"blog", previous_hour + 10, 1200,
+                              ProbeStatus::GOOD}));
+    ASSERT_TRUE(source->save({"blog", previous_hour + 20, 1200,
+                              ProbeStatus::OTHER}));
+    TestApp app(serviceConfiguration(), *source);
+    const int port = app.listen();
+    ASSERT_GT(port, 0);
+    httplib::Client client("127.0.0.1", port);
+    auto response = client.Get("/");
+    ASSERT_TRUE(response);
+    EXPECT_EQ(response->status, 200);
+    EXPECT_NE(response->body.find("history-bad"), std::string::npos);
+    EXPECT_NE(response->body.find("history-other"), std::string::npos);
+    EXPECT_NE(response->body.find("history-na"), std::string::npos);
+}
+
 TEST(App, RendersMissingStatusAsNa)
 {
     auto source = openTestSource();
diff --git a/tests/data_source_sqlite_test.cpp b/tests/data_source_sqlite_test.cpp
index b1b0bdf..c05051f 100644
--- a/tests/data_source_sqlite_test.cpp
+++ b/tests/data_source_sqlite_test.cpp
@@ -36,9 +36,22 @@ TEST(DataSourceSqlite, History)
         << "Ordered history and bound service ID";
     ASSERT_TRUE((source.history(service_id, 2199999999, 2200000000).value() ==
                 std::vector<StatusRecord>{older})) << "Exclusive upper bound";
+    ASSERT_TRUE(source.save({"aggregate", 0, 1, ProbeStatus::GOOD}));
+    ASSERT_TRUE(source.save({"aggregate", 1, 1, ProbeStatus::OTHER}));
+    ASSERT_TRUE(source.save({"aggregate", 3599, 1, ProbeStatus::BAD}));
+    ASSERT_TRUE(source.save({"aggregate", 3600, 1, ProbeStatus::GOOD}));
+    ASSERT_TRUE(source.save({"aggregate", 7200, 1, ProbeStatus::OTHER}));
+    EXPECT_EQ(source.hourlyHistory("aggregate", 0, 10800).value(),
+              (std::vector<HourlyStatusRecord>{
+                  {"aggregate", 0, ProbeStatus::BAD, 3},
+                  {"aggregate", 3600, ProbeStatus::GOOD, 1},
+                  {"aggregate", 7200, ProbeStatus::OTHER, 1}
+              }));
     ASSERT_TRUE(source.history(service_id, 0, 0).value().empty())
         << "Empty range";
     ASSERT_TRUE(!source.history(service_id, 1, 0)) << "Reject reversed bounds";
+    ASSERT_TRUE(!source.hourlyHistory(service_id, 1, 0))
+        << "Reject reversed hourly bounds";
     ASSERT_TRUE(!source.save({"", 0, 0, ProbeStatus::GOOD}))
         << "Reject empty ID";
     ASSERT_TRUE(!source.save({"x", 0, -1, ProbeStatus::GOOD}))
@@ -131,4 +144,46 @@ TEST_F(DataSourceSqliteFile, Persistence)
     EXPECT_FALSE(DataSourceSqlite::open(path + "/missing.sqlite"));
 }
 
+TEST_F(DataSourceSqliteFile, BackfillsHourlyHistory)
+{
+    {
+        auto database = mw::SQLite::connectFile(path);
+        ASSERT_TRUE(database);
+        ASSERT_TRUE((*database)->execute(R"(
+            CREATE TABLE status_history (
+                id INTEGER PRIMARY KEY,
+                service_id TEXT NOT NULL,
+                timestamp INTEGER NOT NULL,
+                duration_microsecond INTEGER NOT NULL,
+                status INTEGER NOT NULL
+            )
+        )"));
+        ASSERT_TRUE((*database)->execute(R"(
+            INSERT INTO status_history
+                (service_id, timestamp, duration_microsecond, status)
+            VALUES
+                ('legacy', 1, 1, 0),
+                ('legacy', 2, 1, 2),
+                ('legacy', 3599, 1, 1),
+                ('legacy', 3600, 1, 0)
+        )"));
+    }
+
+    auto opened = DataSourceSqlite::open(path);
+    ASSERT_TRUE(opened);
+    EXPECT_EQ((*opened)->hourlyHistory("legacy", 0, 7200).value(),
+              (std::vector<HourlyStatusRecord>{
+                  {"legacy", 0, ProbeStatus::BAD, 3},
+                  {"legacy", 3600, ProbeStatus::GOOD, 1}
+              }));
+
+    auto reopened = DataSourceSqlite::open(path);
+    ASSERT_TRUE(reopened);
+    EXPECT_EQ((*reopened)->hourlyHistory("legacy", 0, 7200).value(),
+              (std::vector<HourlyStatusRecord>{
+                  {"legacy", 0, ProbeStatus::BAD, 3},
+                  {"legacy", 3600, ProbeStatus::GOOD, 1}
+              }));
+}
+
 }
diff --git a/tests/scheduler_test.cpp b/tests/scheduler_test.cpp
index 6b91986..a07da20 100644
--- a/tests/scheduler_test.cpp
+++ b/tests/scheduler_test.cpp
@@ -95,6 +95,13 @@ public:
         return database->history(service_id, start, end);
     }
 
+    mw::E<std::vector<HourlyStatusRecord>> hourlyHistory(
+        const std::string& service_id, std::int64_t start,
+        std::int64_t end) override
+    {
+        return database->hourlyHistory(service_id, start, end);
+    }
+
     bool waitForRecords(const std::string& service_id, std::size_t count,
                         std::chrono::milliseconds timeout = 1s)
     {