Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 743491f..c87724d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,16 +1,38 @@
cmake_minimum_required(VERSION 3.24)
+if(POLICY CMP0169)
+ cmake_policy(SET CMP0169 OLD)
+endif()
+
project(status_tracker LANGUAGES CXX)
include(FetchContent)
set(LIBMW_BUILD_SQLITE ON CACHE BOOL "Build libmw SQLite support")
set(LIBMW_BUILD_URL ON)
set(LIBMW_BUILD_HTTP_SERVER ON)
+set(RYML_DEFAULT_CALLBACK_USES_EXCEPTIONS ON CACHE BOOL
+ "Return YAML parse failures as exceptions" FORCE)
+FetchContent_Declare(json
+ GIT_REPOSITORY https://github.com/nlohmann/json.git
+ GIT_TAG HEAD
+)
+FetchContent_Declare(inja
+ GIT_REPOSITORY https://github.com/pantor/inja.git
+ GIT_TAG main
+)
+FetchContent_Declare(ryml
+ GIT_REPOSITORY https://github.com/biojppm/rapidyaml.git
+ GIT_TAG master
+)
FetchContent_Declare(libmw
GIT_REPOSITORY https://github.com/MetroWind/libmw.git
GIT_TAG HEAD
)
-FetchContent_MakeAvailable(libmw)
+FetchContent_MakeAvailable(json libmw ryml)
+FetchContent_GetProperties(inja)
+if(NOT inja_POPULATED)
+ FetchContent_Populate(inja)
+endif()
set(CARES_SHARED OFF CACHE BOOL "Build shared c-ares library")
set(CARES_STATIC ON CACHE BOOL "Build static c-ares library")
set(CARES_BUILD_TOOLS OFF CACHE BOOL "Build c-ares tools")
@@ -34,6 +56,7 @@ include(cmake/embed_assets.cmake)
set(SOURCE_FILES
src/app.cpp
+ src/configuration.cpp
"${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp"
src/data_source_sqlite.cpp
src/probe.cpp
@@ -46,6 +69,7 @@ set(LIBS
mw::http-server
mw::sqlite
mw::url
+ ryml::ryml
CURL::libcurl
c-ares::cares
SQLite3::SQLite3
@@ -54,6 +78,8 @@ set(LIBS
set(INCLUDES
${CMAKE_CURRENT_SOURCE_DIR}/src
${libmw_SOURCE_DIR}/includes
+ ${json_SOURCE_DIR}/single_include
+ ${inja_SOURCE_DIR}/single_include
)
add_executable(status_tracker ${SOURCE_FILES} src/main.cpp)
@@ -72,6 +98,7 @@ if(BUILD_TESTING)
include(GoogleTest)
set(TEST_FILES
+ tests/configuration_test.cpp
src/fake_probe.cpp
tests/app_test.cpp
tests/data_source_sqlite_test.cpp
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
index 1155ce6..7b601c4 100644
--- a/cmake/embed_assets.cmake
+++ b/cmake/embed_assets.cmake
@@ -7,7 +7,8 @@ foreach(ASSET IN LISTS STATIC_FILES)
file(READ "${ASSET}" ASSET_HEX HEX)
string(LENGTH "${ASSET_HEX}" ASSET_SIZE)
math(EXPR ASSET_SIZE "${ASSET_SIZE} / 2")
- string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," ASSET_BYTES "${ASSET_HEX}")
+ string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1,"
+ ASSET_BYTES "${ASSET_HEX}")
string(APPEND EMBEDDED_SOURCE
"const unsigned char ASSET_${ASSET_INDEX}[] = {${ASSET_BYTES}0};\n")
file(RELATIVE_PATH ASSET_NAME "${CMAKE_CURRENT_SOURCE_DIR}/static"
@@ -30,12 +31,15 @@ foreach(ASSET IN LISTS STATIC_FILES)
set(ASSET_PATH "/static/${ASSET_NAME}")
endif()
string(APPEND ASSET_ENTRIES
- " {\"${ASSET_PATH}\", \"${ASSET_TYPE}\", {reinterpret_cast<const char*>(ASSET_${ASSET_INDEX}), ${ASSET_SIZE}}},\n")
+ " {\"${ASSET_PATH}\", \"${ASSET_TYPE}\", "
+ "{reinterpret_cast<const char*>(ASSET_${ASSET_INDEX}), "
+ "${ASSET_SIZE}}},\n")
math(EXPR ASSET_INDEX "${ASSET_INDEX} + 1")
endforeach()
string(APPEND EMBEDDED_SOURCE
"const EmbeddedAsset ASSETS[] = {\n${ASSET_ENTRIES}};\n}\n\n"
- "std::span<const EmbeddedAsset> embeddedAssets()\n{\n return ASSETS;\n}\n")
+ "std::span<const EmbeddedAsset> embeddedAssets()\n{\n"
+ " return ASSETS;\n}\n")
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
"${EMBEDDED_SOURCE}")
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..241ee35
--- /dev/null
+++ b/config.example.yaml
@@ -0,0 +1,60 @@
+# Copy this file to a local path and run:
+# status_tracker ./config.yaml
+
+worker_count: 4
+database_path: status-tracker.sqlite
+
+# Groups are a sequence. Service IDs are mapping keys and must be unique.
+groups:
+ - name: Public
+ services:
+ blog:
+ name: Blog
+ description: Personal website
+ url: https://blog.mws.rocks/
+ interval:
+ value: 1
+ unit: minute
+ endpoint:
+ protocol: HTTP
+ # If omitted, the service-level URL is used for HTTP probes.
+ # url: https://blog.example/
+ timeout_second: 5
+
+ - name: Infrastructure
+ services:
+ vm_host:
+ name: VM host
+ description: Virtualization host
+ interval:
+ value: 1
+ unit: minute
+ endpoint:
+ protocol: ICMP
+ host: 10.10.10.20
+ timeout_second: 5
+
+ ssh:
+ name: SSH
+ description: SSH service on the VM host
+ interval:
+ value: 1
+ unit: minute
+ endpoint:
+ protocol: TCP
+ host: 10.10.10.20
+ port: 22
+ timeout_second: 5
+
+ dns:
+ name: DNS
+ description: UDP DNS endpoint
+ interval:
+ value: 1
+ unit: minute
+ endpoint:
+ protocol: UDP
+ host: 10.10.10.53
+ port: 53
+ payload: status-tracker
+ timeout_second: 5
diff --git a/src/app.cpp b/src/app.cpp
index f54e849..853b4a0 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -1,17 +1,185 @@
#include "app.h"
+#include <chrono>
#include <exception>
+#include <functional>
#include <string>
+#include <string_view>
#include "embedded_assets.h"
-App::App(const ListenAddress& listen) : mw::HTTPServer(listen)
+#include <inja/inja.hpp>
+#include <nlohmann/json.hpp>
+
+namespace
+{
+
+enum class UiStatus
+{
+ GOOD,
+ BAD,
+ OTHER,
+ NA
+};
+
+struct StatusPresentation
+{
+ std::string class_name;
+ std::string label;
+};
+
+StatusPresentation present(UiStatus status)
+{
+ switch(status)
+ {
+ case UiStatus::GOOD:
+ return {"good", "Good"};
+ case UiStatus::BAD:
+ return {"bad", "Bad"};
+ case UiStatus::OTHER:
+ return {"other", "Other"};
+ case UiStatus::NA:
+ return {"na", "N/A"};
+ }
+ std::terminate();
+}
+
+std::int64_t nowUnixSeconds()
+{
+ return std::chrono::duration_cast<std::chrono::seconds>(
+ std::chrono::system_clock::now().time_since_epoch()).count();
+}
+
+bool isStale(const ServiceConfig& config, std::int64_t timestamp,
+ std::int64_t now)
+{
+ if(timestamp >= now)
+ {
+ return false;
+ }
+ auto interval = std::chrono::duration_cast<std::chrono::seconds>(
+ config.interval).count();
+ if(interval <= 0)
+ {
+ interval = 1;
+ }
+ const auto age = now - timestamp;
+ return age > interval && age - interval > interval;
+}
+
+UiStatus deriveStatus(const ServiceConfig& config,
+ const std::optional<StatusRecord>& record,
+ std::int64_t now)
+{
+ if(!record || isStale(config, record->timestamp, now))
+ {
+ return UiStatus::NA;
+ }
+ switch(record->status)
+ {
+ case ProbeStatus::GOOD:
+ return UiStatus::GOOD;
+ case ProbeStatus::BAD:
+ return UiStatus::BAD;
+ case ProbeStatus::OTHER:
+ return UiStatus::OTHER;
+ }
+ return UiStatus::OTHER;
+}
+
+const EmbeddedAsset* findAsset(std::string_view path)
+{
+ for(const auto& asset : embeddedAssets())
+ {
+ if(asset.path == path)
+ {
+ return &asset;
+ }
+ }
+ return nullptr;
+}
+
+mw::E<nlohmann::json> makePageData(const Configuration& configuration,
+ DataSourceInterface& data_source)
+{
+ const auto now = nowUnixSeconds();
+ nlohmann::json data = {
+ {"groups", nlohmann::json::array()},
+ {"no_groups", configuration.groups.empty()}
+ };
+ for(const auto& group : configuration.groups)
+ {
+ nlohmann::json group_data = {
+ {"name", group.name},
+ {"services", nlohmann::json::array()},
+ {"no_services", group.services.empty()}
+ };
+ for(const auto& config : group.services)
+ {
+ auto latest = data_source.latest(config.id);
+ if(!latest)
+ {
+ return std::unexpected(latest.error());
+ }
+ const auto& record = *latest;
+ const auto status = deriveStatus(config, record, now);
+ const auto presentation = present(status);
+ nlohmann::json service_data = {
+ {"id", config.id},
+ {"name", config.name},
+ {"description", config.description},
+ {"status_class", presentation.class_name},
+ {"status_label", presentation.label},
+ {"has_url", config.url.has_value() && !config.url->empty()},
+ {"url", config.url.value_or("")},
+ {"has_result", record.has_value()},
+ {"timestamp", record ? record->timestamp : 0},
+ {"duration_microsecond", record
+ ? record->duration_microsecond : 0}
+ };
+ group_data["services"].push_back(std::move(service_data));
+ }
+ data["groups"].push_back(std::move(group_data));
+ }
+ return data;
+}
+
+}
+
+App::App(const ListenAddress& listen, Configuration configuration,
+ DataSourceInterface& data_source)
+ : mw::HTTPServer(listen), configuration(std::move(configuration)),
+ data_source(data_source)
{}
void App::setup()
{
server.set_exception_handler(unexpectedException);
- server.Get("/.*", serveStatic);
+ server.Get("/", std::bind_front(&App::serveIndex, this));
+ server.Get("/static/.*", serveStatic);
+}
+
+void App::serveIndex([[maybe_unused]] const Request& request,
+ Response& response) const
+{
+ auto data = makePageData(configuration, data_source);
+ if(!data)
+ {
+ response.status = 500;
+ response.set_content("Failed to read service status: " +
+ data.error().msg() + "\n",
+ "text/plain; charset=utf-8");
+ return;
+ }
+ const auto* index = findAsset("/");
+ if(index == nullptr)
+ {
+ std::terminate();
+ }
+ inja::Environment environment;
+ environment.set_html_autoescape(true);
+ const auto html = environment.render(std::string(index->content), *data);
+ response.set_content(html, "text/html; charset=utf-8");
}
void App::serveStatic(const Request& request, Response& response)
diff --git a/src/app.h b/src/app.h
index 9b50cae..11c000d 100644
--- a/src/app.h
+++ b/src/app.h
@@ -2,22 +2,36 @@
#include <exception>
+#include "configuration.h"
+#include "data_source_interface.h"
+
#include <mw/http_server.hpp>
/// HTTP application serving the status tracker UI.
class App : public mw::HTTPServer
{
public:
- /// Configure the listening address. Call start() to begin serving.
- explicit App(const ListenAddress& listen);
+ /// Configure the listener, service definitions, and status storage.
+ /// The data source must outlive the application.
+ App(const ListenAddress& listen, Configuration configuration,
+ DataSourceInterface& data_source);
protected:
/// Register HTTP routes before the server starts.
void setup() override;
private:
+ /// Render the grouped service status page.
+ void serveIndex(const Request& request, Response& response) const;
+
+ /// Serve one embedded static resource.
static void serveStatic(const Request& request, Response& response);
+
+ /// Convert unexpected request exceptions into process termination.
static void unexpectedException(const Request& request,
Response& response,
std::exception_ptr exception) noexcept;
+
+ Configuration configuration;
+ DataSourceInterface& data_source;
};
diff --git a/src/configuration.cpp b/src/configuration.cpp
new file mode 100644
index 0000000..fd82131
--- /dev/null
+++ b/src/configuration.cpp
@@ -0,0 +1,403 @@
+#include "configuration.h"
+
+#include <algorithm>
+#include <charconv>
+#include <cctype>
+#include <exception>
+#include <fstream>
+#include <iterator>
+#include <limits>
+#include <string_view>
+#include <unordered_set>
+#include <utility>
+
+#include <ryml.hpp>
+#include <ryml_std.hpp>
+#include <mw/utils.hpp>
+
+namespace
+{
+
+using Node = ryml::ConstNodeRef;
+
+Node child(Node node, std::string_view key)
+{
+ return node.find_child(c4::csubstr(key.data(), key.size()));
+}
+
+mw::Error configError(std::string_view context, std::string_view message)
+{
+ return mw::runtimeError(std::string(context) + ": " +
+ std::string(message));
+}
+
+mw::E<std::vector<char>> readFile(const std::filesystem::path& path)
+{
+ std::ifstream file(path, std::ios::binary);
+ if(!file)
+ {
+ return std::unexpected(configError(
+ path.string(), "failed to open configuration file"));
+ }
+ std::vector<char> content(
+ (std::istreambuf_iterator<char>(file)),
+ std::istreambuf_iterator<char>());
+ if(file.bad())
+ {
+ return std::unexpected(configError(
+ path.string(), "failed to read configuration file"));
+ }
+ return content;
+}
+
+mw::E<std::string> requiredString(Node node, std::string_view key,
+ std::string_view context)
+{
+ auto value = child(node, key);
+ if(!value.readable() || !value.has_val())
+ {
+ return std::unexpected(configError(
+ context, std::string("missing string field ") + std::string(key)));
+ }
+ std::string result;
+ value.load(&result);
+ return result;
+}
+
+mw::E<std::optional<std::string>> optionalString(
+ Node node, std::string_view key, std::string_view context)
+{
+ auto value = child(node, key);
+ if(!value.readable())
+ {
+ return std::optional<std::string>{};
+ }
+ if(!value.has_val())
+ {
+ return std::unexpected(configError(
+ context, std::string("invalid string field ") + std::string(key)));
+ }
+ std::string result;
+ value.load(&result);
+ return std::optional<std::string>{std::move(result)};
+}
+
+template<typename Integer>
+mw::E<Integer> requiredInteger(Node node, std::string_view key,
+ std::string_view context)
+{
+ auto value = child(node, key);
+ if(!value.readable() || !value.has_val())
+ {
+ return std::unexpected(configError(
+ context, std::string("missing integer field ") + std::string(key)));
+ }
+ const auto text = value.val();
+ Integer result{};
+ const auto parsed = std::from_chars(text.begin(), text.end(), result);
+ if(parsed.ec != std::errc{} || parsed.ptr != text.end())
+ {
+ return std::unexpected(configError(
+ context, std::string("invalid integer field ") + std::string(key)));
+ }
+ return result;
+}
+
+template<typename Integer>
+mw::E<std::optional<Integer>> optionalInteger(
+ Node node, std::string_view key, std::string_view context)
+{
+ auto value = child(node, key);
+ if(!value.readable())
+ {
+ return std::optional<Integer>{};
+ }
+ if(!value.has_val())
+ {
+ return std::unexpected(configError(
+ context, std::string("invalid integer field ") + std::string(key)));
+ }
+ const auto text = value.val();
+ Integer result{};
+ const auto parsed = std::from_chars(text.begin(), text.end(), result);
+ if(parsed.ec != std::errc{} || parsed.ptr != text.end())
+ {
+ return std::unexpected(configError(
+ context, std::string("invalid integer field ") + std::string(key)));
+ }
+ return std::optional<Integer>{result};
+}
+
+std::string lower(std::string value)
+{
+ std::transform(value.begin(), value.end(), value.begin(),
+ [](unsigned char character) {
+ return static_cast<char>(std::tolower(character));
+ });
+ return value;
+}
+
+mw::E<std::chrono::steady_clock::duration> parseInterval(
+ Node service, std::string_view context)
+{
+ auto interval = child(service, "interval");
+ if(!interval.readable() || !interval.is_map())
+ {
+ return std::unexpected(configError(
+ context, "missing interval map"));
+ }
+ ASSIGN_OR_RETURN(const auto value,
+ requiredInteger<std::int64_t>(interval, "value", context));
+ ASSIGN_OR_RETURN(auto unit, requiredString(interval, "unit", context));
+ if(value <= 0)
+ {
+ return std::unexpected(configError(
+ context, "interval value must be positive"));
+ }
+ const auto normalized_unit = lower(std::move(unit));
+ std::int64_t multiplier = 0;
+ if(normalized_unit == "second" || normalized_unit == "seconds")
+ {
+ multiplier = 1;
+ }
+ else if(normalized_unit == "minute" || normalized_unit == "minutes")
+ {
+ multiplier = 60;
+ }
+ else if(normalized_unit == "hour" || normalized_unit == "hours")
+ {
+ multiplier = 60 * 60;
+ }
+ else if(normalized_unit == "day" || normalized_unit == "days")
+ {
+ multiplier = 24 * 60 * 60;
+ }
+ else
+ {
+ return std::unexpected(configError(
+ context, "interval unit must be second, minute, hour, or day"));
+ }
+ const auto max_seconds = std::chrono::duration_cast<std::chrono::seconds>(
+ std::chrono::steady_clock::duration::max()).count();
+ if(value > max_seconds / multiplier)
+ {
+ return std::unexpected(configError(context, "interval is too large"));
+ }
+ return std::chrono::duration_cast<std::chrono::steady_clock::duration>(
+ std::chrono::seconds(value * multiplier));
+}
+
+mw::E<EndpointConfig> parseEndpoint(Node endpoint, std::string_view context)
+{
+ ASSIGN_OR_RETURN(auto protocol,
+ requiredString(endpoint, "protocol", context));
+ protocol = lower(std::move(protocol));
+ if(protocol == "http" || protocol == "https")
+ {
+ ASSIGN_OR_RETURN(auto url,
+ optionalString(endpoint, "url", context));
+ return EndpointConfig{HttpEndpoint{url.value_or("")}};
+ }
+ if(protocol == "tcp")
+ {
+ ASSIGN_OR_RETURN(auto host,
+ requiredString(endpoint, "host", context));
+ ASSIGN_OR_RETURN(const auto port,
+ requiredInteger<std::uint64_t>(
+ endpoint, "port", context));
+ if(port == 0 || port > std::numeric_limits<std::uint16_t>::max())
+ {
+ return std::unexpected(configError(
+ context, "TCP port must be between 1 and 65535"));
+ }
+ return EndpointConfig{TcpEndpoint{
+ std::move(host), static_cast<std::uint16_t>(port)}};
+ }
+ if(protocol == "udp")
+ {
+ ASSIGN_OR_RETURN(auto host,
+ requiredString(endpoint, "host", context));
+ ASSIGN_OR_RETURN(const auto port,
+ requiredInteger<std::uint64_t>(
+ endpoint, "port", context));
+ if(port == 0 || port > std::numeric_limits<std::uint16_t>::max())
+ {
+ return std::unexpected(configError(
+ context, "UDP port must be between 1 and 65535"));
+ }
+ ASSIGN_OR_RETURN(auto payload,
+ optionalString(endpoint, "payload", context));
+ return EndpointConfig{UdpEndpoint{
+ std::move(host), static_cast<std::uint16_t>(port),
+ payload.value_or("")}};
+ }
+ if(protocol == "icmp")
+ {
+ ASSIGN_OR_RETURN(auto host,
+ requiredString(endpoint, "host", context));
+ return EndpointConfig{IcmpEndpoint{std::move(host)}};
+ }
+ return std::unexpected(configError(
+ context, "protocol must be HTTP, TCP, UDP, or ICMP"));
+}
+
+mw::E<ServiceConfig> parseService(Node service, std::string id,
+ std::string_view context)
+{
+ if(!service.is_map())
+ {
+ return std::unexpected(configError(context, "service must be a map"));
+ }
+ ServiceConfig result;
+ result.id = std::move(id);
+ ASSIGN_OR_RETURN(result.name, requiredString(service, "name", context));
+ ASSIGN_OR_RETURN(result.description,
+ requiredString(service, "description", context));
+ ASSIGN_OR_RETURN(result.url, optionalString(service, "url", context));
+ auto endpoint = child(service, "endpoint");
+ if(!endpoint.readable() || !endpoint.is_map())
+ {
+ return std::unexpected(configError(
+ context, "missing endpoint map"));
+ }
+ ASSIGN_OR_RETURN(result.endpoint,
+ parseEndpoint(endpoint, context));
+ auto timeout = optionalInteger<std::int64_t>(
+ service, "timeout_second", context);
+ if(!timeout)
+ {
+ return std::unexpected(timeout.error());
+ }
+ if(!*timeout)
+ {
+ timeout = optionalInteger<std::int64_t>(
+ endpoint, "timeout_second", context);
+ if(!timeout)
+ {
+ return std::unexpected(timeout.error());
+ }
+ }
+ const auto timeout_value = timeout->value_or(5);
+ if(timeout_value <= 0)
+ {
+ return std::unexpected(configError(
+ context, "timeout_second must be positive"));
+ }
+ result.timeout = std::chrono::seconds(timeout_value);
+ ASSIGN_OR_RETURN(result.interval, parseInterval(service, context));
+ return result;
+}
+
+mw::E<Configuration> parseTree(ryml::Tree& tree)
+{
+ const auto root = tree.rootref();
+ if(!root.is_map())
+ {
+ return std::unexpected(configError(
+ "configuration", "root must be a map"));
+ }
+ Configuration result;
+ ASSIGN_OR_RETURN(const auto worker_count,
+ requiredInteger<std::uint64_t>(
+ root, "worker_count", "configuration"));
+ if(worker_count == 0 ||
+ worker_count > std::numeric_limits<std::size_t>::max())
+ {
+ return std::unexpected(configError(
+ "configuration", "worker_count must be positive"));
+ }
+ result.worker_count = static_cast<std::size_t>(worker_count);
+ ASSIGN_OR_RETURN(result.database_path,
+ requiredString(root, "database_path", "configuration"));
+ if(result.database_path.empty() ||
+ result.database_path.find('\0') != std::string::npos)
+ {
+ return std::unexpected(configError(
+ "configuration", "database_path must not be empty"));
+ }
+ auto groups = child(root, "groups");
+ if(!groups.readable() || !groups.is_seq())
+ {
+ return std::unexpected(configError(
+ "configuration", "groups must be a sequence"));
+ }
+ std::unordered_set<std::string> service_ids;
+ std::unordered_set<std::string> group_names;
+ std::size_t group_index = 0;
+ for(const auto& group : groups)
+ {
+ const auto group_location =
+ "groups[" + std::to_string(group_index) + "]";
+ ++group_index;
+ if(!group.is_map())
+ {
+ return std::unexpected(configError(
+ group_location, "group must be a map"));
+ }
+ ASSIGN_OR_RETURN(auto group_name,
+ requiredString(group, "name", group_location));
+ if(group_name.empty())
+ {
+ return std::unexpected(configError(
+ group_location, "group name must not be empty"));
+ }
+ if(!group_names.insert(group_name).second)
+ {
+ return std::unexpected(configError(
+ group_location, "group name must be unique"));
+ }
+ const auto group_context = group_location + "." + group_name;
+ ServiceGroupConfig group_config;
+ group_config.name = std::move(group_name);
+ auto services = child(group, "services");
+ if(!services.readable() || !services.is_map())
+ {
+ return std::unexpected(configError(
+ group_context, "services must be a map"));
+ }
+ for(const auto& service : services)
+ {
+ if(!service.has_key() || !service.is_map())
+ {
+ return std::unexpected(configError(
+ group_context, "service must be a map"));
+ }
+ const std::string service_id(
+ service.key().begin(), service.key().end());
+ if(service_id.empty())
+ {
+ return std::unexpected(configError(
+ group_context, "service id must not be empty"));
+ }
+ const auto context = group_context + "." + service_id;
+ ASSIGN_OR_RETURN(auto service_config,
+ parseService(service, service_id, context));
+ if(!service_ids.insert(service_config.id).second)
+ {
+ return std::unexpected(configError(
+ context, "service id must be unique"));
+ }
+ group_config.services.push_back(std::move(service_config));
+ }
+ result.groups.push_back(std::move(group_config));
+ }
+ return result;
+}
+
+}
+
+mw::E<Configuration> Configuration::fromYaml(
+ const std::filesystem::path& path)
+{
+ ASSIGN_OR_RETURN(auto buffer, readFile(path));
+ try
+ {
+ auto tree = ryml::parse_in_place(ryml::to_substr(buffer));
+ return parseTree(tree);
+ }
+ catch(const std::exception& error)
+ {
+ return std::unexpected(configError(
+ path.string(), std::string("invalid YAML: ") + error.what()));
+ }
+}
diff --git a/src/configuration.h b/src/configuration.h
index daa91b0..32a9dac 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -1,9 +1,12 @@
#pragma once
#include <cstddef>
+#include <filesystem>
#include <string>
#include <vector>
+#include <mw/error.hpp>
+
#include "service_config.h"
/// A group of services displayed together in the web UI.
@@ -24,4 +27,8 @@ struct Configuration
std::string database_path;
/// Service groups in configuration order.
std::vector<ServiceGroupConfig> groups;
+
+ /// Load and validate a YAML configuration file.
+ static mw::E<Configuration> fromYaml(
+ const std::filesystem::path& path);
};
diff --git a/src/main.cpp b/src/main.cpp
index 4e0ee58..d5e5965 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,11 +1,49 @@
+#include <filesystem>
#include <iostream>
+#include <string_view>
+#include <utility>
#include "app.h"
+#include "data_source_sqlite.h"
+
+namespace
+{
+
+void printUsage(std::ostream& output)
+{
+ output << "Usage: status_tracker FILE\n";
+}
+
+}
// Entry point for the status tracker service.
-int main()
+int main(int argc, char** argv)
{
- App app(mw::IPSocketInfo{"127.0.0.1", 8080});
+ if(argc == 2 && std::string_view(argv[1]) == "--help")
+ {
+ printUsage(std::cout);
+ return 0;
+ }
+ if(argc != 2)
+ {
+ printUsage(std::cerr);
+ return 2;
+ }
+ auto configuration = Configuration::fromYaml(
+ std::filesystem::path(argv[1]));
+ if(!configuration)
+ {
+ std::cerr << configuration.error().msg() << '\n';
+ return 1;
+ }
+ auto data_source = DataSourceSqlite::open(configuration->database_path);
+ if(!data_source)
+ {
+ std::cerr << data_source.error().msg() << '\n';
+ return 1;
+ }
+ App app(mw::IPSocketInfo{"127.0.0.1", 8080},
+ std::move(*configuration), **data_source);
auto started = app.start();
if(!started)
{
diff --git a/static/index.html b/static/index.html
index ef3045b..ea98088 100644
--- a/static/index.html
+++ b/static/index.html
@@ -9,7 +9,34 @@
<body>
<main>
<h1>Status Tracker</h1>
- <p>Service status will appear here.</p>
+ {% if no_groups %}
+ <p class="empty">No services are configured.</p>
+ {% endif %}
+ {% for group in groups %}
+ <section class="service-group">
+ <h2>{{ group.name }}</h2>
+ {% if group.no_services %}
+ <p class="empty">No services in this group.</p>
+ {% endif %}
+ <div class="service-list">
+ {% for service in group.services %}
+ <article class="service-card">
+ <div class="service-heading">
+ <span class="led led-{{ service.status_class }}"
+ title="{{ service.status_label }}"
+ aria-label="{{ service.status_label }}"></span>
+ <h3>{{ service.name }}</h3>
+ </div>
+ <p>{{ service.description }}</p>
+ {% if service.has_url %}
+ <a href="{{ service.url }}">{{ service.url }}</a>
+ {% endif %}
+ <p class="status-label">{{ service.status_label }}</p>
+ </article>
+ {% endfor %}
+ </div>
+ </section>
+ {% endfor %}
</main>
</body>
</html>
diff --git a/static/style.css b/static/style.css
index 6afb634..fa96a76 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2,6 +2,8 @@
{
font-family: system-ui, sans-serif;
color-scheme: light dark;
+ color: #202124;
+ background: #f5f6f8;
}
main
@@ -10,3 +12,82 @@ main
margin: 3rem auto;
padding: 0 1rem;
}
+
+h1
+{
+ margin-bottom: 2rem;
+}
+
+.service-group
+{
+ margin-bottom: 2rem;
+}
+
+.service-list
+{
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
+}
+
+.service-card
+{
+ background: white;
+ border: 1px solid #d9dce1;
+ border-radius: 0.5rem;
+ padding: 1rem;
+}
+
+.service-heading
+{
+ align-items: center;
+ display: flex;
+ gap: 0.6rem;
+}
+
+.service-heading h3
+{
+ margin: 0;
+}
+
+.led
+{
+ border-radius: 50%;
+ display: inline-block;
+ flex: 0 0 0.8rem;
+ height: 0.8rem;
+ width: 0.8rem;
+}
+
+.led-good
+{
+ background: #1b9e4b;
+}
+
+.led-bad
+{
+ background: #d93434;
+}
+
+.led-other
+{
+ background: #e0a21a;
+}
+
+.led-na
+{
+ background: #858b94;
+ opacity: 0.45;
+}
+
+.status-label
+{
+ color: #59616d;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.empty
+{
+ color: #59616d;
+}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 8495cad..a1c5923 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -1,7 +1,11 @@
#include "app.h"
+#include "data_source_sqlite.h"
#include "embedded_assets.h"
+#include <chrono>
+#include <string>
#include <thread>
+#include <utility>
#include <gtest/gtest.h>
@@ -11,7 +15,10 @@ namespace
class TestApp : public App
{
public:
- TestApp() : App(mw::IPSocketInfo{"127.0.0.1", 0}) {}
+ TestApp(Configuration configuration, DataSourceInterface& data_source)
+ : App(mw::IPSocketInfo{"127.0.0.1", 0}, std::move(configuration),
+ data_source)
+ {}
int listen()
{
@@ -43,14 +50,52 @@ private:
std::thread worker;
};
+Configuration emptyConfiguration()
+{
+ return {};
+}
+
+Configuration serviceConfiguration()
+{
+ return Configuration{
+ .worker_count = 1,
+ .database_path = ":memory:",
+ .groups = {{
+ .name = "Websites",
+ .services = {{
+ .id = "blog",
+ .name = "Blog",
+ .description = "Personal website",
+ .endpoint = HttpEndpoint{"https://example.test"},
+ .timeout = std::chrono::seconds(5),
+ .interval = std::chrono::hours(1),
+ .url = "https://example.test"
+ }}
+ }}
+ };
+}
+
+std::unique_ptr<DataSourceSqlite> openTestSource()
+{
+ auto source = DataSourceSqlite::open(":memory:");
+ EXPECT_TRUE(source);
+ return source ? std::move(*source) : nullptr;
+}
+
TEST(App, ServesEmbeddedAssets)
{
- TestApp app;
+ auto source = openTestSource();
+ ASSERT_TRUE(source);
+ TestApp app(emptyConfiguration(), *source);
const int port = app.listen();
ASSERT_GT(port, 0);
httplib::Client client("127.0.0.1", port);
for(const auto& asset : embeddedAssets())
{
+ if(asset.path == "/")
+ {
+ continue;
+ }
auto response = client.Get(std::string(asset.path) + "?v=1");
ASSERT_TRUE(response);
EXPECT_EQ(response->status, 200);
@@ -64,11 +109,18 @@ TEST(App, ServesEmbeddedAssets)
EXPECT_EQ(head->get_header_value("Content-Length"),
std::to_string(asset.content.size()));
}
+ auto index = client.Get("/");
+ ASSERT_TRUE(index);
+ EXPECT_EQ(index->status, 200);
+ EXPECT_NE(index->body.find("No services are configured"),
+ std::string::npos);
}
TEST(App, RejectsUnknownPathsAndWrites)
{
- TestApp app;
+ auto source = openTestSource();
+ ASSERT_TRUE(source);
+ TestApp app(emptyConfiguration(), *source);
const int port = app.listen();
ASSERT_GT(port, 0);
httplib::Client client("127.0.0.1", port);
@@ -83,4 +135,57 @@ TEST(App, RejectsUnknownPathsAndWrites)
EXPECT_NE(response->status, 200);
}
+TEST(App, RendersConfiguredStatuses)
+{
+ 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();
+ ASSERT_TRUE(source->save({"blog", now, 1200, ProbeStatus::GOOD}));
+ 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("Websites"), std::string::npos);
+ EXPECT_NE(response->body.find("Blog"), std::string::npos);
+ EXPECT_NE(response->body.find("Personal website"), std::string::npos);
+ EXPECT_NE(response->body.find("led-good"), std::string::npos);
+ EXPECT_NE(response->body.find("Good"), std::string::npos);
+}
+
+TEST(App, RendersMissingStatusAsNa)
+{
+ auto source = openTestSource();
+ ASSERT_TRUE(source);
+ 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("led-na"), std::string::npos);
+ EXPECT_NE(response->body.find("N/A"), std::string::npos);
+}
+
+TEST(App, DerivesNaForStaleStatus)
+{
+ 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();
+ ASSERT_TRUE(source->save({"blog", now - 7201, 1200, ProbeStatus::GOOD}));
+ 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("led-na"), std::string::npos);
+}
+
}
diff --git a/tests/configuration_test.cpp b/tests/configuration_test.cpp
new file mode 100644
index 0000000..6853e0c
--- /dev/null
+++ b/tests/configuration_test.cpp
@@ -0,0 +1,174 @@
+#include "configuration.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <string>
+#include <variant>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryConfig
+{
+public:
+ explicit TemporaryConfig(std::string content)
+ : path(std::filesystem::temp_directory_path() /
+ "status_tracker_test_config.yaml")
+ {
+ std::ofstream file(path);
+ file << content;
+ }
+
+ ~TemporaryConfig()
+ {
+ std::filesystem::remove(path);
+ }
+
+ const std::filesystem::path path;
+};
+
+TEST(Configuration, LoadsGroupedProtocolSettings)
+{
+ TemporaryConfig file(
+ "worker_count: 4\n"
+ "database_path: tracker.sqlite\n"
+ "groups:\n"
+ " - name: Public services\n"
+ " services:\n"
+ " blog:\n"
+ " name: Blog\n"
+ " description: Personal website\n"
+ " url: https://blog.example/\n"
+ " timeout_second: 3\n"
+ " interval:\n"
+ " value: 1\n"
+ " unit: minute\n"
+ " endpoint:\n"
+ " protocol: HTTP\n"
+ " ssh:\n"
+ " name: SSH\n"
+ " description: SSH server\n"
+ " interval:\n"
+ " value: 2\n"
+ " unit: hour\n"
+ " endpoint:\n"
+ " protocol: tcp\n"
+ " host: server.example\n"
+ " port: 22\n"
+ " timeout_second: 7\n"
+ " dns:\n"
+ " name: DNS\n"
+ " description: DNS server\n"
+ " interval:\n"
+ " value: 30\n"
+ " unit: second\n"
+ " endpoint:\n"
+ " protocol: UDP\n"
+ " host: dns.example\n"
+ " port: 53\n"
+ " payload: ping\n"
+ " gateway:\n"
+ " name: Gateway\n"
+ " description: Network gateway\n"
+ " interval:\n"
+ " value: 1\n"
+ " unit: day\n"
+ " endpoint:\n"
+ " protocol: icmp\n"
+ " host: 192.0.2.1\n");
+
+ auto configuration = Configuration::fromYaml(file.path);
+ ASSERT_TRUE(configuration)
+ << (configuration ? "" : configuration.error().msg());
+ EXPECT_EQ(configuration->worker_count, 4);
+ EXPECT_EQ(configuration->database_path, "tracker.sqlite");
+ ASSERT_EQ(configuration->groups.size(), 1);
+ ASSERT_EQ(configuration->groups.front().services.size(), 4);
+
+ const auto& services = configuration->groups.front().services;
+ EXPECT_EQ(services[0].timeout, std::chrono::seconds(3));
+ EXPECT_EQ(services[0].interval, std::chrono::minutes(1));
+ ASSERT_TRUE(std::holds_alternative<HttpEndpoint>(services[0].endpoint));
+ EXPECT_TRUE(std::get<HttpEndpoint>(services[0].endpoint).url.empty());
+ ASSERT_TRUE(services[0].url);
+ EXPECT_EQ(*services[0].url, "https://blog.example/");
+
+ EXPECT_EQ(services[1].timeout, std::chrono::seconds(7));
+ ASSERT_TRUE(std::holds_alternative<TcpEndpoint>(services[1].endpoint));
+ EXPECT_EQ(std::get<TcpEndpoint>(services[1].endpoint).port, 22);
+
+ ASSERT_TRUE(std::holds_alternative<UdpEndpoint>(services[2].endpoint));
+ EXPECT_EQ(std::get<UdpEndpoint>(services[2].endpoint).payload, "ping");
+ EXPECT_EQ(services[3].interval, std::chrono::hours(24));
+ ASSERT_TRUE(std::holds_alternative<IcmpEndpoint>(services[3].endpoint));
+}
+
+TEST(Configuration, RejectsInvalidValues)
+{
+ const std::vector<std::string> contents = {
+ "worker_count: 0\n"
+ "database_path: db\n"
+ "groups: {}\n",
+ "worker_count: 1\n"
+ "database_path: db\n"
+ "groups:\n"
+ " - name: g\n"
+ " services:\n"
+ " x:\n"
+ " name: x\n"
+ " description: x\n"
+ " interval: {value: 1, unit: fortnight}\n"
+ " endpoint: {protocol: TCP, host: x, port: 1}\n",
+ "worker_count: 1\n"
+ "database_path: db\n"
+ "groups:\n"
+ " - name: g\n"
+ " services:\n"
+ " x:\n"
+ " name: x\n"
+ " description: x\n"
+ " interval: {value: 1, unit: minute}\n"
+ " endpoint: {protocol: TCP, host: x, port: 0}\n"
+ };
+ for(const auto& content : contents)
+ {
+ TemporaryConfig file(content);
+ EXPECT_FALSE(Configuration::fromYaml(file.path));
+ }
+}
+
+TEST(Configuration, RejectsDuplicateServiceIDs)
+{
+ TemporaryConfig file(
+ "worker_count: 1\n"
+ "database_path: db.sqlite\n"
+ "groups:\n"
+ " - name: first\n"
+ " services:\n"
+ " duplicate:\n"
+ " name: First\n"
+ " description: First service\n"
+ " interval: {value: 1, unit: minute}\n"
+ " endpoint: {protocol: ICMP, host: 192.0.2.1}\n"
+ " - name: second\n"
+ " services:\n"
+ " duplicate:\n"
+ " name: Second\n"
+ " description: Second service\n"
+ " interval: {value: 1, unit: minute}\n"
+ " endpoint: {protocol: ICMP, host: 192.0.2.2}\n");
+
+ EXPECT_FALSE(Configuration::fromYaml(file.path));
+}
+
+TEST(Configuration, RejectsMalformedYaml)
+{
+ TemporaryConfig file("worker_count: [\n");
+ EXPECT_FALSE(Configuration::fromYaml(file.path));
+}
+
+}