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)
{