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);
}
}