diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e78188..13f2895 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,11 +89,13 @@ else() endif() add_definitions(-DHAZEL_VERSION="${HAZEL_VERSION}") # }}} +add_definitions(-DCROW_ENFORCE_WS_SPEC=1) add_subdirectory(src) +add_subdirectory(tests EXCLUDE_FROM_ALL) -#add_custom_target(test - #COMMAND tests - #DEPENDS tests - #COMMENT "Test hazel") +add_custom_target(test + COMMAND tests + DEPENDS tests + COMMENT "Test hazel") # vim:ft=cmake diff --git a/README.md b/README.md index 8640bed..94ed71e 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ Internal metaserver for my homelab. It's going to be a while before it's general ## Requirements - * A Linux-based server - * C++20 compiler - * CMake 3.28 (Pro tip: [CMake is available via pip](https://pypi.org/project/cmake/)) - * libpq-dev, uuid-dev, libasio-dev (Debian-based names; look up your distro's package manager for the applicable packages). Additional dependencies are sourced automagically during the build process - * Postgresql +* A Linux-based server +* C++20 compiler +* CMake 3.28 (Pro tip: [CMake is available via pip](https://pypi.org/project/cmake/)) +* libpq-dev, uuid-dev, libasio-dev (Debian-based names; look up your distro's package manager for the applicable packages). Additional dependencies are sourced automagically during the build process +* Postgresql + diff --git a/docs/features/dashboard/Index.md b/docs/features/dashboard/Index.md new file mode 100644 index 0000000..f1850a5 --- /dev/null +++ b/docs/features/dashboard/Index.md @@ -0,0 +1,83 @@ +# Dashboard + +The dashboard is one of Hazel's built-in features. + +## Modules + +The dashboard is built around different modules. Note that the modules are separated into distinct types, so they can't be easily mixed in the final output. This may become an option in the future, but is not currently a goal. + +### Link module (`links` config key) + +The link module can be used as a way to, shock, link to other stuff. + +Some sites support additional features in the display. These are called "dynamic apps" internally, because I wasn't creative when I named them. These can show, for example, aggregate statistics directly on the dashboard sourced from the service linked to. + +Note that these are optional, including for enabled services, and have to be manually configured. + +#### Config format + +```json +"dashboard": { + "links": { + "name": "Display name in the dashboard", + "type": "app type; optional, see 'Supported dynamic apps' for the list of allowed values", + "url": "https://example.com", + "config": { + "Contains a set of key-value pairs used for configuring dynamic apps. Unsupported values are quietly ignored. See the docs for each dynamic app for allowed values" + } + } +} +``` + +#### Dynamic app caveats + +Dynamic app updates are only done on a single thread. This means that if one update takes a significant amount of time, other updates may be delayed. + +It's therefore strongly recommended to not set the `update_frequency` interval too low, as this may result in update cycles taking longer than planned. Additionally, due to implementation reasons, this will slow down clients that want to load at the same time as the update. + +This is a consequence of how the dynamic apps are set up. They're not queried by the client when the page is loaded, but handled fully on the backend. This is done to allow the dashboard to be displayed by other users without leaking API keys and similar. + +In the event of future multi-user support, some dynamic apps may be handled etnirely client-sided. + +#### Supported dynamic apps + +* Pi-hole (`pihole`) +* Miniflux (`miniflux`) +* Uptime-kuma (`uptime-kuma`) + +##### Pi-hole +| Config key name | Description | Type | Required | +| --------------- | ----------- | ---- | -------- | +| `api_key` | The API key for pihole, as found in pihole's settings | String | Yes | +| `update_frequency` | The number of seconds between updates | integer, >= 5 | No; defaults to 600 | +| `verify_ssl` | Whether or not to verify certificates. This may need to be set to `true` for self-signed certificates | bool | No; defaults to false. | + +##### Uptime-kuma + +| Config key name | Description | Type | Required | +| --------------- | ----------- | ---- | -------- | +| `dashboard` | The dashboard to gather data from. Note that a public dashboard must be configured in uptime-kuma | string | Yes | +| `update_frequency` | The number of seconds between updates | integer, >= 5 | No; defaults to 60 | +| `verify_ssl` | Whether or not to verify certificates. This may need to be set to `true` for self-signed certificates | bool | No; defaults to false. | + +##### Miniflux + +| Config key name | Description | Type | Required | +| --------------- | ----------- | ---- | -------- | +| `api_key`[^1] | An API key for Miniflux, generated under settings. | string | Yes | +| `update_frequency` | The number of seconds between updates | integer, >= 5 | No; defaults to 1800 | +| `verify_ssl` | Whether or not to verify certificates. This may need to be set to `true` for self-signed certificates | bool | No; defaults to false. | + +#### Self-signed certificates + +**NOTE:** This only applies if you haven't installed the CA certificate to the system. If you can `curl https://your-domain` without it complaining about an SSL cert problem, it's probably fine. You'll also get an equivalent error in Hazel's logs (`journalctl -r -u hazel`) if the requests fail. + +If you can't get this to work, the rest of the section is for you. + +--- + +For now, self-signed certificates are supported by disabling SSL verification. You can do this by setting `verify_ssl` in dynamic apps to `false`. Note that this can have security implications if the domains are hijacked. + +libcpr, the underlying request library, [supports custom CA verification](https://docs.libcpr.org/advanced-usage.html#certificate-authority-ca-bundle), but this has not been set up because it's tedious and I just don't want to. Pull requests adding support for self-signed certificates are welcome. + +[^1]: Miniflux's API does support username and password authentication, but this is not supported by Hazel to minimise username and password hard-coding in config diff --git a/etc/hazel/hazel.conf.template b/etc/hazel/hazel.conf.template index 065466e..ab0afb7 100644 --- a/etc/hazel/hazel.conf.template +++ b/etc/hazel/hazel.conf.template @@ -31,6 +31,19 @@ } } }, + "dashboard": { + "links": [ + { + "name": "Pihole", + "type": "pihole", + "url": "https://pihole.lan", + "config": { + "api_key": "abcd69420", + "update_frequency": 600 + } + } + ] + }, "server": { "port": 6906 } diff --git a/scripts/debian/dependencies.sh b/scripts/debian/dependencies.sh old mode 100644 new mode 100755 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ca45b1a..3b61602 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ set (SOURCE_FILES # Endpoints hazel/features/miniflux/MinifluxProxy.cpp hazel/features/WebCore.cpp + hazel/features/webcore/Dashboard.cpp # Adapters hazel/automation/adapters/DiscordAdapter.cpp @@ -13,6 +14,10 @@ set (SOURCE_FILES # Utils hazel/json/JsonImpl.cpp + # Dashboard stuff + hazel/data/DashboardDataProvider.cpp + hazel/data/dashboard/DashboardUpdaters.cpp + # Server (just the core shit) hazel/server/Hazel.cpp ) @@ -39,6 +44,18 @@ target_link_libraries(hazelsrc ) target_link_libraries(hazel hazelsrc) +# Copying web content +add_custom_target(webcontent + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/www + COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/www ${CMAKE_BINARY_DIR}/www + COMMENT "Copying web to output dir" +) +add_custom_target(webcontentdebug + COMMAND ${CMAKE_COMMAND} -E create_symlink ${CMAKE_SOURCE_DIR}/www ${CMAKE_BINARY_DIR}/www-debug + COMMENT "Symlink debug symlink repo" +) +add_dependencies(hazelsrc webcontent webcontentdebug) + # Doesn't work because no sudo # Maybe it doesn't make sense to use /etc then? maybe use /opt/hazel/conf/? # Won't help with the systemd service, but there's an install script for that @@ -52,10 +69,9 @@ install( PATTERN *.so* PATTERN *.a EXCLUDE ) -# For when there's actual web content -#install( - #DIRECTORY www - #DESTINATION . -#) +install( + DIRECTORY ${CMAKE_BINARY_DIR}/www + DESTINATION . +) # vim:ft=cmake diff --git a/src/hazel/Main.cpp b/src/hazel/Main.cpp index 24acf44..6be5069 100644 --- a/src/hazel/Main.cpp +++ b/src/hazel/Main.cpp @@ -1,5 +1,13 @@ #include "hazel/server/Hazel.hpp" +#include "spdlog/spdlog.h" +#include "spdlog/cfg/env.h" + int main() { +#ifdef HAZEL_DEBUG + spdlog::set_level(spdlog::level::debug); +#else + spdlog::cfg::load_env_levels(); +#endif hazel::HazelCore::getInstance().init(); } diff --git a/src/hazel/data/DashboardDataProvider.cpp b/src/hazel/data/DashboardDataProvider.cpp new file mode 100644 index 0000000..a2a81e5 --- /dev/null +++ b/src/hazel/data/DashboardDataProvider.cpp @@ -0,0 +1,93 @@ +#include "DashboardDataProvider.hpp" +#include "hazel/data/DashboardStructs.hpp" +#include "hazel/data/dashboard/DashboardUpdaters.hpp" +#include "hazel/server/Config.hpp" +#include "spdlog/spdlog.h" +#include + +namespace hazel { + +DashboardDataProvider::DashboardDataProvider(DashboardConfig& conf) { + if (conf.links.size()) { + std::vector links; + for (auto& link : conf.links) { + long long updateFreq = 60; + if (link.config.has_value() + && link.config->contains("update_frequency")) { + updateFreq = link.config->at("update_frequency").get(); + if (updateFreq < 5) { + updateFreq = 5; + } + } else { + switch (link.type) { + case LinkDynamicApp::Pihole: + updateFreq = 600; + break; + case LinkDynamicApp::Miniflux: + updateFreq = 1800; + break; + default:; + } + } + links.push_back(DashboardLinkModule { + .title = link.name, + .url = link.url, + .icon = "TODO", + .colour = "#000", + .fields = {}, + .lastUpdate = Clock::time_point(std::chrono::seconds(0)), + .updateFrequency = std::chrono::seconds(updateFreq), + .appType = link.type, + .extras = link.config.value_or(nlohmann::json::object()), + }); + + } + this->dataArchive.links = std::move(links); + } + + this->updateProcessor = std::thread(std::bind(&DashboardDataProvider::update, this)); +} + +void DashboardDataProvider::update() { + while (true) { + // Dirty is set to cache.empty() to make sure the string is initialised properly at least once. + // This can be necessary if, after the initialisation, none of the dashboard components + // contain dynamic data. This would result in the update function returning false for all + // components, resulting in an empty string. + // + // Also note that this raw access is safe. The update function is the only function that does a write, + // and locking read-only is unnecessary here. + // Even with a write on another thread, length should never dip back to zero even in a race + // condition, so it should be fine. + // Plus, if multi-thread writes become a thing, the data archive also needs to be + // wrapped in a thread-safe wrapper, which I foresee could be Fun:tm:. This should + // never work with enough data for that to be applicable though, so I'm choosing to + // completely ignore it :p + long long sleepTime; + bool dirty = + Updaters::updateDashboardData(dataArchive, sleepTime) + || jsonCache.raw().empty(); + + // In some cases, the next update for a Thing:tm: can be scheduled immediately after the last update. + // This max statement is to make sure it isn't immediately or almost immediately executed to reduce cache writes. + sleepTime = std::max(sleepTime, 5ll); + + if (dirty) { + this->jsonCache.write([&](std::string& cache) { + cache = nlohmann::json(dataArchive).dump(); + }); + } + if (sleepTime == std::numeric_limits::max()) { + spdlog::warn("sleepTime is the max long value. The dashboard probably doesn't have anything in it that needs updates. Stopping update thread. If this is wrong, please open a bug report: https://github.com/LunarWatcher/hazel/issues/"); + break; + } + spdlog::debug("Dashboard data refreshed (dirty = {}). Sleeping for {}", dirty, sleepTime); + std::this_thread::sleep_for(std::chrono::seconds(sleepTime)); + } +} + +std::string DashboardDataProvider::getJsonData() { + return this->jsonCache.copy(); +} + +} diff --git a/src/hazel/data/DashboardDataProvider.hpp b/src/hazel/data/DashboardDataProvider.hpp new file mode 100644 index 0000000..3687bdd --- /dev/null +++ b/src/hazel/data/DashboardDataProvider.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "hazel/data/DashboardStructs.hpp" +#include "hazel/server/Config.hpp" +#include "hazel/sync/RWContainer.hpp" +#include +#include +#include +#include + +namespace hazel { + +class DashboardDataProvider { +private: + /** + * Underlying struct for the data. + * This is used to update and manage the data + * in a non-JSON format. + */ + DashboardData dataArchive; + + RWContainer jsonCache; + + /** + * The thread in charge of running the update() function + */ + std::thread updateProcessor; + + void update(); +public: + DashboardDataProvider(DashboardConfig& conf); + + std::string getJsonData(); +}; + +} diff --git a/src/hazel/data/DashboardStructs.hpp b/src/hazel/data/DashboardStructs.hpp new file mode 100644 index 0000000..9da727d --- /dev/null +++ b/src/hazel/data/DashboardStructs.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "hazel/meta/Typedefs.hpp" +#include +#include + +namespace hazel { + + +enum class LinkDynamicApp { + Miniflux, + UptimeKuma, + Pihole, + /** + * Used to represent a link that _isn't_ dynamic. + */ + _None +}; +NLOHMANN_JSON_SERIALIZE_ENUM( + LinkDynamicApp, + { + {LinkDynamicApp::Miniflux, "miniflux"}, + {LinkDynamicApp::Pihole, "pihole"}, + {LinkDynamicApp::UptimeKuma, "uptime-kuma"}, + {LinkDynamicApp::_None, "__UNDEFINED__"}, + } +); + +struct DashboardLinkModule { + // Exported fields {{{ + std::string title; + std::string url; + std::string icon; + std::string colour; + + /** + * Describes extra fields to display under the service card. + * This is only populated for dynamic apps + */ + std::map fields; + // }}} + // Internal fields {{{ + Clock::time_point lastUpdate; + std::chrono::seconds updateFrequency; + + LinkDynamicApp appType; + /** + * Extra config fields. Only used if appType != _None + * Implementation-dependent and not exported to the client, as + * this may contain API tokens. + */ + nlohmann::json extras; + // }}} + + NLOHMANN_DEFINE_TYPE_INTRUSIVE( + DashboardLinkModule, + title, + url, + icon, + colour, + fields + ); +}; + +struct DashboardData { + std::optional> links; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE( + DashboardData, + links + ); +}; + +} diff --git a/src/hazel/data/dashboard/DashboardUpdaters.cpp b/src/hazel/data/dashboard/DashboardUpdaters.cpp new file mode 100644 index 0000000..b0f93e2 --- /dev/null +++ b/src/hazel/data/dashboard/DashboardUpdaters.cpp @@ -0,0 +1,72 @@ +#include "DashboardUpdaters.hpp" +#include "cpr/ssl_options.h" +#include "hazel/data/DashboardStructs.hpp" +#include +#include +#include + +namespace hazel { + +bool Updaters::updateDashboardData(DashboardData &data, long long& waitSecs) { + waitSecs = std::numeric_limits::max(); + + return updateLinks(data, waitSecs); +} + +bool Updaters::updateLinks(DashboardData& data, long long& waitSecs) { + bool dirty = false; + auto& links = *data.links; + for (auto& link : links) { + if (link.appType != LinkDynamicApp::_None) { + auto now = Clock::now(); + if (link.lastUpdate + link.updateFrequency >= now) { + // The update interval is not yet hit. Continue, and compute the wait + long long diff = std::chrono::duration_cast( + now - link.lastUpdate - link.updateFrequency + ).count(); + waitSecs = std::min(waitSecs, diff); + continue; + } + dirty = true; + spdlog::info("here"); + + // Update time. Compute waitSecsc as the min wait time + waitSecs = std::min(waitSecs, (long long) link.updateFrequency.count()); + link.lastUpdate = now; + // Run the update. Updates are delegated to various other functions + Links::updaters.at(link.appType)(link); + + } + } + return dirty; +} + +void Updaters::Links::updatePihole(DashboardLinkModule& link) { + auto data = cpr::Get( + cpr::Url{ + link.url + "/admin/api.php" + }, + cpr::Parameters{ + {"summaryRaw", ""}, + {"auth", link.extras.at("api_key").get()} + }, + cpr::VerifySsl(link.extras.value("verify_ssl", true)) + ); + if (data.status_code >= 400 || data.status_code < 100) { + spdlog::error("Bad response from Pi-hole (status_code = {}): {}", data.status_code, data.text); + return; + } + + auto json = nlohmann::json::parse(data.text); + + auto& fields = link.fields; + fields["Queries today"] = std::to_string(json.at("dns_queries_today").get()); + fields["Blocked today"] = std::to_string(json.at("ads_blocked_today").get()); + fields["Blocked percent"] = std::to_string(json.at("ads_percentage_today").get()) + "%"; +} +void Updaters::Links::updateMiniflux(DashboardLinkModule& link) { +} +void Updaters::Links::updateUptimeKuma(DashboardLinkModule& link) { +} + +} diff --git a/src/hazel/data/dashboard/DashboardUpdaters.hpp b/src/hazel/data/dashboard/DashboardUpdaters.hpp new file mode 100644 index 0000000..9d2a33e --- /dev/null +++ b/src/hazel/data/dashboard/DashboardUpdaters.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include "hazel/data/DashboardStructs.hpp" +#include + +namespace hazel { + +namespace Updaters { + +extern bool updateDashboardData(DashboardData& data, long long& waitSecs); + +extern bool updateLinks(DashboardData& data, long long& waitSecs); + +namespace Links { + +extern void updatePihole(DashboardLinkModule& link); +extern void updateMiniflux(DashboardLinkModule& link); +extern void updateUptimeKuma(DashboardLinkModule& link); + +inline std::map> updaters = { + {LinkDynamicApp::Miniflux, updateMiniflux}, + {LinkDynamicApp::Pihole, updatePihole}, + {LinkDynamicApp::UptimeKuma, updateUptimeKuma}, +}; + +} +} + +} diff --git a/src/hazel/features/WebCore.cpp b/src/hazel/features/WebCore.cpp index 30ff557..73a1e69 100644 --- a/src/hazel/features/WebCore.cpp +++ b/src/hazel/features/WebCore.cpp @@ -1,4 +1,5 @@ #include "WebCore.hpp" +#include "hazel/features/webcore/Dashboard.hpp" #include #include @@ -7,6 +8,8 @@ void hazel::InitWebCore(HazelCore &server) { CROW_ROUTE(server.getApp(), "/_health") .methods(crow::HTTPMethod::GET) (HAZEL_CALLBACK_BINDING(webcoreHealth)); + + InitDashboard(server); } void hazel::webcoreHealth(HazelCore&, crow::request&, crow::response& res) { diff --git a/src/hazel/features/WebCore.hpp b/src/hazel/features/WebCore.hpp index 571a703..15ece82 100644 --- a/src/hazel/features/WebCore.hpp +++ b/src/hazel/features/WebCore.hpp @@ -2,7 +2,6 @@ #include "cpr/response.h" #include "hazel/server/Config.hpp" -#include "nlohmann/json_fwd.hpp" #include #include diff --git a/src/hazel/features/webcore/Dashboard.cpp b/src/hazel/features/webcore/Dashboard.cpp new file mode 100644 index 0000000..76226c9 --- /dev/null +++ b/src/hazel/features/webcore/Dashboard.cpp @@ -0,0 +1,37 @@ +#include "Dashboard.hpp" +#include "crow/app.h" +#include "crow/common.h" +#include "crow/mustache.h" +#include "crow/websocket.h" + +#include +#include + +void hazel::InitDashboard(HazelCore &server) { + HAZEL_REDIRECT("/", "/index.html"); + CROW_ROUTE(server.getApp(), "/index.html") + .methods(crow::HTTPMethod::GET) + (HAZEL_CALLBACK_BINDING(webcoreIndex)); + CROW_ROUTE(server.getApp(), "/api/dashboard") + .methods(crow::HTTPMethod::GET) + (HAZEL_CALLBACK_BINDING(webcoreDashboardData)); + + HAZEL_STATIC_ASSET("style.css"); + HAZEL_STATIC_ASSET("base.js"); + + HAZEL_STATIC_PAGE("open-source.html", "Open-source licenses"); + +} + +void hazel::webcoreIndex(HazelCore &, crow::request &, crow::response &res) { + crow::mustache::context ctx; + + HAZEL_WEBPAGE(res, ctx, "index.mustache", "Dashboard"); + + res.end(); +} + +void hazel::webcoreDashboardData(HazelCore& server, crow::request&, crow::response& res) { + HAZEL_JSON(res); + res.end(server.getDashData().getJsonData()); +} diff --git a/src/hazel/features/webcore/Dashboard.hpp b/src/hazel/features/webcore/Dashboard.hpp new file mode 100644 index 0000000..e164a1f --- /dev/null +++ b/src/hazel/features/webcore/Dashboard.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace hazel { + +class HazelCore; + +extern void InitDashboard(HazelCore& server); +// Pages {{{ +extern void webcoreIndex(HazelCore& server, crow::request& req, crow::response& res); +// }}} + +// API {{{ +extern void webcoreDashboardData(HazelCore& server, crow::request& req, crow::response& res); +// }}} + +} diff --git a/src/hazel/json/JsonImpl.cpp b/src/hazel/json/JsonImpl.cpp index b891d08..6f23781 100644 --- a/src/hazel/json/JsonImpl.cpp +++ b/src/hazel/json/JsonImpl.cpp @@ -1,5 +1,6 @@ #include "hazel/automation/adapters/DiscordAdapter.hpp" #include "hazel/automation/adapters/NtfyAdapter.hpp" +#include "hazel/data/DashboardStructs.hpp" #include void hazel::from_json(const nlohmann::json& i, Config& o) { @@ -30,4 +31,31 @@ void hazel::from_json(const nlohmann::json& i, Config& o) { } } } + + if (i.contains("dashboard")) { + i.at("dashboard").get_to(o.dashboard); + } +} + +void hazel::from_json(const nlohmann::json& i, DashboardConfig& o) { + if (i.contains("links")) { + i.at("links").get_to(o.links); + } } + +void hazel::from_json(const nlohmann::json& i, DashboardLink& o) { + if (i.contains("type")) { + o.type = i.at("type").get(); + } else { + o.type = LinkDynamicApp::_None; + } + + i.at("name").get_to(o.name); + i.at("url").get_to(o.url); + + if (i.contains("config")) { + i.at("config").get_to(o.config); + } + +} + diff --git a/src/hazel/json/SerialiserImpl.hpp b/src/hazel/json/SerialiserImpl.hpp index f52b09b..1b544ab 100644 --- a/src/hazel/json/SerialiserImpl.hpp +++ b/src/hazel/json/SerialiserImpl.hpp @@ -1,3 +1,5 @@ +#pragma once + #include "nlohmann/adl_serializer.hpp" #include #include diff --git a/src/hazel/meta/Macros.hpp b/src/hazel/meta/Macros.hpp index 69f34eb..8b89f39 100644 --- a/src/hazel/meta/Macros.hpp +++ b/src/hazel/meta/Macros.hpp @@ -19,3 +19,27 @@ res.end(); \ }) +#define HAZEL_REDIRECT(from, to) CROW_ROUTE(server.getApp(), from)([&](crow::response& res) { \ + res.redirect(to); \ + res.end(); \ +}); + + +#define HAZEL_COMMON_CONTEXT(ctx) \ + ctx["Version"] = HAZEL_VERSION; + +#define HAZEL_WEBPAGE(res, ctx, contentPartial, title) \ + ctx["Title"] = (std::string) title; \ + HAZEL_COMMON_CONTEXT(ctx); \ + /* Why the actual fuck is a capture required to make this work? */ \ + ctx["Content"] = [&](std::string&) { \ + return std::string("{{>" contentPartial "}}"); \ + }; \ + res.body = crow::mustache::load("partials/base.mustache").render_string(ctx); \ + HAZEL_HTML(res); + +#define HAZEL_STATIC_PAGE(filename, title) CROW_ROUTE(server.getApp(), "/" filename)([&](crow::response& res) { \ + crow::mustache::context ctx; \ + HAZEL_WEBPAGE(res, ctx, filename, title); \ + res.end(); \ +}) diff --git a/src/hazel/meta/Typedefs.hpp b/src/hazel/meta/Typedefs.hpp index 5fc1a9b..194b3eb 100644 --- a/src/hazel/meta/Typedefs.hpp +++ b/src/hazel/meta/Typedefs.hpp @@ -4,6 +4,7 @@ #include "crow/middlewares/cookie_parser.h" #include "crow/middlewares/session.h" +#include #include #define GET(type, thing, key, defaultValue) auto raw##key = thing.get(#key); \ @@ -16,4 +17,7 @@ using Server = crow::Crow< crow::CookieParser >; +using Clock = std::chrono::system_clock; +using TimerClock = std::chrono::high_resolution_clock; + } diff --git a/src/hazel/server/Config.hpp b/src/hazel/server/Config.hpp index c2a3aeb..504af28 100644 --- a/src/hazel/server/Config.hpp +++ b/src/hazel/server/Config.hpp @@ -1,7 +1,7 @@ #pragma once #include "hazel/automation/Adapter.hpp" -#include "nlohmann/detail/macro_scope.hpp" +#include "hazel/data/DashboardStructs.hpp" #include #include #include @@ -46,17 +46,32 @@ struct ServerConfig { ); }; +struct DashboardLink { + LinkDynamicApp type; + std::string name, + url; + std::optional config; +}; + +struct DashboardConfig { + std::vector links; + +}; + /** * Combined config struct */ struct Config { - std::map miniflux_proxies; ServerConfig server; + DashboardConfig dashboard; + std::map miniflux_proxies; std::map> adapters; }; extern void from_json(const nlohmann::json& i, Config& o); +extern void from_json(const nlohmann::json& i, DashboardConfig& o); +extern void from_json(const nlohmann::json& i, DashboardLink& o); } diff --git a/src/hazel/server/Hazel.cpp b/src/hazel/server/Hazel.cpp index 49b9289..2ff59c2 100644 --- a/src/hazel/server/Hazel.cpp +++ b/src/hazel/server/Hazel.cpp @@ -21,6 +21,9 @@ HazelCore::HazelCore() { } conf = nlohmann::json::parse(ifs); + dashData = std::make_shared( + conf.dashboard + ); } void HazelCore::bootstrapDatabase() { diff --git a/src/hazel/server/Hazel.hpp b/src/hazel/server/Hazel.hpp index 730fa86..0c3529b 100644 --- a/src/hazel/server/Hazel.hpp +++ b/src/hazel/server/Hazel.hpp @@ -3,6 +3,7 @@ #include #include "Config.hpp" +#include "hazel/data/DashboardDataProvider.hpp" #include #include @@ -22,7 +23,7 @@ class HazelCore { std::string assetBaseDir; bool sslEnabled; - + std::shared_ptr dashData; HazelCore(); @@ -54,6 +55,10 @@ class HazelCore { bool isSSLEnabled() { return sslEnabled; } + DashboardDataProvider& getDashData() { + return *dashData; + } + static HazelCore& getInstance() { static HazelCore server; return server; diff --git a/src/hazel/sync/RWContainer.hpp b/src/hazel/sync/RWContainer.hpp new file mode 100644 index 0000000..afe0e87 --- /dev/null +++ b/src/hazel/sync/RWContainer.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace hazel { + +/** + * Utility container for a data object with a mutex wrapper. + * + * Note that if T is a pointer type, the behaviour of this class is undefined, + * and does not guarantee synchronisation. + */ +template +class RWContainer { +private: + T data; + std::shared_mutex mutex; + +public: + RWContainer() : data() {} + RWContainer(const T& initVal) : data(initVal) {} + + void read(std::function callback) { + std::shared_lock lock(mutex); + callback(data); + } + + /** + * Returns a copy of the underlying data. + */ + T copy() { + std::shared_lock lock(mutex); + return data; + } + + void write(std::function callback) { + std::unique_lock lock(mutex); + callback(data); + } + + /** + * Utility function that returns the object without locking it. + * This is fundamentally unsafe and should not be used unless + * there's a very good reason to do so. + */ + T& raw() { + return data; + } + +}; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fdd2e25..584f979 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,15 +1,13 @@ add_executable(tests - src/ABCDTests.cpp + src/DashboardTests.cpp ) Include(FetchContent) -# TODO: Ensure this is up-to-date prior to using the code -# The template will not be kept 100% up-to-date due to this being a waste of time FetchContent_Declare( Catch2 GIT_REPOSITORY https://github.com/catchorg/Catch2.git - GIT_TAG v3.3.0 + GIT_TAG v3.5.4 ) FetchContent_MakeAvailable(Catch2) diff --git a/tests/src/DashboardTests.cpp b/tests/src/DashboardTests.cpp new file mode 100644 index 0000000..620828c --- /dev/null +++ b/tests/src/DashboardTests.cpp @@ -0,0 +1,47 @@ +#include "hazel/data/DashboardStructs.hpp" +#include "hazel/server/Config.hpp" +#include + +TEST_CASE("Check base config support", "[Config]") { + std::string config = R"( + { + "dashboard": { + "links": [ + { + "name": "Pihole", + "type": "pihole", + "url": "https://127.0.0.1", + "config": { + "api_key": "abcd69420", + "update_frequency": 600 + } + }, + { + "name": "Pihole 2", + "url": "https://127.0.0.1" + } + ] + } + } + )"; + auto conf = nlohmann::json::parse(config).get(); + + REQUIRE(conf.adapters.size() == 0); + REQUIRE(conf.miniflux_proxies.size() == 0); + REQUIRE(conf.dashboard.links.size() == 2); + { + auto link = conf.dashboard.links.at(0); + REQUIRE(link.config.has_value()); + REQUIRE(link.config->size() == 2); + REQUIRE(link.name == "Pihole"); + REQUIRE(link.type == hazel::LinkDynamicApp::Pihole); + REQUIRE(link.url == "https://127.0.0.1"); + } + { + auto link = conf.dashboard.links.at(1); + REQUIRE_FALSE(link.config.has_value()); + REQUIRE(link.name == "Pihole 2"); + REQUIRE(link.type == hazel::LinkDynamicApp::_None); + REQUIRE(link.url == "https://127.0.0.1"); + } +} diff --git a/www/assets/base.js b/www/assets/base.js new file mode 100644 index 0000000..1b85a6c --- /dev/null +++ b/www/assets/base.js @@ -0,0 +1,93 @@ +function refreshLinks(links) { + let targetContainer = document.getElementById("links"); + if (targetContainer == null) { + console.log("Failed to get link container"); + return; + } + + for (let link of links) { + // Unsafe? + let elem = document.querySelector("[data-title=\"" + link.title + "\"]"); + if (elem == null) { + // Link container + elem = targetContainer.appendChild(document.createElement("div")); + elem.setAttribute("data-title", link.title); + elem.classList.add("small-paragraphs", "link-container"); + + // Logo and content container + let titleLink = elem.appendChild(document.createElement("a")); + titleLink.href = encodeURI(link.url); + titleLink.classList.add("flex-horizontal"); + + + let left = titleLink.appendChild(document.createElement("div")); + left.classList.add("link-logo"); + + let right = titleLink.appendChild(document.createElement("div")); + right.classList.add("link-content"); + + let top = right.appendChild(document.createElement("div")); + top.classList.add("link-top"); + + let title = top.appendChild(document.createElement("p")); + title.classList.add("link-title"); + title.innerText = link.title; + + + + } + + let fields = elem.getElementsByClassName("link-fields"); + if (fields == null || fields.length != 1) { + if (Object.keys(link.fields).length > 0) { + // (Re)create the field container + let bottom = elem.getElementsByClassName("link-content")[0].appendChild(document.createElement("div")); + bottom.classList.add("link-fields", "flex-horizontal"); + fields = [bottom]; + } else { + // The container doesn't exist, and there aren't any links; everything is fine, continue + continue; + } + } + + let fieldContainer = fields[0]; + + if (link.fields && Object.keys(link.fields).length > 0) { + for (const [field, value] of Object.entries(link.fields)) { + let fieldElem = fieldContainer.querySelector("[data-field-title=\"" + field + "\"]"); + if (fieldElem == null) { + fieldElem = fieldContainer.appendChild(document.createElement("div")); + fieldElem.classList.add("link-field"); + let title = fieldElem.appendChild(document.createElement("p")); + title.setAttribute("data-field-title", field); + title.innerText = field; + title.classList.add("field-title"); + + let valueField = fieldElem.appendChild(document.createElement("p")); + valueField.classList.add("field-value"); + } + fieldElem.getElementsByClassName("field-value")[0].innerText = value; + } + } else { + // Wipe fields if the server suddenly returns none + fieldContainer.remove(); + } + + } +} + +function updateDashboard() { + fetch("/api/dashboard") + .then((res) => { + // TODO handle bad status code + return res.json(); + }) + .then((json) => { + if ("links" in json && json["links"] != null) { + refreshLinks(json["links"]); + } + }); +} + +updateDashboard(); +setInterval(updateDashboard, 6000); diff --git a/www/assets/style.css b/www/assets/style.css new file mode 100644 index 0000000..6b67453 --- /dev/null +++ b/www/assets/style.css @@ -0,0 +1,156 @@ +:root { + --colour-primary: #C8B575; + + --colour-secondary: #3D2B56; + --colour-secondary-light: #5100C4; + --colour-secondary-light-110: #A362FF; + + --colour-accent-text: var(--colour-secondary-light); + --colour-accent-text-light: var(--colour-secondary); + --colour-accent-text-light-110: var(--colour-secondary-light-110); +} + +body { + /* Clear default margin for the body */ + margin: 0; + min-height: 100vh; + + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: "nav" + "main" + "footer"; +} +nav a { + text-decoration: none; +} + +.theme-background { + width: 100%; + background-color: var(--colour-primary); + padding-top: 9px; + padding-bottom: 9px; +} +.theme-background * { + margin: 0; +} + +.small-paragraphs p { + margin: 0; + padding: 0; +} + +/* Content width container {{{*/ +.container { + width: 90%; + margin: 0 auto; +} +@media only screen and (min-width: 600px) { + .container { + width: 85%; + } +} +@media only screen and (min-width: 990px) { + .container { + width: 70%; + } +} +/*}}}*/ +/* Base element styles {{{ */ +code { + background-color: #e3e3e3; + padding: 3px; + border-radius: 2px; +} + +a { + color: var(--colour-accent-text); +} +a:visited { + color: var(--colour-accent-text-light); +} +a:hover { + color: var(--colour-accent-text-light-110); +} +/* }}} */ +/* Extended element styles {{{ */ +.sneaky-list { + list-style: none; + padding-left: 0; +} +/* }}} */ +/* Grids {{{ */ +.column2-grid { + display: grid; + grid-template-columns: 1fr 1fr; +} +/* }}}*/ +/* Flexboxes {{{ */ +.flex-vertical { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1 0 auto; +} + +.flex-horizontal { + display: flex; + flex-direction: row; + gap: 5px; + flex: 1 0 auto; +} +/* }}}*/ +/* Links {{{ */ +.link-container { + box-sizing: border-box; + border-radius: 5px; + max-width: 300px; + border: 2px solid var(--colour-primary); +} +.link-container > a { + text-decoration: none; + /* No gaps between the logo and the link content, mainly to + * make sure the border between the link title and fields + * doesn't look out of place + */ + gap: 0px; + height: 100%; +} +.link-top { + padding: 5px; + box-sizing: border-box; +} +.link-fields { + gap: 0px; +} + +.field-title { + font-size: 0.8em; + text-align: center; +} +.field-value { + font-size: 0.7em; + text-align: center; +} + +.link-field { + border-top: 2px solid var(--colour-primary); + box-sizing: border-box; + padding: 1px; +} +.link-fields:hover, .link-fields:visited, .link-fields { + color: black; + text-decoration: none; +} + +.link-field:not(:nth-last-child(1)) { + border-right: 2px solid var(--colour-primary); +} + +.link-title { + font-size: 1.3em; + text-decoration: underline; +} + +/* }}} */ diff --git a/www/index.mustache b/www/index.mustache new file mode 100644 index 0000000..d8e6854 --- /dev/null +++ b/www/index.mustache @@ -0,0 +1,3 @@ +

