BareGit

Add latency plot

Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Sep 20 15:31:43 2026 -0700
Commit: 21299ceead04b121d98b9d17eab3a6723515ea4c

Changes

diff --git a/src/app.cpp b/src/app.cpp
index 8b18855..eafd36f 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -103,11 +103,38 @@ std::int64_t startOfHour(std::int64_t timestamp)
         ? remainder + SECONDS_PER_HOUR : remainder);
 }
 
+double latencyPercentage(std::int64_t duration,
+                         double maximum_duration)
+{
+    if(maximum_duration <= 0)
+    {
+        return 0;
+    }
+    return static_cast<double>(duration) * 100 / maximum_duration;
+}
+
+double latencyPercentage(double duration, double maximum_duration)
+{
+    if(maximum_duration <= 0)
+    {
+        return 0;
+    }
+    return duration * 100 / maximum_duration;
+}
+
 nlohmann::json makeHistoryData(
     const std::vector<HourlyStatusRecord>& records,
     std::int64_t first_hour)
 {
     nlohmann::json history = nlohmann::json::array();
+    double maximum_duration = 0;
+    for(const auto& record : records)
+    {
+        if(record.max_duration_microsecond > maximum_duration)
+        {
+            maximum_duration = record.max_duration_microsecond;
+        }
+    }
     auto record = records.begin();
     for(std::int64_t index = 0; index < HISTORY_HOUR_COUNT; ++index)
     {
@@ -120,10 +147,35 @@ nlohmann::json makeHistoryData(
                 record->hour_start == hour_start
             ? statusFromProbe(record->status) : UiStatus::NA;
         const auto presentation = present(status);
-        history.push_back({
+        nlohmann::json history_data = {
             {"status_class", presentation.class_name},
-            {"status_label", presentation.label}
-        });
+            {"status_label", presentation.label},
+            {"has_latency", false},
+            {"latency_min", 0},
+            {"latency_max", 0},
+            {"latency_average", 0.0},
+            {"latency_min_percent", 0.0},
+            {"latency_max_percent", 0.0},
+            {"latency_average_percent", 0.0}
+        };
+        if(record != records.end() && record->hour_start == hour_start &&
+           record->sample_count > 0)
+        {
+            history_data["has_latency"] = true;
+            history_data["latency_min"] =
+                record->min_duration_microsecond;
+            history_data["latency_max"] =
+                record->max_duration_microsecond;
+            history_data["latency_average"] =
+                record->average_duration_microsecond;
+            history_data["latency_min_percent"] = latencyPercentage(
+                record->min_duration_microsecond, maximum_duration);
+            history_data["latency_max_percent"] = latencyPercentage(
+                record->max_duration_microsecond, maximum_duration);
+            history_data["latency_average_percent"] = latencyPercentage(
+                record->average_duration_microsecond, maximum_duration);
+        }
+        history.push_back(std::move(history_data));
     }
     return history;
 }
diff --git a/src/data_source_interface.h b/src/data_source_interface.h
index a070865..48c6266 100644
--- a/src/data_source_interface.h
+++ b/src/data_source_interface.h
@@ -36,6 +36,12 @@ struct HourlyStatusRecord
     ProbeStatus status;
     /// Number of probe results represented by this aggregate.
     std::int64_t sample_count;
+    /// Shortest probe duration observed during the hour.
+    std::int64_t min_duration_microsecond;
+    /// Longest probe duration observed during the hour.
+    std::int64_t max_duration_microsecond;
+    /// Mean probe duration during the hour, which may be fractional.
+    double average_duration_microsecond;
 
     /// Compare all aggregate fields.
     bool operator==(const HourlyStatusRecord&) const = default;
diff --git a/src/data_source_sqlite.cpp b/src/data_source_sqlite.cpp
index 154d497..29fca8c 100644
--- a/src/data_source_sqlite.cpp
+++ b/src/data_source_sqlite.cpp
@@ -6,6 +6,7 @@ namespace
 {
 
 constexpr std::int64_t SECONDS_PER_HOUR = 60 * 60;
+constexpr int HOURLY_STATUS_SCHEMA_VERSION = 2;
 
 std::int64_t startOfHour(std::int64_t timestamp)
 {
@@ -57,17 +58,51 @@ mw::E<std::unique_ptr<DataSourceSqlite>> DataSourceSqlite::open(
             hour_start INTEGER NOT NULL,
             status INTEGER NOT NULL CHECK(status BETWEEN 0 AND 2),
             sample_count INTEGER NOT NULL CHECK(sample_count > 0),
+            min_duration_microsecond INTEGER NOT NULL
+                CHECK(min_duration_microsecond >= 0),
+            max_duration_microsecond INTEGER NOT NULL
+                CHECK(max_duration_microsecond >= 0),
+            average_duration_microsecond REAL NOT NULL
+                CHECK(average_duration_microsecond >= 0),
             PRIMARY KEY(service_id, hour_start)
         )
     )"));
     ASSIGN_OR_RETURN(auto schema_version,
         database->evalToValue<int>("PRAGMA user_version;"));
-    if(schema_version == 0)
+    if(schema_version < HOURLY_STATUS_SCHEMA_VERSION)
     {
         DO_OR_RETURN(database->execute("BEGIN IMMEDIATE;"));
+        auto migration_table = database->execute(R"(
+            DROP TABLE IF EXISTS hourly_status_migration;
+        )");
+        if(!migration_table)
+        {
+            return transactionError(*database, migration_table.error());
+        }
+        migration_table = database->execute(R"(
+            CREATE TABLE hourly_status_migration (
+                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),
+                min_duration_microsecond INTEGER NOT NULL
+                    CHECK(min_duration_microsecond >= 0),
+                max_duration_microsecond INTEGER NOT NULL
+                    CHECK(max_duration_microsecond >= 0),
+                average_duration_microsecond REAL NOT NULL
+                    CHECK(average_duration_microsecond >= 0),
+                PRIMARY KEY(service_id, hour_start)
+            )
+        )");
+        if(!migration_table)
+        {
+            return transactionError(*database, migration_table.error());
+        }
         auto backfill = database->execute(R"(
-            INSERT INTO hourly_status
-                (service_id, hour_start, status, sample_count)
+            INSERT INTO hourly_status_migration
+                (service_id, hour_start, status, sample_count,
+                 min_duration_microsecond, max_duration_microsecond,
+                 average_duration_microsecond)
             SELECT service_id,
                    timestamp - ((timestamp % 3600 + 3600) % 3600),
                    CASE
@@ -77,20 +112,32 @@ mw::E<std::unique_ptr<DataSourceSqlite>> DataSourceSqlite::open(
                            THEN 2
                        ELSE 0
                    END,
-                   COUNT(*)
+                   COUNT(*),
+                   MIN(duration_microsecond),
+                   MAX(duration_microsecond),
+                   AVG(duration_microsecond)
             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;");
+        auto drop_table = database->execute("DROP TABLE hourly_status;");
+        if(!drop_table)
+        {
+            return transactionError(*database, drop_table.error());
+        }
+        auto rename_table = database->execute(
+            "ALTER TABLE hourly_status_migration RENAME TO hourly_status;");
+        if(!rename_table)
+        {
+            return transactionError(*database, rename_table.error());
+        }
+        auto set_version = database->execute(
+            "PRAGMA user_version = 2;");
         if(!set_version)
         {
             return transactionError(*database, set_version.error());
@@ -139,22 +186,37 @@ mw::E<void> DataSourceSqlite::save(const StatusRecord& record)
 
     auto aggregate = database->statementFromStr(R"(
         INSERT INTO hourly_status
-            (service_id, hour_start, status, sample_count)
-        VALUES (?, ?, ?, 1)
+            (service_id, hour_start, status, sample_count,
+             min_duration_microsecond, max_duration_microsecond,
+             average_duration_microsecond)
+        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
+            sample_count = hourly_status.sample_count + 1,
+            min_duration_microsecond = MIN(
+                hourly_status.min_duration_microsecond,
+                excluded.min_duration_microsecond),
+            max_duration_microsecond = MAX(
+                hourly_status.max_duration_microsecond,
+                excluded.max_duration_microsecond),
+            average_duration_microsecond =
+                (hourly_status.average_duration_microsecond *
+                    hourly_status.sample_count +
+                 excluded.average_duration_microsecond) /
+                (hourly_status.sample_count + 1)
     )");
     if(!aggregate)
     {
         return transactionError(*database, aggregate.error());
     }
     auto aggregate_bound = aggregate->bind(
-        record.service_id, startOfHour(record.timestamp), status);
+        record.service_id, startOfHour(record.timestamp), status,
+        record.duration_microsecond, record.duration_microsecond,
+        static_cast<double>(record.duration_microsecond));
     if(!aggregate_bound)
     {
         return transactionError(*database, aggregate_bound.error());
@@ -230,20 +292,26 @@ mw::E<std::vector<HourlyStatusRecord>> DataSourceSqlite::hourlyHistory(
             "Reversed hourly history bounds"));
     }
     ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
-        SELECT hour_start, status, sample_count FROM hourly_status
+        SELECT hour_start, status, sample_count,
+               min_duration_microsecond, max_duration_microsecond,
+               average_duration_microsecond
+        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>(
+        (database->eval<std::int64_t, int, std::int64_t,
+                        std::int64_t, std::int64_t, double>(
             std::move(statement))));
     std::vector<HourlyStatusRecord> records;
     records.reserve(rows.size());
-    for(const auto& [hour_start, status, sample_count] : rows)
+    for(const auto& [hour_start, status, sample_count, min_duration,
+                     max_duration, average_duration] : rows)
     {
         records.push_back({service_id, hour_start,
-                           static_cast<ProbeStatus>(status), sample_count});
+                           static_cast<ProbeStatus>(status), sample_count,
+                           min_duration, max_duration, average_duration});
     }
     return records;
 }
