BareGit

Add configuration loading and embedded status UI

- Load PRD-shaped YAML configuration files with validation.
- Add positional configuration path handling to the executable.
- Render service statuses through the embedded HTTP application.
- Add configuration and HTTP handler coverage.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Sep 19 11:08:59 2026 -0700
Commit: 57097390f3a0f74e7954092cd2713c71c956deaf

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