Dashboard

+ + diff --git a/www/open-source.html b/www/open-source.html new file mode 100644 index 0000000..40b93ad --- /dev/null +++ b/www/open-source.html @@ -0,0 +1,25 @@ +

Open-source licenses

+ +

Hazel makes use of multiple third-party libraries to function. This list primarily contains direct depedencies, as well as a few indirect ones. For all indirect dependencies, refer to the repos for the respective libraries. Also note that Hazel itself is open-source under the MIT license.

+ +

Backend

+ + + + + diff --git a/www/partials/base.mustache b/www/partials/base.mustache new file mode 100644 index 0000000..d397506 --- /dev/null +++ b/www/partials/base.mustache @@ -0,0 +1,26 @@ + + + + + + + + + {{ Title }} | Hazel + + + + + + {{>partials/header.mustache}} +
+
+ {{&Content}} +
+
+ {{>partials/footer.mustache}} + + +{{! +vim:ft=mustache +}} diff --git a/www/partials/footer.mustache b/www/partials/footer.mustache new file mode 100644 index 0000000..c9cc34d --- /dev/null +++ b/www/partials/footer.mustache @@ -0,0 +1,17 @@ + diff --git a/www/partials/header.mustache b/www/partials/header.mustache new file mode 100644 index 0000000..accdb39 --- /dev/null +++ b/www/partials/header.mustache @@ -0,0 +1,7 @@ +
+ +