diff --git a/static/index.html b/static/index.html
index f0068ef..2d57b6b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -44,6 +44,30 @@
                     aria-label="{{ hour.status_label }}"></span>
               {% endfor %}
             </div>
+            <div class="latency-history" role="group"
+                 aria-label="Latency for last {{ service.history_hours }} hours"
+                 title="Hourly latency; oldest hour on the left">
+              {% for hour in service.history %}
+              {% if hour.has_latency %}
+              <span class="latency-cell" role="img"
+                    aria-label="Min {{ hour.latency_min }} us, average
+                    {{ hour.latency_average }} us, max
+                    {{ hour.latency_max }} us"
+                    title="Min {{ hour.latency_min }} us, average
+                    {{ hour.latency_average }} us, max
+                    {{ hour.latency_max }} us"
+                    style="--latency-min: {{ hour.latency_min_percent }}%;
+                           --latency-max: {{ hour.latency_max_percent }}%;
+                           --latency-average:
+                           {{ hour.latency_average_percent }}%;">
+                <span class="latency-range"></span>
+                <span class="latency-average"></span>
+              </span>
+              {% else %}
+              <span class="latency-cell" aria-hidden="true"></span>
+              {% endif %}
+              {% endfor %}
+            </div>
           </li>
           {% endfor %}
         </ul>
diff --git a/static/style.css b/static/style.css
index 92e9a3d..479edd4 100644
--- a/static/style.css
+++ b/static/style.css
@@ -107,7 +107,7 @@ h2
     border: 1px solid #57606f;
     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;
+    height: 7.5rem;
     text-align: start;
     box-sizing: content-box;
 }
@@ -151,8 +151,8 @@ h2
 {
     display: flex;
     gap: 1px;
-    height: 2px;
-    margin-top: 0.8rem;
+    height: var(--pip-size);
+    margin: 0.8rem 0;
 }
 
 .history-cell
@@ -162,6 +162,43 @@ h2
     height: var(--pip-size);
 }
 
+.latency-history
+{
+    display: flex;
+    gap: 1px;
+    height: 24px;
+    margin-top: 0.4rem;
+}
+
+.latency-cell
+{
+    display: block;
+    flex: 0 0 var(--pip-size);
+    height: 100%;
+    position: relative;
+}
+
+.latency-range
+{
+    background: #57606f;
+    bottom: var(--latency-min);
+    height: calc(var(--latency-max) - var(--latency-min));
+    left: calc(50% - 1px);
+    position: absolute;
+    width: calc(0.5 * var(--pip-size));
+}
+
+.latency-average
+{
+    background: #f1f2f6;
+    bottom: var(--latency-average);
+    height: var(--pip-size);
+    left: 50%;
+    position: absolute;
+    transform: translate(-50%, 50%);
+    width: var(--pip-size);
+}
+
 .empty
 {
     color: #59616d;
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 42d1d49..c1d2633 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -163,16 +163,16 @@ TEST(App, RendersHourlyHistory)
     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,
+    ASSERT_TRUE(source->save({"blog", current_hour + 10, 1000,
                               ProbeStatus::GOOD}));
-    ASSERT_TRUE(source->save({"blog", current_hour + 20, 1200,
+    ASSERT_TRUE(source->save({"blog", current_hour + 20, 2000,
                               ProbeStatus::OTHER}));
-    ASSERT_TRUE(source->save({"blog", current_hour + 30, 1200,
+    ASSERT_TRUE(source->save({"blog", current_hour + 30, 3000,
                               ProbeStatus::BAD}));
     const auto previous_hour = current_hour - 60 * 60;
-    ASSERT_TRUE(source->save({"blog", previous_hour + 10, 1200,
+    ASSERT_TRUE(source->save({"blog", previous_hour + 10, 4000,
                               ProbeStatus::GOOD}));
-    ASSERT_TRUE(source->save({"blog", previous_hour + 20, 1200,
+    ASSERT_TRUE(source->save({"blog", previous_hour + 20, 5000,
                               ProbeStatus::OTHER}));
     TestApp app(serviceConfiguration(), *source);
     const int port = app.listen();
@@ -181,9 +181,11 @@ TEST(App, RendersHourlyHistory)
     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);
+    EXPECT_NE(response->body.find("led-bad"), std::string::npos);
+    EXPECT_NE(response->body.find("led-other"), std::string::npos);
+    EXPECT_NE(response->body.find("led-na"), std::string::npos);
+    EXPECT_NE(response->body.find("latency-range"), std::string::npos);
+    EXPECT_NE(response->body.find("latency-average"), std::string::npos);
 }
 
 TEST(App, RendersMissingStatusAsNa)
diff --git a/tests/data_source_sqlite_test.cpp b/tests/data_source_sqlite_test.cpp
index c05051f..80ecb4d 100644
--- a/tests/data_source_sqlite_test.cpp
+++ b/tests/data_source_sqlite_test.cpp
@@ -36,17 +36,26 @@ 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.save({"aggregate", 0, 10, ProbeStatus::GOOD}));
+    ASSERT_TRUE(source.save({"aggregate", 1, 20, ProbeStatus::OTHER}));
+    ASSERT_TRUE(source.save({"aggregate", 3599, 25, ProbeStatus::BAD}));
+    ASSERT_TRUE(source.save({"aggregate", 3600, 40, ProbeStatus::GOOD}));
+    ASSERT_TRUE(source.save({"aggregate", 7200, 50, ProbeStatus::OTHER}));
+    const auto aggregates = source.hourlyHistory(
+        "aggregate", 0, 10800).value();
+    ASSERT_EQ(aggregates.size(), 3);
+    EXPECT_EQ(aggregates[0].service_id, "aggregate");
+    EXPECT_EQ(aggregates[0].hour_start, 0);
+    EXPECT_EQ(aggregates[0].status, ProbeStatus::BAD);
+    EXPECT_EQ(aggregates[0].sample_count, 3);
+    EXPECT_EQ(aggregates[0].min_duration_microsecond, 10);
+    EXPECT_EQ(aggregates[0].max_duration_microsecond, 25);
+    EXPECT_NEAR(aggregates[0].average_duration_microsecond, 55.0 / 3,
+                1e-9);
+    EXPECT_EQ((aggregates[1]), (HourlyStatusRecord{
+        "aggregate", 3600, ProbeStatus::GOOD, 1, 40, 40, 40.0}));
+    EXPECT_EQ((aggregates[2]), (HourlyStatusRecord{
+        "aggregate", 7200, ProbeStatus::OTHER, 1, 50, 50, 50.0}));
     ASSERT_TRUE(source.history(service_id, 0, 0).value().empty())
         << "Empty range";
     ASSERT_TRUE(!source.history(service_id, 1, 0)) << "Reject reversed bounds";
@@ -162,28 +171,79 @@ TEST_F(DataSourceSqliteFile, BackfillsHourlyHistory)
             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)
+                ('legacy', 1, 10, 0),
+                ('legacy', 2, 20, 2),
+                ('legacy', 3599, 25, 1),
+                ('legacy', 3600, 40, 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}
-              }));
+    const auto aggregates = (*opened)->hourlyHistory(
+        "legacy", 0, 7200).value();
+    ASSERT_EQ(aggregates.size(), 2);
+    EXPECT_EQ((aggregates[0]), (HourlyStatusRecord{
+        "legacy", 0, ProbeStatus::BAD, 3, 10, 25, 55.0 / 3}));
+    EXPECT_EQ((aggregates[1]), (HourlyStatusRecord{
+        "legacy", 3600, ProbeStatus::GOOD, 1, 40, 40, 40.0}));
 
     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}
-              }));
+              aggregates);
+}
+
+TEST_F(DataSourceSqliteFile, MigratesHourlyStatusV1)
+{
+    {
+        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, 10, 0),
+                ('legacy', 2, 20, 2),
+                ('legacy', 3599, 25, 1)
+        )"));
+        ASSERT_TRUE((*database)->execute(R"(
+            CREATE TABLE hourly_status (
+                service_id TEXT NOT NULL,
+                hour_start INTEGER NOT NULL,
+                status INTEGER NOT NULL,
+                sample_count INTEGER NOT NULL,
+                PRIMARY KEY(service_id, hour_start)
+            )
+        )"));
+        ASSERT_TRUE((*database)->execute(R"(
+            INSERT INTO hourly_status
+                (service_id, hour_start, status, sample_count)
+            VALUES ('legacy', 0, 1, 3)
+        )"));
+        ASSERT_TRUE((*database)->execute("PRAGMA user_version = 1;"));
+    }
+
+    auto opened = DataSourceSqlite::open(path);
+    ASSERT_TRUE(opened);
+    const auto aggregates = (*opened)->hourlyHistory(
+        "legacy", 0, 3600).value();
+    ASSERT_EQ(aggregates.size(), 1);
+    EXPECT_EQ(aggregates[0].status, ProbeStatus::BAD);
+    EXPECT_EQ(aggregates[0].sample_count, 3);
+    EXPECT_EQ(aggregates[0].min_duration_microsecond, 10);
+    EXPECT_EQ(aggregates[0].max_duration_microsecond, 25);
+    EXPECT_NEAR(aggregates[0].average_duration_microsecond, 55.0 / 3,
+                1e-9);
 }
 
 }