diff options
| author | Stefan Boberg <[email protected]> | 2026-03-09 17:43:08 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-09 17:43:08 +0100 |
| commit | b37b34ea6ad906f54e8104526e77ba66aed997da (patch) | |
| tree | e80ce17d666aff6d2f0d73d4977128ffb4055476 /src | |
| parent | add fallback for zencache multirange (#816) (diff) | |
| download | zen-b37b34ea6ad906f54e8104526e77ba66aed997da.tar.xz zen-b37b34ea6ad906f54e8104526e77ba66aed997da.zip | |
Dashboard overhaul, compute integration (#814)
- **Frontend dashboard overhaul**: Unified compute/main dashboards into a single shared UI. Added new pages for cache, projects, metrics, sessions, info (build/runtime config, system stats). Added live-update via WebSockets with pause control, sortable detail tables, themed styling. Refactored compute/hub/orchestrator pages into modular JS.
- **HTTP server fixes and stats**: Fixed http.sys local-only fallback when default port is in use, implemented root endpoint redirect for http.sys, fixed Linux/Mac port reuse. Added /stats endpoint exposing HTTP server metrics (bytes transferred, request rates). Added WebSocket stats tracking.
- **OTEL/diagnostics hardening**: Improved OTLP HTTP exporter with better error handling and resilience. Extended diagnostics services configuration.
- **Session management**: Added new sessions service with HTTP endpoints for registering, updating, querying, and removing sessions. Includes session log file support. This is still WIP.
- **CLI subcommand support**: Added support for commands with subcommands in the zen CLI tool, with improved command dispatch.
- **Misc**: Exposed CPU usage/hostname to frontend, fixed JS compact binary float32/float64 decoding, limited projects displayed on front page to 25 sorted by last access, added vscode:// link support.
Also contains some fixes from TSAN analysis.
Diffstat (limited to 'src')
80 files changed, 6027 insertions, 1032 deletions
diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 7f7afa322..9a466da2e 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -194,6 +194,84 @@ ZenCmdBase::GetSubCommand(cxxopts::Options&, return argc; } +ZenSubCmdBase::ZenSubCmdBase(std::string_view Name, std::string_view Description) +: m_SubOptions(std::string(Name), std::string(Description)) +{ + m_SubOptions.add_options()("h,help", "Print help"); +} + +void +ZenCmdWithSubCommands::AddSubCommand(ZenSubCmdBase& SubCmd) +{ + m_SubCommands.push_back(&SubCmd); +} + +bool +ZenCmdWithSubCommands::OnParentOptionsParsed(const ZenCliOptions& /*GlobalOptions*/) +{ + return true; +} + +void +ZenCmdWithSubCommands::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) +{ + std::vector<cxxopts::Options*> SubOptionPtrs; + SubOptionPtrs.reserve(m_SubCommands.size()); + for (ZenSubCmdBase* SubCmd : m_SubCommands) + { + SubOptionPtrs.push_back(&SubCmd->SubOptions()); + } + + cxxopts::Options* MatchedSubOption = nullptr; + std::vector<char*> SubCommandArguments; + int ParentArgc = GetSubCommand(Options(), argc, argv, SubOptionPtrs, MatchedSubOption, SubCommandArguments); + + if (!ParseOptions(Options(), ParentArgc, argv)) + { + return; + } + + if (MatchedSubOption == nullptr) + { + ExtendableStringBuilder<128> VerbList; + for (bool First = true; ZenSubCmdBase * SubCmd : m_SubCommands) + { + if (!First) + { + VerbList.Append(", "); + } + VerbList.Append(SubCmd->SubOptions().program()); + First = false; + } + throw OptionParseException(fmt::format("No subcommand specified. Available subcommands: {}", VerbList.ToView()), Options().help()); + } + + ZenSubCmdBase* MatchedSubCmd = nullptr; + for (ZenSubCmdBase* SubCmd : m_SubCommands) + { + if (&SubCmd->SubOptions() == MatchedSubOption) + { + MatchedSubCmd = SubCmd; + break; + } + } + ZEN_ASSERT(MatchedSubCmd != nullptr); + + // Parse subcommand args before OnParentOptionsParsed so --help on the subcommand + // works without requiring parent options like --hosturl to be populated. + if (!ParseOptions(*MatchedSubOption, gsl::narrow<int>(SubCommandArguments.size()), SubCommandArguments.data())) + { + return; + } + + if (!OnParentOptionsParsed(GlobalOptions)) + { + return; + } + + MatchedSubCmd->Run(GlobalOptions); +} + static ReturnCode GetReturnCodeFromHttpResult(const HttpClientError& Ex) { diff --git a/src/zen/zen.h b/src/zen/zen.h index e3481beea..06e5356a6 100644 --- a/src/zen/zen.h +++ b/src/zen/zen.h @@ -79,4 +79,41 @@ class CacheStoreCommand : public ZenCmdBase virtual ZenCmdCategory& CommandCategory() const override { return g_CacheStoreCategory; } }; +// Base for individual subcommands +class ZenSubCmdBase +{ +public: + ZenSubCmdBase(std::string_view Name, std::string_view Description); + virtual ~ZenSubCmdBase() = default; + cxxopts::Options& SubOptions() { return m_SubOptions; } + virtual void Run(const ZenCliOptions& GlobalOptions) = 0; + +protected: + cxxopts::Options m_SubOptions; +}; + +// Base for commands that host subcommands - handles all dispatch boilerplate +class ZenCmdWithSubCommands : public ZenCmdBase +{ +public: + void Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) final; + +protected: + void AddSubCommand(ZenSubCmdBase& SubCmd); + virtual bool OnParentOptionsParsed(const ZenCliOptions& GlobalOptions); + +private: + std::vector<ZenSubCmdBase*> m_SubCommands; +}; + +class CacheStoreCmdWithSubCommands : public ZenCmdWithSubCommands +{ + ZenCmdCategory& CommandCategory() const override { return g_CacheStoreCategory; } +}; + +class StorageCmdWithSubCommands : public ZenCmdWithSubCommands +{ + ZenCmdCategory& CommandCategory() const override { return g_StorageCategory; } +}; + } // namespace zen diff --git a/src/zenbase/include/zenbase/refcount.h b/src/zenbase/include/zenbase/refcount.h index 40ad7bca5..08bc6ae54 100644 --- a/src/zenbase/include/zenbase/refcount.h +++ b/src/zenbase/include/zenbase/refcount.h @@ -51,6 +51,9 @@ private: * NOTE: Unlike RefCounted, this class deletes the derived type when the reference count reaches zero. * It has no virtual destructor, so it's important that you either don't derive from it further, * or ensure that the derived class has a virtual destructor. + * + * This class is useful when you want to avoid adding a vtable to a class just to implement + * reference counting. */ template<typename T> diff --git a/src/zencore/include/zencore/logging/tracesink.h b/src/zencore/include/zencore/logging/tracesink.h index e63d838b4..785c51e10 100644 --- a/src/zencore/include/zencore/logging/tracesink.h +++ b/src/zencore/include/zencore/logging/tracesink.h @@ -6,6 +6,8 @@ namespace zen::logging { +#if ZEN_WITH_TRACE + /** * A logging sink that forwards log messages to the trace system. * @@ -20,4 +22,6 @@ public: void SetFormatter(std::unique_ptr<Formatter> InFormatter) override; }; +#endif + } // namespace zen::logging diff --git a/src/zencore/include/zencore/sentryintegration.h b/src/zencore/include/zencore/sentryintegration.h index a4e33d69e..27e5a8a82 100644 --- a/src/zencore/include/zencore/sentryintegration.h +++ b/src/zencore/include/zencore/sentryintegration.h @@ -40,6 +40,7 @@ public: }; void Initialize(const Config& Conf, const std::string& CommandLine); + void Close(); void LogStartupInformation(); static void ClearCaches(); diff --git a/src/zencore/include/zencore/system.h b/src/zencore/include/zencore/system.h index fecbe2dbe..a67999e52 100644 --- a/src/zencore/include/zencore/system.h +++ b/src/zencore/include/zencore/system.h @@ -14,6 +14,7 @@ class CbWriter; std::string GetMachineName(); std::string_view GetOperatingSystemName(); +std::string GetOperatingSystemVersion(); std::string_view GetRuntimePlatformName(); // "windows", "wine", "linux", or "macos" std::string_view GetCpuName(); @@ -28,6 +29,7 @@ struct SystemMetrics uint64_t AvailVirtualMemoryMiB = 0; uint64_t PageFileMiB = 0; uint64_t AvailPageFileMiB = 0; + uint64_t UptimeSeconds = 0; }; /// Extended metrics that include CPU usage percentage, which requires diff --git a/src/zencore/logging/tracesink.cpp b/src/zencore/logging/tracesink.cpp index e3533327b..8a6f4e40c 100644 --- a/src/zencore/logging/tracesink.cpp +++ b/src/zencore/logging/tracesink.cpp @@ -7,6 +7,8 @@ #include <zencore/timer.h> #include <zencore/trace.h> +#if ZEN_WITH_TRACE + namespace zen::logging { UE_TRACE_CHANNEL_DEFINE(LogChannel) @@ -86,3 +88,5 @@ TraceSink::SetFormatter(std::unique_ptr<Formatter> /*InFormatter*/) } } // namespace zen::logging + +#endif diff --git a/src/zencore/sentryintegration.cpp b/src/zencore/sentryintegration.cpp index e39b8438d..8d087e8c6 100644 --- a/src/zencore/sentryintegration.cpp +++ b/src/zencore/sentryintegration.cpp @@ -128,6 +128,8 @@ namespace zen { # if ZEN_USE_SENTRY ZEN_DEFINE_LOG_CATEGORY_STATIC(LogSentry, "sentry-sdk"); +static std::atomic<bool> s_SentryLogEnabled{true}; + static void SentryLogFunction(sentry_level_t Level, const char* Message, va_list Args, [[maybe_unused]] void* Userdata) { @@ -147,14 +149,15 @@ SentryLogFunction(sentry_level_t Level, const char* Message, va_list Args, [[may } // SentryLogFunction can be called before the logging system is initialized - // (during sentry_init which runs before InitializeLogging). Fall back to - // console logging when the category logger is not yet available. + // (during sentry_init which runs before InitializeLogging), or after it has + // been shut down (during sentry_close on a background worker thread). Fall + // back to console logging when the category logger is not available. // // Since we want to default to WARN level but this runs before logging has // been configured, we ignore the callbacks for DEBUG/INFO explicitly here // which means users don't see every possible log message if they're trying // to configure the levels using --log-debug=sentry-sdk - if (!TheDefaultLogger) + if (!TheDefaultLogger || !s_SentryLogEnabled.load(std::memory_order_acquire)) { switch (Level) { @@ -212,11 +215,21 @@ SentryIntegration::SentryIntegration() SentryIntegration::~SentryIntegration() { + Close(); +} + +void +SentryIntegration::Close() +{ if (m_IsInitialized && m_SentryErrorCode == 0) { logging::SetErrorLog(""); m_SentryAssert.reset(); + // Disable spdlog forwarding before sentry_close() since its background + // worker thread may still log during shutdown via SentryLogFunction + s_SentryLogEnabled.store(false, std::memory_order_release); sentry_close(); + m_IsInitialized = false; } } diff --git a/src/zencore/system.cpp b/src/zencore/system.cpp index 833d3c04b..141450b84 100644 --- a/src/zencore/system.cpp +++ b/src/zencore/system.cpp @@ -4,6 +4,7 @@ #include <zencore/compactbinarybuilder.h> #include <zencore/except.h> +#include <zencore/fmtutils.h> #include <zencore/memory/memory.h> #include <zencore/string.h> @@ -135,6 +136,8 @@ GetSystemMetrics() Metrics.AvailPageFileMiB = MemStatus.ullAvailPageFile / 1024 / 1024; } + Metrics.UptimeSeconds = GetTickCount64() / 1000; + return Metrics; } #elif ZEN_PLATFORM_LINUX @@ -226,6 +229,17 @@ GetSystemMetrics() Metrics.VirtualMemoryMiB = Metrics.SystemMemoryMiB; Metrics.AvailVirtualMemoryMiB = Metrics.AvailSystemMemoryMiB; + // System uptime + if (FILE* UptimeFile = fopen("/proc/uptime", "r")) + { + double UptimeSec = 0; + if (fscanf(UptimeFile, "%lf", &UptimeSec) == 1) + { + Metrics.UptimeSeconds = static_cast<uint64_t>(UptimeSec); + } + fclose(UptimeFile); + } + // Parse /proc/meminfo for swap/page file information Metrics.PageFileMiB = 0; Metrics.AvailPageFileMiB = 0; @@ -318,6 +332,18 @@ GetSystemMetrics() Metrics.PageFileMiB = SwapUsage.xsu_total / 1024 / 1024; Metrics.AvailPageFileMiB = (SwapUsage.xsu_total - SwapUsage.xsu_used) / 1024 / 1024; + // System uptime via boot time + { + struct timeval BootTime + { + }; + Size = sizeof(BootTime); + if (sysctlbyname("kern.boottime", &BootTime, &Size, nullptr, 0) == 0) + { + Metrics.UptimeSeconds = static_cast<uint64_t>(time(nullptr) - BootTime.tv_sec); + } + } + return Metrics; } #else @@ -574,6 +600,38 @@ GetOperatingSystemName() return ZEN_PLATFORM_NAME; } +std::string +GetOperatingSystemVersion() +{ +#if ZEN_PLATFORM_WINDOWS + // Use RtlGetVersion to avoid the compatibility shim that GetVersionEx applies + using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW); + RTL_OSVERSIONINFOW OsVer{.dwOSVersionInfoSize = sizeof(OsVer)}; + if (auto Fn = (RtlGetVersionFn)GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion")) + { + Fn(&OsVer); + } + return fmt::format("Windows {}.{} Build {}", OsVer.dwMajorVersion, OsVer.dwMinorVersion, OsVer.dwBuildNumber); +#elif ZEN_PLATFORM_LINUX + struct utsname Info + { + }; + if (uname(&Info) == 0) + { + return fmt::format("{} {}", Info.sysname, Info.release); + } + return "Linux"; +#elif ZEN_PLATFORM_MAC + char OsVersion[64] = ""; + size_t Size = sizeof(OsVersion); + if (sysctlbyname("kern.osproductversion", OsVersion, &Size, nullptr, 0) == 0) + { + return fmt::format("macOS {}", OsVersion); + } + return "macOS"; +#endif +} + std::string_view GetRuntimePlatformName() { @@ -608,7 +666,7 @@ Describe(const SystemMetrics& Metrics, CbWriter& Writer) Writer << "cpu_count" << Metrics.CpuCount << "core_count" << Metrics.CoreCount << "lp_count" << Metrics.LogicalProcessorCount << "total_memory_mb" << Metrics.SystemMemoryMiB << "avail_memory_mb" << Metrics.AvailSystemMemoryMiB << "total_virtual_mb" << Metrics.VirtualMemoryMiB << "avail_virtual_mb" << Metrics.AvailVirtualMemoryMiB << "total_pagefile_mb" << Metrics.PageFileMiB - << "avail_pagefile_mb" << Metrics.AvailPageFileMiB; + << "avail_pagefile_mb" << Metrics.AvailPageFileMiB << "uptime_seconds" << Metrics.UptimeSeconds; } void diff --git a/src/zencore/testutils.cpp b/src/zencore/testutils.cpp index 5bc2841ae..0cd3f8121 100644 --- a/src/zencore/testutils.cpp +++ b/src/zencore/testutils.cpp @@ -46,7 +46,7 @@ ScopedTemporaryDirectory::~ScopedTemporaryDirectory() IoBuffer CreateRandomBlob(uint64_t Size) { - static FastRandom Rand{.Seed = 0x7CEBF54E45B9F5D1}; + thread_local FastRandom Rand{.Seed = 0x7CEBF54E45B9F5D1}; return CreateRandomBlob(Rand, Size); }; diff --git a/src/zenhttp/httpserver.cpp b/src/zenhttp/httpserver.cpp index d798c46d9..9bae95690 100644 --- a/src/zenhttp/httpserver.cpp +++ b/src/zenhttp/httpserver.cpp @@ -28,6 +28,7 @@ #include <zencore/thread.h> #include <zenhttp/packageformat.h> #include <zentelemetry/otlptrace.h> +#include <zentelemetry/stats.h> #include <charconv> #include <mutex> @@ -1094,6 +1095,39 @@ HttpServer::SetHttpRequestFilter(IHttpRequestFilter* RequestFilter) OnSetHttpRequestFilter(RequestFilter); } +CbObject +HttpServer::CollectStats() +{ + CbObjectWriter Cbo; + + metrics::EmitSnapshot("requests", m_RequestMeter, Cbo); + + Cbo.BeginObject("bytes"); + { + Cbo << "received" << GetTotalBytesReceived(); + Cbo << "sent" << GetTotalBytesSent(); + } + Cbo.EndObject(); + + Cbo.BeginObject("websockets"); + { + Cbo << "active_connections" << GetActiveWebSocketConnectionCount(); + Cbo << "frames_received" << m_WsFramesReceived.load(std::memory_order_relaxed); + Cbo << "frames_sent" << m_WsFramesSent.load(std::memory_order_relaxed); + Cbo << "bytes_received" << m_WsBytesReceived.load(std::memory_order_relaxed); + Cbo << "bytes_sent" << m_WsBytesSent.load(std::memory_order_relaxed); + } + Cbo.EndObject(); + + return Cbo.Save(); +} + +void +HttpServer::HandleStatsRequest(HttpServerRequest& Request) +{ + Request.WriteResponse(HttpResponseCode::OK, CollectStats()); +} + ////////////////////////////////////////////////////////////////////////// HttpRpcHandler::HttpRpcHandler() diff --git a/src/zenhttp/include/zenhttp/httpclient.h b/src/zenhttp/include/zenhttp/httpclient.h index bec4984db..1bb36a298 100644 --- a/src/zenhttp/include/zenhttp/httpclient.h +++ b/src/zenhttp/include/zenhttp/httpclient.h @@ -118,6 +118,15 @@ private: class HttpClientBase; +/** HTTP Client + * + * This is safe for use on multiple threads simultaneously, as each + * instance maintains an internal connection pool and will synchronize + * access to it as needed. + * + * Uses libcurl under the hood. We currently only use HTTP 1.1 features. + * + */ class HttpClient { public: diff --git a/src/zenhttp/include/zenhttp/httpserver.h b/src/zenhttp/include/zenhttp/httpserver.h index c1152dc3e..0e1714669 100644 --- a/src/zenhttp/include/zenhttp/httpserver.h +++ b/src/zenhttp/include/zenhttp/httpserver.h @@ -13,6 +13,8 @@ #include <zencore/uid.h> #include <zenhttp/httpcommon.h> +#include <zentelemetry/stats.h> + #include <functional> #include <gsl/gsl-lite.hpp> #include <list> @@ -203,12 +205,34 @@ private: int m_UriPrefixLength = 0; }; +struct IHttpStatsProvider +{ + /** Handle an HTTP stats request, writing the response directly. + * Implementations may inspect query parameters on the request + * to include optional detailed breakdowns. + */ + virtual void HandleStatsRequest(HttpServerRequest& Request) = 0; + + /** Return the provider's current stats as a CbObject snapshot. + * Used by the WebSocket push thread to broadcast live updates + * without requiring an HttpServerRequest. Providers that do + * not override this will be skipped in WebSocket broadcasts. + */ + virtual CbObject CollectStats() { return {}; } +}; + +struct IHttpStatsService +{ + virtual void RegisterHandler(std::string_view Id, IHttpStatsProvider& Provider) = 0; + virtual void UnregisterHandler(std::string_view Id, IHttpStatsProvider& Provider) = 0; +}; + /** HTTP server * * Implements the main event loop to service HTTP requests, and handles routing * requests to the appropriate handler as registered via RegisterService */ -class HttpServer : public RefCounted +class HttpServer : public RefCounted, public IHttpStatsProvider { public: void RegisterService(HttpService& Service); @@ -235,10 +259,46 @@ public: virtual uint64_t GetTotalBytesReceived() const { return 0; } virtual uint64_t GetTotalBytesSent() const { return 0; } + /** Mark that a request has been handled. Called by server implementations. */ + void MarkRequest() { m_RequestMeter.Mark(); } + + /** Set a default redirect path for root requests */ + void SetDefaultRedirect(std::string_view Path) { m_DefaultRedirect = Path; } + + std::string_view GetDefaultRedirect() const { return m_DefaultRedirect; } + + /** Track active WebSocket connections — called by server implementations on upgrade/close. */ + void OnWebSocketConnectionOpened() { m_ActiveWebSocketConnections.fetch_add(1, std::memory_order_relaxed); } + void OnWebSocketConnectionClosed() { m_ActiveWebSocketConnections.fetch_sub(1, std::memory_order_relaxed); } + uint64_t GetActiveWebSocketConnectionCount() const { return m_ActiveWebSocketConnections.load(std::memory_order_relaxed); } + + /** Track WebSocket frame and byte counters — called by WS connection implementations per frame. */ + void OnWebSocketFrameReceived(uint64_t Bytes) + { + m_WsFramesReceived.fetch_add(1, std::memory_order_relaxed); + m_WsBytesReceived.fetch_add(Bytes, std::memory_order_relaxed); + } + void OnWebSocketFrameSent(uint64_t Bytes) + { + m_WsFramesSent.fetch_add(1, std::memory_order_relaxed); + m_WsBytesSent.fetch_add(Bytes, std::memory_order_relaxed); + } + + // IHttpStatsProvider + virtual CbObject CollectStats() override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + private: std::vector<HttpService*> m_KnownServices; int m_EffectivePort = 0; std::string m_ExternalHost; + metrics::Meter m_RequestMeter; + std::string m_DefaultRedirect; + std::atomic<uint64_t> m_ActiveWebSocketConnections{0}; + std::atomic<uint64_t> m_WsFramesReceived{0}; + std::atomic<uint64_t> m_WsFramesSent{0}; + std::atomic<uint64_t> m_WsBytesReceived{0}; + std::atomic<uint64_t> m_WsBytesSent{0}; virtual void OnRegisterService(HttpService& Service) = 0; virtual int OnInitialize(int BasePort, std::filesystem::path DataDir) = 0; @@ -456,17 +516,6 @@ private: bool HandlePackageOffers(HttpService& Service, HttpServerRequest& Request, Ref<IHttpPackageHandler>& PackageHandlerRef); -struct IHttpStatsProvider -{ - virtual void HandleStatsRequest(HttpServerRequest& Request) = 0; -}; - -struct IHttpStatsService -{ - virtual void RegisterHandler(std::string_view Id, IHttpStatsProvider& Provider) = 0; - virtual void UnregisterHandler(std::string_view Id, IHttpStatsProvider& Provider) = 0; -}; - void http_forcelink(); // internal void websocket_forcelink(); // internal diff --git a/src/zenhttp/include/zenhttp/httpstats.h b/src/zenhttp/include/zenhttp/httpstats.h index e6fea6765..460315faf 100644 --- a/src/zenhttp/include/zenhttp/httpstats.h +++ b/src/zenhttp/include/zenhttp/httpstats.h @@ -3,23 +3,50 @@ #pragma once #include <zencore/logging.h> +#include <zencore/thread.h> #include <zenhttp/httpserver.h> +#include <zenhttp/websocket.h> +#include <atomic> #include <map> +#include <memory> +#include <thread> +#include <vector> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <asio/io_context.hpp> +#include <asio/steady_timer.hpp> +ZEN_THIRD_PARTY_INCLUDES_END namespace zen { -class HttpStatsService : public HttpService, public IHttpStatsService +class HttpStatsService : public HttpService, public IHttpStatsService, public IWebSocketHandler { public: - HttpStatsService(); + /// Construct without an io_context — optionally uses a dedicated push thread + /// for WebSocket stats broadcasting. + explicit HttpStatsService(bool EnableWebSockets = false); + + /// Construct with an external io_context — uses an asio timer instead + /// of a dedicated thread for WebSocket stats broadcasting. + /// The caller must ensure the io_context outlives this service and that + /// its run loop is active. + HttpStatsService(asio::io_context& IoContext, bool EnableWebSockets = true); + ~HttpStatsService(); + void Shutdown(); + virtual const char* BaseUri() const override; virtual void HandleRequest(HttpServerRequest& Request) override; virtual void RegisterHandler(std::string_view Id, IHttpStatsProvider& Provider) override; virtual void UnregisterHandler(std::string_view Id, IHttpStatsProvider& Provider) override; + // IWebSocketHandler + void OnWebSocketOpen(Ref<WebSocketConnection> Connection) override; + void OnWebSocketMessage(WebSocketConnection& Conn, const WebSocketMessage& Msg) override; + void OnWebSocketClose(WebSocketConnection& Conn, uint16_t Code, std::string_view Reason) override; + private: LoggerRef m_Log; HttpRequestRouter m_Router; @@ -28,6 +55,22 @@ private: RwLock m_Lock; std::map<std::string, IHttpStatsProvider*> m_Providers; + + // WebSocket push + RwLock m_WsConnectionsLock; + std::vector<Ref<WebSocketConnection>> m_WsConnections; + std::atomic<bool> m_PushEnabled{false}; + + void BroadcastStats(); + + // Thread-based push (when no io_context is provided) + std::thread m_PushThread; + Event m_PushEvent; + void PushThreadFunction(); + + // Timer-based push (when an io_context is provided) + std::unique_ptr<asio::steady_timer> m_PushTimer; + void EnqueuePushTimer(); }; } // namespace zen diff --git a/src/zenhttp/monitoring/httpstats.cpp b/src/zenhttp/monitoring/httpstats.cpp index b097a0d3f..2370def0c 100644 --- a/src/zenhttp/monitoring/httpstats.cpp +++ b/src/zenhttp/monitoring/httpstats.cpp @@ -3,15 +3,57 @@ #include "zenhttp/httpstats.h" #include <zencore/compactbinarybuilder.h> +#include <zencore/string.h> +#include <zencore/thread.h> +#include <zencore/trace.h> namespace zen { -HttpStatsService::HttpStatsService() : m_Log(logging::Get("stats")) +HttpStatsService::HttpStatsService(bool EnableWebSockets) : m_Log(logging::Get("stats")) { + if (EnableWebSockets) + { + m_PushEnabled.store(true); + m_PushThread = std::thread([this] { PushThreadFunction(); }); + } +} + +HttpStatsService::HttpStatsService(asio::io_context& IoContext, bool EnableWebSockets) : m_Log(logging::Get("stats")) +{ + if (EnableWebSockets) + { + m_PushEnabled.store(true); + m_PushTimer = std::make_unique<asio::steady_timer>(IoContext); + EnqueuePushTimer(); + } } HttpStatsService::~HttpStatsService() { + Shutdown(); +} + +void +HttpStatsService::Shutdown() +{ + if (!m_PushEnabled.exchange(false)) + { + return; + } + + if (m_PushTimer) + { + m_PushTimer->cancel(); + m_PushTimer.reset(); + } + + if (m_PushThread.joinable()) + { + m_PushEvent.Set(); + m_PushThread.join(); + } + + m_WsConnectionsLock.WithExclusiveLock([&] { m_WsConnections.clear(); }); } const char* @@ -39,6 +81,7 @@ HttpStatsService::UnregisterHandler(std::string_view Id, IHttpStatsProvider& Pro void HttpStatsService::HandleRequest(HttpServerRequest& Request) { + ZEN_TRACE_CPU("HttpStatsService::HandleRequest"); using namespace std::literals; std::string_view Key = Request.RelativeUri(); @@ -89,4 +132,154 @@ HttpStatsService::HandleRequest(HttpServerRequest& Request) } } +////////////////////////////////////////////////////////////////////////// +// +// IWebSocketHandler +// + +void +HttpStatsService::OnWebSocketOpen(Ref<WebSocketConnection> Connection) +{ + ZEN_TRACE_CPU("HttpStatsService::OnWebSocketOpen"); + ZEN_INFO("Stats WebSocket client connected"); + + m_WsConnectionsLock.WithExclusiveLock([&] { m_WsConnections.push_back(std::move(Connection)); }); + + // Send initial state immediately + if (m_PushThread.joinable()) + { + m_PushEvent.Set(); + } +} + +void +HttpStatsService::OnWebSocketMessage(WebSocketConnection& /*Conn*/, const WebSocketMessage& /*Msg*/) +{ + // No client-to-server messages expected +} + +void +HttpStatsService::OnWebSocketClose(WebSocketConnection& Conn, [[maybe_unused]] uint16_t Code, [[maybe_unused]] std::string_view Reason) +{ + ZEN_TRACE_CPU("HttpStatsService::OnWebSocketClose"); + ZEN_INFO("Stats WebSocket client disconnected (code {})", Code); + + m_WsConnectionsLock.WithExclusiveLock([&] { + auto It = std::remove_if(m_WsConnections.begin(), m_WsConnections.end(), [&Conn](const Ref<WebSocketConnection>& C) { + return C.Get() == &Conn; + }); + m_WsConnections.erase(It, m_WsConnections.end()); + }); +} + +////////////////////////////////////////////////////////////////////////// +// +// Stats broadcast +// + +void +HttpStatsService::BroadcastStats() +{ + ZEN_TRACE_CPU("HttpStatsService::BroadcastStats"); + std::vector<Ref<WebSocketConnection>> Connections; + m_WsConnectionsLock.WithSharedLock([&] { Connections = m_WsConnections; }); + + if (Connections.empty()) + { + return; + } + + // Collect stats from all providers + ExtendableStringBuilder<4096> JsonBuilder; + JsonBuilder.Append("{"); + + bool First = true; + { + RwLock::SharedLockScope _(m_Lock); + for (auto& [Id, Provider] : m_Providers) + { + CbObject Stats = Provider->CollectStats(); + if (!Stats) + { + continue; + } + + if (!First) + { + JsonBuilder.Append(","); + } + First = false; + + // Emit as "provider_id": { ... } + JsonBuilder.Append("\""); + JsonBuilder.Append(Id); + JsonBuilder.Append("\":"); + + ExtendableStringBuilder<2048> StatsJson; + Stats.ToJson(StatsJson); + JsonBuilder.Append(StatsJson.ToView()); + } + } + + JsonBuilder.Append("}"); + + std::string_view Json = JsonBuilder.ToView(); + for (auto& Conn : Connections) + { + if (Conn->IsOpen()) + { + Conn->SendText(Json); + } + } +} + +////////////////////////////////////////////////////////////////////////// +// +// Thread-based push (fallback when no io_context) +// + +void +HttpStatsService::PushThreadFunction() +{ + SetCurrentThreadName("stats_ws_push"); + + while (m_PushEnabled.load()) + { + m_PushEvent.Wait(5000); + m_PushEvent.Reset(); + + if (!m_PushEnabled.load()) + { + break; + } + + BroadcastStats(); + } +} + +////////////////////////////////////////////////////////////////////////// +// +// Timer-based push (when io_context is provided) +// + +void +HttpStatsService::EnqueuePushTimer() +{ + if (!m_PushTimer) + { + return; + } + + m_PushTimer->expires_after(std::chrono::seconds(5)); + m_PushTimer->async_wait([this](const asio::error_code& Ec) { + if (Ec) + { + return; + } + + BroadcastStats(); + EnqueuePushTimer(); + }); +} + } // namespace zen diff --git a/src/zenhttp/servers/httpasio.cpp b/src/zenhttp/servers/httpasio.cpp index 2cf051d14..f5178ebe8 100644 --- a/src/zenhttp/servers/httpasio.cpp +++ b/src/zenhttp/servers/httpasio.cpp @@ -531,6 +531,8 @@ public: std::atomic<uint64_t> m_TotalBytesReceived{0}; std::atomic<uint64_t> m_TotalBytesSent{0}; + + HttpServer* m_HttpServer = nullptr; }; /** @@ -949,6 +951,7 @@ private: void OnDataReceived(const asio::error_code& Ec, std::size_t ByteCount); void OnResponseDataSent(const asio::error_code& Ec, std::size_t ByteCount, uint32_t RequestNumber, HttpResponse* ResponseToPop); void CloseConnection(); + void SendInlineResponse(uint32_t RequestNumber, std::string_view StatusLine, std::string_view Headers = {}, std::string_view Body = {}); HttpAsioServerImpl& m_Server; asio::streambuf m_RequestBuffer; @@ -1167,6 +1170,38 @@ HttpServerConnection::CloseConnection() } void +HttpServerConnection::SendInlineResponse(uint32_t RequestNumber, + std::string_view StatusLine, + std::string_view Headers, + std::string_view Body) +{ + ExtendableStringBuilder<256> ResponseBuilder; + ResponseBuilder << "HTTP/1.1 " << StatusLine << "\r\n"; + if (!Headers.empty()) + { + ResponseBuilder << Headers; + } + if (!m_RequestData.IsKeepAlive()) + { + ResponseBuilder << "Connection: close\r\n"; + } + ResponseBuilder << "\r\n"; + if (!Body.empty()) + { + ResponseBuilder << Body; + } + auto ResponseView = ResponseBuilder.ToView(); + IoBuffer ResponseData(IoBuffer::Clone, ResponseView.data(), ResponseView.size()); + auto Buffer = asio::buffer(ResponseData.GetData(), ResponseData.GetSize()); + asio::async_write( + *m_Socket.get(), + Buffer, + [Conn = AsSharedPtr(), RequestNumber, Response = std::move(ResponseData)](const asio::error_code& Ec, std::size_t ByteCount) { + Conn->OnResponseDataSent(Ec, ByteCount, RequestNumber, /* ResponseToPop */ nullptr); + }); +} + +void HttpServerConnection::HandleRequest() { ZEN_MEMSCOPE(GetHttpasioTag()); @@ -1204,7 +1239,9 @@ HttpServerConnection::HandleRequest() return; } - Ref<WsAsioConnection> WsConn(new WsAsioConnection(std::move(Conn->m_Socket), *WsHandler)); + Conn->m_Server.m_HttpServer->OnWebSocketConnectionOpened(); + Ref<WsAsioConnection> WsConn( + new WsAsioConnection(std::move(Conn->m_Socket), *WsHandler, Conn->m_Server.m_HttpServer)); Ref<WebSocketConnection> WsConnRef(WsConn.Get()); WsHandler->OnWebSocketOpen(std::move(WsConnRef)); @@ -1241,6 +1278,8 @@ HttpServerConnection::HandleRequest() { ZEN_TRACE_CPU("asio::HandleRequest"); + m_Server.m_HttpServer->MarkRequest(); + auto RemoteEndpoint = m_Socket->remote_endpoint(); bool IsLocalConnection = m_Socket->local_endpoint().address() == RemoteEndpoint.address(); @@ -1378,51 +1417,24 @@ HttpServerConnection::HandleRequest() } } - if (m_RequestData.RequestVerb() == HttpVerb::kHead) + // If a default redirect is configured and the request is for the root path, send a 302 + std::string_view DefaultRedirect = m_Server.m_HttpServer->GetDefaultRedirect(); + if (!DefaultRedirect.empty() && (m_RequestData.Url() == "/" || m_RequestData.Url().empty())) { - std::string_view Response = - "HTTP/1.1 404 NOT FOUND\r\n" - "\r\n"sv; - - if (!m_RequestData.IsKeepAlive()) - { - Response = - "HTTP/1.1 404 NOT FOUND\r\n" - "Connection: close\r\n" - "\r\n"sv; - } - - asio::async_write(*m_Socket.get(), - asio::buffer(Response), - [Conn = AsSharedPtr(), RequestNumber](const asio::error_code& Ec, std::size_t ByteCount) { - Conn->OnResponseDataSent(Ec, ByteCount, RequestNumber, /* ResponseToPop */ nullptr); - }); + ExtendableStringBuilder<128> Headers; + Headers << "Location: " << DefaultRedirect << "\r\nContent-Length: 0\r\n"; + SendInlineResponse(RequestNumber, "302 Found"sv, Headers.ToView()); + } + else if (m_RequestData.RequestVerb() == HttpVerb::kHead) + { + SendInlineResponse(RequestNumber, "404 NOT FOUND"sv); } else { - std::string_view Response = - "HTTP/1.1 404 NOT FOUND\r\n" - "Content-Length: 23\r\n" - "Content-Type: text/plain\r\n" - "\r\n" - "No suitable route found"sv; - - if (!m_RequestData.IsKeepAlive()) - { - Response = - "HTTP/1.1 404 NOT FOUND\r\n" - "Content-Length: 23\r\n" - "Content-Type: text/plain\r\n" - "Connection: close\r\n" - "\r\n" - "No suitable route found"sv; - } - - asio::async_write(*m_Socket.get(), - asio::buffer(Response), - [Conn = AsSharedPtr(), RequestNumber](const asio::error_code& Ec, std::size_t ByteCount) { - Conn->OnResponseDataSent(Ec, ByteCount, RequestNumber, /* ResponseToPop */ nullptr); - }); + SendInlineResponse(RequestNumber, + "404 NOT FOUND"sv, + "Content-Length: 23\r\nContent-Type: text/plain\r\n"sv, + "No suitable route found"sv); } } @@ -1448,8 +1460,11 @@ struct HttpAcceptor m_Acceptor.set_option(exclusive_address(true)); m_AlternateProtocolAcceptor.set_option(exclusive_address(true)); #else // ZEN_PLATFORM_WINDOWS - m_Acceptor.set_option(asio::socket_base::reuse_address(false)); - m_AlternateProtocolAcceptor.set_option(asio::socket_base::reuse_address(false)); + // Allow binding to a port in TIME_WAIT so the server can restart immediately + // after a previous instance exits. On Linux this does not allow two processes + // to actively listen on the same port simultaneously. + m_Acceptor.set_option(asio::socket_base::reuse_address(true)); + m_AlternateProtocolAcceptor.set_option(asio::socket_base::reuse_address(true)); #endif // ZEN_PLATFORM_WINDOWS m_Acceptor.set_option(asio::ip::tcp::no_delay(true)); @@ -2092,6 +2107,7 @@ HttpAsioServer::HttpAsioServer(const AsioConfig& Config) : m_InitialConfig(Config) , m_Impl(std::make_unique<asio_http::HttpAsioServerImpl>()) { + m_Impl->m_HttpServer = this; ZEN_DEBUG("Request object size: {} ({:#x})", sizeof(HttpRequestParser), sizeof(HttpRequestParser)); } diff --git a/src/zenhttp/servers/httpplugin.cpp b/src/zenhttp/servers/httpplugin.cpp index 021b941bd..4bf8c61bb 100644 --- a/src/zenhttp/servers/httpplugin.cpp +++ b/src/zenhttp/servers/httpplugin.cpp @@ -378,6 +378,8 @@ HttpPluginConnectionHandler::HandleRequest() { ZEN_TRACE_CPU("http_plugin::HandleRequest"); + m_Server->MarkRequest(); + HttpPluginServerRequest Request(m_RequestParser, *Service, m_RequestParser.Body()); const HttpVerb RequestVerb = Request.RequestVerb(); diff --git a/src/zenhttp/servers/httpsys.cpp b/src/zenhttp/servers/httpsys.cpp index cf639c114..dfe6bb6aa 100644 --- a/src/zenhttp/servers/httpsys.cpp +++ b/src/zenhttp/servers/httpsys.cpp @@ -451,6 +451,8 @@ public: inline uint16_t GetResponseCode() const { return m_ResponseCode; } inline int64_t GetResponseBodySize() const { return m_TotalDataSize; } + void SetLocationHeader(std::string_view Location) { m_LocationHeader = Location; } + private: eastl::fixed_vector<HTTP_DATA_CHUNK, 16> m_HttpDataChunks; uint64_t m_TotalDataSize = 0; // Sum of all chunk sizes @@ -460,6 +462,7 @@ private: bool m_IsInitialResponse = true; HttpContentType m_ContentType = HttpContentType::kBinary; eastl::fixed_vector<IoBuffer, 16> m_DataBuffers; + std::string m_LocationHeader; void InitializeForPayload(uint16_t ResponseCode, std::span<IoBuffer> Blobs); }; @@ -715,6 +718,15 @@ HttpMessageResponseRequest::IssueRequest(std::error_code& ErrorCode) ContentTypeHeader->pRawValue = ContentTypeString.data(); ContentTypeHeader->RawValueLength = (USHORT)ContentTypeString.size(); + // Location header (for redirects) + + if (!m_LocationHeader.empty()) + { + PHTTP_KNOWN_HEADER LocationHeader = &HttpResponse.Headers.KnownHeaders[HttpHeaderLocation]; + LocationHeader->pRawValue = m_LocationHeader.data(); + LocationHeader->RawValueLength = (USHORT)m_LocationHeader.size(); + } + std::string_view ReasonString = ReasonStringForHttpResultCode(m_ResponseCode); HttpResponse.StatusCode = m_ResponseCode; @@ -916,7 +928,10 @@ HttpAsyncWorkRequest::HandleCompletion(ULONG IoResult, ULONG_PTR NumberOfBytesTr ZEN_UNUSED(IoResult, NumberOfBytesTransferred); - ZEN_WARN("Unexpected I/O completion during async work! IoResult: {}, NumberOfBytesTransferred: {}", IoResult, NumberOfBytesTransferred); + ZEN_WARN("Unexpected I/O completion during async work! IoResult: {} ({:#x}), NumberOfBytesTransferred: {}", + GetSystemErrorAsString(IoResult), + IoResult, + NumberOfBytesTransferred); return this; } @@ -1083,7 +1098,10 @@ HttpSysServer::InitializeServer(int BasePort) if (Result != NO_ERROR) { - ZEN_ERROR("Failed to create server session for '{}': {:#x}", WideToUtf8(WildcardUrlPath), Result); + ZEN_ERROR("Failed to create server session for '{}': {} ({:#x})", + WideToUtf8(WildcardUrlPath), + GetSystemErrorAsString(Result), + Result); return 0; } @@ -1092,7 +1110,7 @@ HttpSysServer::InitializeServer(int BasePort) if (Result != NO_ERROR) { - ZEN_ERROR("Failed to create URL group for '{}': {:#x}", WideToUtf8(WildcardUrlPath), Result); + ZEN_ERROR("Failed to create URL group for '{}': {} ({:#x})", WideToUtf8(WildcardUrlPath), GetSystemErrorAsString(Result), Result); return 0; } @@ -1116,7 +1134,9 @@ HttpSysServer::InitializeServer(int BasePort) if ((Result == ERROR_SHARING_VIOLATION)) { - ZEN_INFO("Desired port {} is in use (HttpAddUrlToUrlGroup returned: {}), retrying", EffectivePort, Result); + ZEN_INFO("Desired port {} is in use (HttpAddUrlToUrlGroup returned: {}), retrying", + EffectivePort, + GetSystemErrorAsString(Result)); Sleep(500); Result = HttpAddUrlToUrlGroup(m_HttpUrlGroupId, WildcardUrlPath.c_str(), HTTP_URL_CONTEXT(0), 0); @@ -1138,7 +1158,9 @@ HttpSysServer::InitializeServer(int BasePort) { for (uint32_t Retries = 0; (Result == ERROR_SHARING_VIOLATION) && (Retries < 3); Retries++) { - ZEN_INFO("Desired port {} is in use (HttpAddUrlToUrlGroup returned: {}), retrying", EffectivePort, Result); + ZEN_INFO("Desired port {} is in use (HttpAddUrlToUrlGroup returned: {}), retrying", + EffectivePort, + GetSystemErrorAsString(Result)); Sleep(500); Result = HttpAddUrlToUrlGroup(m_HttpUrlGroupId, WildcardUrlPath.c_str(), HTTP_URL_CONTEXT(0), 0); } @@ -1173,17 +1195,18 @@ HttpSysServer::InitializeServer(int BasePort) const std::u8string_view Hosts[] = {u8"[::1]"sv, u8"localhost"sv, u8"127.0.0.1"sv}; - ULONG InternalResult = ERROR_SHARING_VIOLATION; - for (int PortOffset = 0; (InternalResult == ERROR_SHARING_VIOLATION) && (PortOffset < 10); ++PortOffset) + bool ShouldRetryNextPort = true; + for (int PortOffset = 0; ShouldRetryNextPort && (PortOffset < 10); ++PortOffset) { - EffectivePort = BasePort + (PortOffset * 100); + EffectivePort = BasePort + (PortOffset * 100); + ShouldRetryNextPort = false; for (const std::u8string_view Host : Hosts) { WideStringBuilder<64> LocalUrlPath; LocalUrlPath << u8"http://"sv << Host << u8":"sv << int64_t(EffectivePort) << u8"/"sv; - InternalResult = HttpAddUrlToUrlGroup(m_HttpUrlGroupId, LocalUrlPath.c_str(), HTTP_URL_CONTEXT(0), 0); + ULONG InternalResult = HttpAddUrlToUrlGroup(m_HttpUrlGroupId, LocalUrlPath.c_str(), HTTP_URL_CONTEXT(0), 0); if (InternalResult == NO_ERROR) { @@ -1191,11 +1214,25 @@ HttpSysServer::InitializeServer(int BasePort) m_BaseUris.push_back(LocalUrlPath.c_str()); } + else if (InternalResult == ERROR_SHARING_VIOLATION || InternalResult == ERROR_ACCESS_DENIED) + { + // Port may be owned by another process's wildcard registration (access denied) + // or actively in use (sharing violation) — retry on a different port + ShouldRetryNextPort = true; + } else { - break; + ZEN_WARN("Failed to register local handler '{}': {} ({:#x})", + WideToUtf8(LocalUrlPath), + GetSystemErrorAsString(InternalResult), + InternalResult); } } + + if (!m_BaseUris.empty()) + { + break; + } } } else @@ -1211,7 +1248,10 @@ HttpSysServer::InitializeServer(int BasePort) if (m_BaseUris.empty()) { - ZEN_ERROR("Failed to add base URL to URL group for '{}': {:#x}", WideToUtf8(WildcardUrlPath), Result); + ZEN_ERROR("Failed to add base URL to URL group for '{}': {} ({:#x})", + WideToUtf8(WildcardUrlPath), + GetSystemErrorAsString(Result), + Result); return 0; } @@ -1229,7 +1269,10 @@ HttpSysServer::InitializeServer(int BasePort) if (Result != NO_ERROR) { - ZEN_ERROR("Failed to create request queue for '{}': {:#x}", WideToUtf8(m_BaseUris.front()), Result); + ZEN_ERROR("Failed to create request queue for '{}': {} ({:#x})", + WideToUtf8(m_BaseUris.front()), + GetSystemErrorAsString(Result), + Result); return 0; } @@ -1241,7 +1284,10 @@ HttpSysServer::InitializeServer(int BasePort) if (Result != NO_ERROR) { - ZEN_ERROR("Failed to set server binding property for '{}': {:#x}", WideToUtf8(m_BaseUris.front()), Result); + ZEN_ERROR("Failed to set server binding property for '{}': {} ({:#x})", + WideToUtf8(m_BaseUris.front()), + GetSystemErrorAsString(Result), + Result); return 0; } @@ -1273,7 +1319,7 @@ HttpSysServer::InitializeServer(int BasePort) if (Result != NO_ERROR) { - ZEN_WARN("changing request queue length to {} failed: {}", QueueLength, Result); + ZEN_WARN("changing request queue length to {} failed: {} ({:#x})", QueueLength, GetSystemErrorAsString(Result), Result); } } @@ -1295,21 +1341,6 @@ HttpSysServer::InitializeServer(int BasePort) ZEN_INFO("Started http.sys server at '{}'", WideToUtf8(m_BaseUris.front())); } - // This is not available in all Windows SDK versions so for now we can't use recently - // released functionality. We should investigate how to get more recent SDK releases - // into the build - -# if 0 - if (HttpIsFeatureSupported(/* HttpFeatureHttp3 */ (HTTP_FEATURE_ID) 4)) - { - ZEN_DEBUG("HTTP3 is available"); - } - else - { - ZEN_DEBUG("HTTP3 is NOT available"); - } -# endif - return EffectivePort; } @@ -1695,6 +1726,8 @@ HttpSysTransaction::InvokeRequestHandler(HttpService& Service, IoBuffer Payload) { HttpSysServerRequest& ThisRequest = m_HandlerRequest.emplace(*this, Service, Payload); + m_HttpServer.MarkRequest(); + // Default request handling # if ZEN_WITH_OTEL @@ -2245,8 +2278,12 @@ InitialRequestHandler::HandleCompletion(ULONG IoResult, ULONG_PTR NumberOfBytesT if (SendResult == NO_ERROR) { - Ref<WsHttpSysConnection> WsConn( - new WsHttpSysConnection(RequestQueueHandle, RequestId, *WsHandler, Transaction().Iocp())); + Transaction().Server().OnWebSocketConnectionOpened(); + Ref<WsHttpSysConnection> WsConn(new WsHttpSysConnection(RequestQueueHandle, + RequestId, + *WsHandler, + Transaction().Iocp(), + &Transaction().Server())); Ref<WebSocketConnection> WsConnRef(WsConn.Get()); WsHandler->OnWebSocketOpen(std::move(WsConnRef)); @@ -2255,7 +2292,7 @@ InitialRequestHandler::HandleCompletion(ULONG IoResult, ULONG_PTR NumberOfBytesT return nullptr; } - ZEN_WARN("WebSocket 101 send failed: {}", SendResult); + ZEN_WARN("WebSocket 101 send failed: {} ({:#x})", GetSystemErrorAsString(SendResult), SendResult); // WebSocket upgrade failed — return nullptr since ServerRequest() // was never populated (no InvokeRequestHandler call) @@ -2330,6 +2367,18 @@ InitialRequestHandler::HandleCompletion(ULONG IoResult, ULONG_PTR NumberOfBytesT return new HttpMessageResponseRequest(Transaction(), 404, "Not found"sv); } } + else + { + // If a default redirect is configured and the request is for the root path, send a 302 + std::string_view DefaultRedirect = Transaction().Server().GetDefaultRedirect(); + std::string_view RawUrl(HttpReq->pRawUrl, HttpReq->RawUrlLength); + if (!DefaultRedirect.empty() && (RawUrl == "/" || RawUrl.empty())) + { + auto* Response = new HttpMessageResponseRequest(Transaction(), 302); + Response->SetLocationHeader(DefaultRedirect); + return Response; + } + } // Unable to route return new HttpMessageResponseRequest(Transaction(), 404, "No suitable route found"sv); diff --git a/src/zenhttp/servers/wsasio.cpp b/src/zenhttp/servers/wsasio.cpp index 3e31b58bc..b2543277a 100644 --- a/src/zenhttp/servers/wsasio.cpp +++ b/src/zenhttp/servers/wsasio.cpp @@ -4,6 +4,7 @@ #include "wsframecodec.h" #include <zencore/logging.h> +#include <zenhttp/httpserver.h> namespace zen::asio_http { @@ -16,15 +17,20 @@ WsLog() ////////////////////////////////////////////////////////////////////////// -WsAsioConnection::WsAsioConnection(std::unique_ptr<asio::ip::tcp::socket> Socket, IWebSocketHandler& Handler) +WsAsioConnection::WsAsioConnection(std::unique_ptr<asio::ip::tcp::socket> Socket, IWebSocketHandler& Handler, HttpServer* Server) : m_Socket(std::move(Socket)) , m_Handler(Handler) +, m_HttpServer(Server) { } WsAsioConnection::~WsAsioConnection() { m_IsOpen.store(false); + if (m_HttpServer) + { + m_HttpServer->OnWebSocketConnectionClosed(); + } } void @@ -101,6 +107,11 @@ WsAsioConnection::ProcessReceivedData() m_ReadBuffer.consume(Frame.BytesConsumed); + if (m_HttpServer) + { + m_HttpServer->OnWebSocketFrameReceived(Frame.BytesConsumed); + } + switch (Frame.Opcode) { case WebSocketOpcode::kText: @@ -219,6 +230,11 @@ WsAsioConnection::DoClose(uint16_t Code, std::string_view Reason) void WsAsioConnection::EnqueueWrite(std::vector<uint8_t> Frame) { + if (m_HttpServer) + { + m_HttpServer->OnWebSocketFrameSent(Frame.size()); + } + bool ShouldFlush = false; m_WriteLock.WithExclusiveLock([&] { diff --git a/src/zenhttp/servers/wsasio.h b/src/zenhttp/servers/wsasio.h index d8ffdc00a..e8bb3b1d2 100644 --- a/src/zenhttp/servers/wsasio.h +++ b/src/zenhttp/servers/wsasio.h @@ -14,6 +14,10 @@ ZEN_THIRD_PARTY_INCLUDES_END #include <memory> #include <vector> +namespace zen { +class HttpServer; +} // namespace zen + namespace zen::asio_http { /** @@ -27,10 +31,11 @@ namespace zen::asio_http { * connection alive for the duration of the async operation. The service layer * also holds a Ref<WebSocketConnection>. */ + class WsAsioConnection : public WebSocketConnection { public: - WsAsioConnection(std::unique_ptr<asio::ip::tcp::socket> Socket, IWebSocketHandler& Handler); + WsAsioConnection(std::unique_ptr<asio::ip::tcp::socket> Socket, IWebSocketHandler& Handler, HttpServer* Server); ~WsAsioConnection() override; /** @@ -58,6 +63,7 @@ private: std::unique_ptr<asio::ip::tcp::socket> m_Socket; IWebSocketHandler& m_Handler; + zen::HttpServer* m_HttpServer; asio::streambuf m_ReadBuffer; RwLock m_WriteLock; diff --git a/src/zenhttp/servers/wshttpsys.cpp b/src/zenhttp/servers/wshttpsys.cpp index 3408b64b3..af320172d 100644 --- a/src/zenhttp/servers/wshttpsys.cpp +++ b/src/zenhttp/servers/wshttpsys.cpp @@ -7,6 +7,7 @@ # include "wsframecodec.h" # include <zencore/logging.h> +# include <zenhttp/httpserver.h> namespace zen { @@ -19,11 +20,16 @@ WsHttpSysLog() ////////////////////////////////////////////////////////////////////////// -WsHttpSysConnection::WsHttpSysConnection(HANDLE RequestQueueHandle, HTTP_REQUEST_ID RequestId, IWebSocketHandler& Handler, PTP_IO Iocp) +WsHttpSysConnection::WsHttpSysConnection(HANDLE RequestQueueHandle, + HTTP_REQUEST_ID RequestId, + IWebSocketHandler& Handler, + PTP_IO Iocp, + HttpServer* Server) : m_RequestQueueHandle(RequestQueueHandle) , m_RequestId(RequestId) , m_Handler(Handler) , m_Iocp(Iocp) +, m_HttpServer(Server) , m_ReadBuffer(8192) { m_ReadIoContext.ContextType = HttpSysIoContext::Type::kWebSocketRead; @@ -40,6 +46,11 @@ WsHttpSysConnection::~WsHttpSysConnection() { Disconnect(); } + + if (m_HttpServer) + { + m_HttpServer->OnWebSocketConnectionClosed(); + } } void @@ -174,6 +185,11 @@ WsHttpSysConnection::ProcessReceivedData() // Remove consumed bytes m_Accumulated.erase(m_Accumulated.begin(), m_Accumulated.begin() + Frame.BytesConsumed); + if (m_HttpServer) + { + m_HttpServer->OnWebSocketFrameReceived(Frame.BytesConsumed); + } + switch (Frame.Opcode) { case WebSocketOpcode::kText: @@ -250,6 +266,11 @@ WsHttpSysConnection::ProcessReceivedData() void WsHttpSysConnection::EnqueueWrite(std::vector<uint8_t> Frame) { + if (m_HttpServer) + { + m_HttpServer->OnWebSocketFrameSent(Frame.size()); + } + bool ShouldFlush = false; { diff --git a/src/zenhttp/servers/wshttpsys.h b/src/zenhttp/servers/wshttpsys.h index d854289e0..6015e3873 100644 --- a/src/zenhttp/servers/wshttpsys.h +++ b/src/zenhttp/servers/wshttpsys.h @@ -19,6 +19,8 @@ namespace zen { +class HttpServer; + /** * WebSocket connection over an http.sys opaque-mode connection * @@ -37,7 +39,7 @@ namespace zen { class WsHttpSysConnection : public WebSocketConnection { public: - WsHttpSysConnection(HANDLE RequestQueueHandle, HTTP_REQUEST_ID RequestId, IWebSocketHandler& Handler, PTP_IO Iocp); + WsHttpSysConnection(HANDLE RequestQueueHandle, HTTP_REQUEST_ID RequestId, IWebSocketHandler& Handler, PTP_IO Iocp, HttpServer* Server); ~WsHttpSysConnection() override; /** @@ -75,6 +77,7 @@ private: HTTP_REQUEST_ID m_RequestId; IWebSocketHandler& m_Handler; PTP_IO m_Iocp; + HttpServer* m_HttpServer; // Tagged OVERLAPPED contexts for concurrent read and write HttpSysIoContext m_ReadIoContext{}; diff --git a/src/zenhttp/servers/wstest.cpp b/src/zenhttp/servers/wstest.cpp index fd023c490..2134e4ff1 100644 --- a/src/zenhttp/servers/wstest.cpp +++ b/src/zenhttp/servers/wstest.cpp @@ -767,13 +767,12 @@ namespace { void OnWsMessage(const WebSocketMessage& Msg) override { - m_MessageCount.fetch_add(1); - if (Msg.Opcode == WebSocketOpcode::kText) { std::string_view Text(static_cast<const char*>(Msg.Payload.Data()), Msg.Payload.Size()); m_LastMessage = std::string(Text); } + m_MessageCount.fetch_add(1); } void OnWsClose(uint16_t Code, [[maybe_unused]] std::string_view Reason) override diff --git a/src/zenremotestore/include/zenremotestore/jupiter/jupiterhost.h b/src/zenremotestore/include/zenremotestore/jupiter/jupiterhost.h index bb41f9efc..caf7ecd28 100644 --- a/src/zenremotestore/include/zenremotestore/jupiter/jupiterhost.h +++ b/src/zenremotestore/include/zenremotestore/jupiter/jupiterhost.h @@ -2,6 +2,7 @@ #pragma once +#include <cstdint> #include <string> #include <string_view> #include <vector> diff --git a/src/zenremotestore/projectstore/remoteprojectstore.cpp b/src/zenremotestore/projectstore/remoteprojectstore.cpp index d5c6286a8..4796b3f2a 100644 --- a/src/zenremotestore/projectstore/remoteprojectstore.cpp +++ b/src/zenremotestore/projectstore/remoteprojectstore.cpp @@ -2447,14 +2447,13 @@ BuildContainer(CidStore& ChunkStore, AsyncOnBlock, RemoteResult); ComposedBlocks++; + // Worker will set Blocks[BlockIndex] = Block (including ChunkRawHashes) under shared lock } else { ZEN_INFO("Bulk group {} attachments", ChunkCount); OnBlockChunks(std::move(ChunksInBlock)); - } - { - // We can share the lock as we are not resizing the vector and only touch BlockHash at our own index + // We can share the lock as we are not resizing the vector and only touch our own index RwLock::SharedLockScope _(BlocksLock); Blocks[BlockIndex].ChunkRawHashes = std::move(ChunkRawHashes); } diff --git a/src/zenserver/compute/computeserver.cpp b/src/zenserver/compute/computeserver.cpp index 802d06caf..c64f081b3 100644 --- a/src/zenserver/compute/computeserver.cpp +++ b/src/zenserver/compute/computeserver.cpp @@ -419,6 +419,8 @@ ZenComputeServer::Cleanup() m_IoRunner.join(); } + ShutdownServices(); + if (m_Http) { m_Http->Close(); @@ -570,8 +572,6 @@ ZenComputeServer::RegisterServices(const ZenComputeServerConfig& ServerConfig) ZEN_TRACE_CPU("ZenComputeServer::RegisterServices"); ZEN_UNUSED(ServerConfig); - m_Http->RegisterService(m_StatsService); - if (m_ApiService) { m_Http->RegisterService(*m_ApiService); diff --git a/src/zenserver/compute/computeserver.h b/src/zenserver/compute/computeserver.h index e4a6b01d5..8f4edc0f0 100644 --- a/src/zenserver/compute/computeserver.h +++ b/src/zenserver/compute/computeserver.h @@ -129,7 +129,6 @@ public: void Cleanup(); private: - HttpStatsService m_StatsService; GcManager m_GcManager; GcScheduler m_GcScheduler{m_GcManager}; std::unique_ptr<CidStore> m_CidStore; diff --git a/src/zenserver/diag/diagsvcs.cpp b/src/zenserver/diag/diagsvcs.cpp index 5fa81ff9f..dd4b8956c 100644 --- a/src/zenserver/diag/diagsvcs.cpp +++ b/src/zenserver/diag/diagsvcs.cpp @@ -9,6 +9,7 @@ #include <zencore/logging.h> #include <zencore/memory/llm.h> #include <zencore/string.h> +#include <zencore/system.h> #include <fstream> #include <sstream> @@ -51,6 +52,36 @@ HttpHealthService::HttpHealthService() Writer << "AbsLogPath"sv << m_HealthInfo.AbsLogPath.string(); Writer << "BuildVersion"sv << m_HealthInfo.BuildVersion; Writer << "HttpServerClass"sv << m_HealthInfo.HttpServerClass; + Writer << "Port"sv << m_HealthInfo.Port; + Writer << "Pid"sv << m_HealthInfo.Pid; + Writer << "IsDedicated"sv << m_HealthInfo.IsDedicated; + Writer << "StartTimeMs"sv << m_HealthInfo.StartTimeMs; + } + + Writer.BeginObject("RuntimeConfig"sv); + for (const auto& Opt : m_HealthInfo.RuntimeConfig) + { + Writer << Opt.first << Opt.second; + } + Writer.EndObject(); + + Writer.BeginObject("BuildConfig"sv); + for (const auto& Opt : m_HealthInfo.BuildOptions) + { + Writer << Opt.first << Opt.second; + } + Writer.EndObject(); + + Writer << "Hostname"sv << GetMachineName(); + Writer << "Platform"sv << GetRuntimePlatformName(); + Writer << "Arch"sv << GetCpuName(); + Writer << "OS"sv << GetOperatingSystemVersion(); + + { + auto Metrics = GetSystemMetrics(); + Writer.BeginObject("System"sv); + Describe(Metrics, Writer); + Writer.EndObject(); } HttpReq.WriteResponse(HttpResponseCode::OK, Writer.Save()); diff --git a/src/zenserver/diag/diagsvcs.h b/src/zenserver/diag/diagsvcs.h index 8cc869c83..87ce80b3c 100644 --- a/src/zenserver/diag/diagsvcs.h +++ b/src/zenserver/diag/diagsvcs.h @@ -6,6 +6,7 @@ #include <zenhttp/httpserver.h> #include <filesystem> +#include <vector> ////////////////////////////////////////////////////////////////////////// @@ -89,10 +90,16 @@ private: struct HealthServiceInfo { - std::filesystem::path DataRoot; - std::filesystem::path AbsLogPath; - std::string HttpServerClass; - std::string BuildVersion; + std::filesystem::path DataRoot; + std::filesystem::path AbsLogPath; + std::string HttpServerClass; + std::string BuildVersion; + int Port = 0; + int Pid = 0; + bool IsDedicated = false; + int64_t StartTimeMs = 0; + std::vector<std::pair<std::string_view, bool>> BuildOptions; + std::vector<std::pair<std::string_view, std::string>> RuntimeConfig; }; /** Health monitoring endpoint diff --git a/src/zenserver/diag/otlphttp.cpp b/src/zenserver/diag/otlphttp.cpp index 1434c9331..d6e24cbe3 100644 --- a/src/zenserver/diag/otlphttp.cpp +++ b/src/zenserver/diag/otlphttp.cpp @@ -10,11 +10,18 @@ #include <protozero/buffer_string.hpp> #include <protozero/pbf_builder.hpp> +#include <cstdio> + #if ZEN_WITH_OTEL namespace zen::logging { ////////////////////////////////////////////////////////////////////////// +// +// Important note: in general we cannot use ZEN_WARN/ZEN_ERROR etc in this +// file as it could cause recursive logging calls when we attempt to log +// errors from the OTLP HTTP client itself. +// OtelHttpProtobufSink::OtelHttpProtobufSink(const std::string_view& Uri) : m_OtelHttp(Uri) { @@ -36,14 +43,44 @@ OtelHttpProtobufSink::~OtelHttpProtobufSink() } void +OtelHttpProtobufSink::CheckPostResult(const HttpClient::Response& Result, const char* Endpoint) noexcept +{ + if (!Result.IsSuccess()) + { + uint32_t PrevFailures = m_ConsecutivePostFailures.fetch_add(1); + if (PrevFailures < kMaxReportedFailures) + { + fprintf(stderr, "OtelHttpProtobufSink: %s\n", Result.ErrorMessage(Endpoint).c_str()); + if (PrevFailures + 1 == kMaxReportedFailures) + { + fprintf(stderr, "OtelHttpProtobufSink: suppressing further export errors\n"); + } + } + } + else + { + m_ConsecutivePostFailures.store(0); + } +} + +void OtelHttpProtobufSink::RecordSpans(zen::otel::TraceId Trace, std::span<const zen::otel::Span*> Spans) { - std::string Data = m_Encoder.FormatOtelTrace(Trace, Spans); + try + { + std::string Data = m_Encoder.FormatOtelTrace(Trace, Spans); + + IoBuffer Payload{IoBuffer::Wrap, Data.data(), Data.size()}; + Payload.SetContentType(ZenContentType::kProtobuf); - IoBuffer Payload{IoBuffer::Wrap, Data.data(), Data.size()}; - Payload.SetContentType(ZenContentType::kProtobuf); + HttpClient::Response Result = m_OtelHttp.Post("/v1/traces", Payload); - auto Result = m_OtelHttp.Post("/v1/traces", Payload); + CheckPostResult(Result, "POST /v1/traces"); + } + catch (const std::exception& Ex) + { + fprintf(stderr, "OtelHttpProtobufSink: exception exporting traces: %s\n", Ex.what()); + } } void @@ -55,22 +92,20 @@ OtelHttpProtobufSink::TraceRecorder::RecordSpans(zen::otel::TraceId Trace, std:: void OtelHttpProtobufSink::Log(const LogMessage& Msg) { + try { std::string Data = m_Encoder.FormatOtelProtobuf(Msg); IoBuffer Payload{IoBuffer::Wrap, Data.data(), Data.size()}; Payload.SetContentType(ZenContentType::kProtobuf); - auto Result = m_OtelHttp.Post("/v1/logs", Payload); - } + HttpClient::Response Result = m_OtelHttp.Post("/v1/logs", Payload); + CheckPostResult(Result, "POST /v1/logs"); + } + catch (const std::exception& Ex) { - std::string Data = m_Encoder.FormatOtelMetrics(); - - IoBuffer Payload{IoBuffer::Wrap, Data.data(), Data.size()}; - Payload.SetContentType(ZenContentType::kProtobuf); - - auto Result = m_OtelHttp.Post("/v1/metrics", Payload); + fprintf(stderr, "OtelHttpProtobufSink: exception exporting logs: %s\n", Ex.what()); } } void diff --git a/src/zenserver/diag/otlphttp.h b/src/zenserver/diag/otlphttp.h index 8254af04d..64b3dbc87 100644 --- a/src/zenserver/diag/otlphttp.h +++ b/src/zenserver/diag/otlphttp.h @@ -9,6 +9,8 @@ #include <zentelemetry/otlpencoder.h> #include <zentelemetry/otlptrace.h> +#include <atomic> + #if ZEN_WITH_OTEL namespace zen::logging { @@ -36,6 +38,7 @@ private: virtual void SetFormatter(std::unique_ptr<Formatter>) override {} void RecordSpans(zen::otel::TraceId Trace, std::span<const zen::otel::Span*> Spans); + void CheckPostResult(const HttpClient::Response& Result, const char* Endpoint) noexcept; // This is just a thin wrapper to call back into the sink while participating in // reference counting from the OTEL trace back-end @@ -53,9 +56,13 @@ private: OtelHttpProtobufSink* m_Sink; }; - HttpClient m_OtelHttp; - OtlpEncoder m_Encoder; - Ref<TraceRecorder> m_TraceRecorder; + static constexpr uint32_t kMaxReportedFailures = 5; + + RwLock m_Lock; + std::atomic<uint32_t> m_ConsecutivePostFailures{0}; + HttpClient m_OtelHttp; + OtlpEncoder m_Encoder; + Ref<TraceRecorder> m_TraceRecorder; }; } // namespace zen::logging diff --git a/src/zenserver/frontend/html.zip b/src/zenserver/frontend/html.zip Binary files differindex c167cc70e..84472ff08 100644 --- a/src/zenserver/frontend/html.zip +++ b/src/zenserver/frontend/html.zip diff --git a/src/zenserver/frontend/html/compute/banner.js b/src/zenserver/frontend/html/banner.js index 61c7ce21f..2e878dedf 100644 --- a/src/zenserver/frontend/html/compute/banner.js +++ b/src/zenserver/frontend/html/banner.js @@ -1,8 +1,8 @@ /** - * zen-banner.js — Zen Compute dashboard banner Web Component + * zen-banner.js — Zen dashboard banner Web Component * * Usage: - * <script src="/components/zen-banner.js" defer></script> + * <script src="banner.js" defer></script> * * <zen-banner></zen-banner> * <zen-banner variant="compact"></zen-banner> @@ -19,7 +19,7 @@ class ZenBanner extends HTMLElement { static get observedAttributes() { - return ['variant', 'cluster-status', 'load', 'tagline', 'subtitle']; + return ['variant', 'cluster-status', 'load', 'tagline', 'subtitle', 'logo-src']; } attributeChangedCallback() { @@ -40,6 +40,7 @@ class ZenBanner extends HTMLElement { get _load() { return this.getAttribute('load'); } // null → hidden get _tagline() { return this.getAttribute('tagline'); } // null → default get _subtitle() { return this.getAttribute('subtitle'); } // null → "COMPUTE" + get _logoSrc() { return this.getAttribute('logo-src'); } // null → inline SVG get _statusColor() { return { nominal: '#7ecfb8', degraded: '#d4a84b', offline: '#c0504d' }[this._status] ?? '#7ecfb8'; @@ -97,8 +98,8 @@ class ZenBanner extends HTMLElement { .banner { width: 100%; height: ${height}; - background: #0b0d10; - border: 1px solid #1e2330; + background: var(--theme_g3, #0b0d10); + border: 1px solid var(--theme_g2, #1e2330); border-radius: 6px; display: flex; align-items: center; @@ -106,6 +107,9 @@ class ZenBanner extends HTMLElement { gap: ${gap}; position: relative; overflow: hidden; + text-decoration: none; + color: inherit; + cursor: pointer; } /* scan-line texture */ @@ -140,12 +144,12 @@ class ZenBanner extends HTMLElement { height: ${markSize}; } - .logo-mark svg { width: 100%; height: 100%; } + .logo-mark svg, .logo-mark img { width: 100%; height: 100%; object-fit: contain; } .divider { width: 1px; height: ${divH}; - background: linear-gradient(to bottom, transparent, #2a3040, transparent); + background: linear-gradient(to bottom, transparent, var(--theme_g2, #2a3040), transparent); flex-shrink: 0; } @@ -159,7 +163,7 @@ class ZenBanner extends HTMLElement { font-weight: 700; font-size: ${nameSize}; letter-spacing: 0.12em; - color: #e8e4dc; + color: var(--theme_bright, #e8e4dc); text-transform: uppercase; line-height: 1; } @@ -171,7 +175,7 @@ class ZenBanner extends HTMLElement { font-weight: 300; font-size: ${tagSize}; letter-spacing: 0.3em; - color: #4a5a68; + color: var(--theme_faint, #4a5a68); text-transform: uppercase; } @@ -197,7 +201,7 @@ class ZenBanner extends HTMLElement { .status-lbl { font-size: 9px; letter-spacing: 0.18em; - color: #3a4555; + color: var(--theme_faint, #3a4555); text-transform: uppercase; } @@ -246,9 +250,11 @@ class ZenBanner extends HTMLElement { _html(compact) { const loadAttr = this._load; - const showStatus = !compact; + const hasCluster = !compact && this.hasAttribute('cluster-status'); + const hasLoad = !compact && loadAttr !== null; + const showRight = hasCluster || hasLoad; - const rightSide = showStatus ? ` + const circuit = showRight ? ` <svg class="circuit" width="60" height="60" viewBox="0 0 60 60" fill="none"> <path d="M5 30 H22 L28 18 H60" stroke="#7ecfb8" stroke-width="0.8"/> <path d="M5 38 H18 L24 46 H60" stroke="#7ecfb8" stroke-width="0.8"/> @@ -256,30 +262,37 @@ class ZenBanner extends HTMLElement { <circle cx="18" cy="38" r="2" fill="none" stroke="#7ecfb8" stroke-width="0.8"/> <circle cx="10" cy="30" r="1.2" fill="#7ecfb8"/> <circle cx="10" cy="38" r="1.2" fill="#7ecfb8"/> - </svg> + </svg>` : ''; - <div class="status-cluster"> + const clusterRow = hasCluster ? ` <div class="status-row"> <span class="status-lbl">Cluster</span> <div class="pill cluster"> <div class="dot cluster"></div> ${this._statusLabel} </div> - </div> - ${loadAttr !== null ? ` + </div>` : ''; + + const loadRow = hasLoad ? ` <div class="status-row"> <span class="status-lbl">Load</span> <div class="pill load-pill"> <div class="dot load-dot"></div> ${parseInt(loadAttr, 10)} % </div> - </div>` : ''} + </div>` : ''; + + const rightSide = showRight ? ` + ${circuit} + <div class="status-cluster"> + ${clusterRow} + ${loadRow} </div> ` : ''; return ` - <div class="banner"> - <div class="logo-mark">${this._svgMark()}</div> + <a class="banner" href="/dashboard/"> + <div class="logo-mark">${this._logoMark()}</div> <div class="divider"></div> <div class="text-block"> <div class="wordmark">ZEN<span> ${this._subtitle ?? 'COMPUTE'}</span></div> @@ -287,7 +300,7 @@ class ZenBanner extends HTMLElement { </div> <div class="spacer"></div> ${rightSide} - </div> + </a> `; } @@ -295,7 +308,11 @@ class ZenBanner extends HTMLElement { // SVG logo mark // ───────────────────────────────────────────── - _svgMark() { + _logoMark() { + const src = this._logoSrc; + if (src) { + return `<img src="${src}" alt="zen">`; + } return ` <svg viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="26" cy="26" r="22" stroke="#2a3a48" stroke-width="1.5"/> diff --git a/src/zenserver/frontend/html/compute/compute.html b/src/zenserver/frontend/html/compute/compute.html index 1e101d839..66c20175f 100644 --- a/src/zenserver/frontend/html/compute/compute.html +++ b/src/zenserver/frontend/html/compute/compute.html @@ -5,101 +5,13 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Zen Compute Dashboard</title> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> - <script src="banner.js" defer></script> - <script src="nav.js" defer></script> + <link rel="stylesheet" type="text/css" href="../zen.css" /> + <script src="../theme.js"></script> + <script src="../banner.js" defer></script> + <script src="../nav.js" defer></script> <style> - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: #0d1117; - color: #c9d1d9; - padding: 20px; - } - - .container { - max-width: 1400px; - margin: 0 auto; - } - - h1 { - font-size: 32px; - font-weight: 600; - margin-bottom: 10px; - color: #f0f6fc; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 30px; - } - - .health-indicator { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - padding: 8px 16px; - border-radius: 6px; - background: #161b22; - border: 1px solid #30363d; - } - - .health-indicator .status-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: #6e7681; - } - - .health-indicator.healthy .status-dot { - background: #3fb950; - } - - .health-indicator.unhealthy .status-dot { - background: #f85149; - } - .grid { - display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - margin-bottom: 30px; - } - - .card { - background: #161b22; - border: 1px solid #30363d; - border-radius: 6px; - padding: 20px; - } - - .card-title { - font-size: 14px; - font-weight: 600; - color: #8b949e; - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .metric-value { - font-size: 36px; - font-weight: 600; - color: #f0f6fc; - line-height: 1; - } - - .metric-label { - font-size: 12px; - color: #8b949e; - margin-top: 4px; } .chart-container { @@ -113,7 +25,7 @@ justify-content: space-between; margin-bottom: 12px; padding: 8px 0; - border-bottom: 1px solid #21262d; + border-bottom: 1px solid var(--theme_border_subtle); } .stats-row:last-child { @@ -122,12 +34,12 @@ } .stats-label { - color: #8b949e; + color: var(--theme_g1); font-size: 13px; } .stats-value { - color: #f0f6fc; + color: var(--theme_bright); font-weight: 600; font-size: 13px; } @@ -146,77 +58,39 @@ .rate-value { font-size: 20px; font-weight: 600; - color: #58a6ff; + color: var(--theme_p0); } .rate-label { font-size: 11px; - color: #8b949e; + color: var(--theme_g1); margin-top: 4px; text-transform: uppercase; } - .progress-bar { - width: 100%; - height: 8px; - background: #21262d; - border-radius: 4px; - overflow: hidden; - margin-top: 8px; - } - - .progress-fill { - height: 100%; - background: #58a6ff; - transition: width 0.3s ease; - } - - .timestamp { - font-size: 12px; - color: #6e7681; - text-align: right; - margin-top: 30px; - } - - .error { - color: #f85149; - padding: 12px; - background: #1c1c1c; - border-radius: 6px; - margin: 20px 0; - font-size: 13px; - } - - .section-title { - font-size: 20px; - font-weight: 600; - margin-bottom: 20px; - color: #f0f6fc; - } - .worker-row { cursor: pointer; transition: background 0.15s; } .worker-row:hover { - background: #1c2128; + background: var(--theme_p4); } .worker-row.selected { - background: #1f2d3d; + background: var(--theme_p3); } .worker-detail { margin-top: 20px; - border-top: 1px solid #30363d; + border-top: 1px solid var(--theme_g2); padding-top: 16px; } .worker-detail-title { font-size: 15px; font-weight: 600; - color: #f0f6fc; + color: var(--theme_bright); margin-bottom: 12px; } @@ -227,7 +101,7 @@ .detail-section-label { font-size: 11px; font-weight: 600; - color: #8b949e; + color: var(--theme_g1); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; @@ -241,13 +115,13 @@ .detail-table td { padding: 4px 8px; - color: #c9d1d9; - border-bottom: 1px solid #21262d; + color: var(--theme_g0); + border-bottom: 1px solid var(--theme_border_subtle); vertical-align: top; } .detail-table td:first-child { - color: #8b949e; + color: var(--theme_g1); width: 40%; font-family: monospace; } @@ -259,42 +133,25 @@ .detail-mono { font-family: monospace; font-size: 11px; - color: #8b949e; + color: var(--theme_g1); } .detail-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; - background: #21262d; - color: #c9d1d9; + background: var(--theme_border_subtle); + color: var(--theme_g0); font-size: 11px; margin: 2px 4px 2px 0; } - - .status-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - } - - .status-badge.success { - background: rgba(63, 185, 80, 0.15); - color: #3fb950; - } - - .status-badge.failure { - background: rgba(248, 81, 73, 0.15); - color: #f85149; - } </style> </head> <body> - <div class="container"> - <zen-banner cluster-status="nominal" load="0" tagline="Node Overview"></zen-banner> + <div class="container" style="max-width: 1400px; margin: 0 auto;"> + <zen-banner cluster-status="nominal" load="0" tagline="Node Overview" logo-src="../favicon.ico"></zen-banner> <zen-nav> + <a href="/dashboard/">Home</a> <a href="compute.html">Node</a> <a href="orchestrator.html">Orchestrator</a> </zen-nav> @@ -369,15 +226,15 @@ <span class="stats-value" id="worker-count">-</span> </div> <div id="worker-table-container" style="margin-top: 16px; display: none;"> - <table id="worker-table" style="width: 100%; border-collapse: collapse; font-size: 13px;"> + <table id="worker-table"> <thead> <tr> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Name</th> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Platform</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Cores</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Timeout</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Functions</th> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Worker ID</th> + <th>Name</th> + <th>Platform</th> + <th style="text-align: right;">Cores</th> + <th style="text-align: right;">Timeout</th> + <th style="text-align: right;">Functions</th> + <th>Worker ID</th> </tr> </thead> <tbody id="worker-table-body"></tbody> @@ -390,19 +247,19 @@ <div class="section-title">Queues</div> <div class="card" style="margin-bottom: 30px;"> <div class="card-title">Queue Status</div> - <div id="queue-list-empty" style="color: #6e7681; font-size: 13px;">No queues.</div> + <div id="queue-list-empty" class="empty-state" style="text-align: left;">No queues.</div> <div id="queue-list-container" style="display: none;"> - <table id="queue-list-table" style="width: 100%; border-collapse: collapse; font-size: 13px;"> + <table id="queue-list-table"> <thead> <tr> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 60px;">ID</th> - <th style="text-align: center; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Status</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Active</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Completed</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Failed</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Abandoned</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Cancelled</th> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Token</th> + <th style="text-align: right; width: 60px;">ID</th> + <th style="text-align: center; width: 80px;">Status</th> + <th style="text-align: right;">Active</th> + <th style="text-align: right;">Completed</th> + <th style="text-align: right;">Failed</th> + <th style="text-align: right;">Abandoned</th> + <th style="text-align: right;">Cancelled</th> + <th>Token</th> </tr> </thead> <tbody id="queue-list-body"></tbody> @@ -414,20 +271,20 @@ <div class="section-title">Recent Actions</div> <div class="card" style="margin-bottom: 30px;"> <div class="card-title">Action History</div> - <div id="action-history-empty" style="color: #6e7681; font-size: 13px;">No actions recorded yet.</div> + <div id="action-history-empty" class="empty-state" style="text-align: left;">No actions recorded yet.</div> <div id="action-history-container" style="display: none;"> - <table id="action-history-table" style="width: 100%; border-collapse: collapse; font-size: 13px;"> + <table id="action-history-table"> <thead> <tr> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 60px;">LSN</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 60px;">Queue</th> - <th style="text-align: center; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 70px;">Status</th> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Function</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Started</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Finished</th> - <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Duration</th> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Worker ID</th> - <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Action ID</th> + <th style="text-align: right; width: 60px;">LSN</th> + <th style="text-align: right; width: 60px;">Queue</th> + <th style="text-align: center; width: 70px;">Status</th> + <th>Function</th> + <th style="text-align: right; width: 80px;">Started</th> + <th style="text-align: right; width: 80px;">Finished</th> + <th style="text-align: right; width: 80px;">Duration</th> + <th>Worker ID</th> + <th>Action ID</th> </tr> </thead> <tbody id="action-history-body"></tbody> @@ -766,7 +623,7 @@ // Functions const functions = desc.functions || []; - const functionsHtml = functions.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + const functionsHtml = functions.length === 0 ? '<span style="color:var(--theme_faint);font-size:12px;">none</span>' : `<table class="detail-table">${functions.map(f => `<tr><td>${escapeHtml(f.name || '-')}</td><td class="detail-mono">${escapeHtml(f.version || '-')}</td></tr>` ).join('')}</table>`; @@ -774,12 +631,12 @@ // Executables const executables = desc.executables || []; const totalExecSize = executables.reduce((sum, e) => sum + (e.size || 0), 0); - const execHtml = executables.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + const execHtml = executables.length === 0 ? '<span style="color:var(--theme_faint);font-size:12px;">none</span>' : `<table class="detail-table"> <tr style="font-size:11px;"> - <td style="color:#6e7681;padding-bottom:4px;">Path</td> - <td style="color:#6e7681;padding-bottom:4px;">Hash</td> - <td style="color:#6e7681;padding-bottom:4px;text-align:right;">Size</td> + <td style="color:var(--theme_faint);padding-bottom:4px;">Path</td> + <td style="color:var(--theme_faint);padding-bottom:4px;">Hash</td> + <td style="color:var(--theme_faint);padding-bottom:4px;text-align:right;">Size</td> </tr> ${executables.map(e => `<tr> @@ -788,28 +645,28 @@ <td style="text-align:right;white-space:nowrap;">${e.size != null ? formatBytes(e.size) : '-'}</td> </tr>` ).join('')} - <tr style="border-top:1px solid #30363d;"> - <td style="color:#8b949e;padding-top:6px;">Total</td> + <tr style="border-top:1px solid var(--theme_g2);"> + <td style="color:var(--theme_g1);padding-top:6px;">Total</td> <td></td> - <td style="text-align:right;white-space:nowrap;padding-top:6px;color:#f0f6fc;font-weight:600;">${formatBytes(totalExecSize)}</td> + <td style="text-align:right;white-space:nowrap;padding-top:6px;color:var(--theme_bright);font-weight:600;">${formatBytes(totalExecSize)}</td> </tr> </table>`; // Files const files = desc.files || []; - const filesHtml = files.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + const filesHtml = files.length === 0 ? '<span style="color:var(--theme_faint);font-size:12px;">none</span>' : `<table class="detail-table">${files.map(f => `<tr><td>${escapeHtml(f.name || f)}</td><td class="detail-mono">${escapeHtml(f.hash || '')}</td></tr>` ).join('')}</table>`; // Dirs const dirs = desc.dirs || []; - const dirsHtml = dirs.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + const dirsHtml = dirs.length === 0 ? '<span style="color:var(--theme_faint);font-size:12px;">none</span>' : dirs.map(d => `<span class="detail-tag">${escapeHtml(d)}</span>`).join(''); // Environment const env = desc.environment || []; - const envHtml = env.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + const envHtml = env.length === 0 ? '<span style="color:var(--theme_faint);font-size:12px;">none</span>' : env.map(e => `<span class="detail-tag">${escapeHtml(e)}</span>`).join(''); panel.innerHTML = ` @@ -880,16 +737,16 @@ const timeout = desc ? (desc.timeout != null ? desc.timeout + 's' : '-') : '-'; const functions = desc ? (desc.functions ? desc.functions.length : 0) : '-'; - const tr = document.createElement('tr'); + const tr = document.createElement('tr'); tr.className = 'worker-row' + (id === selectedWorkerId ? ' selected' : ''); tr.dataset.workerId = id; tr.innerHTML = ` - <td style="padding: 6px 8px; color: #f0f6fc; border-bottom: 1px solid #21262d;">${escapeHtml(name)}</td> - <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d;">${escapeHtml(host)}</td> - <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${escapeHtml(String(cores))}</td> - <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${escapeHtml(String(timeout))}</td> - <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${escapeHtml(String(functions))}</td> - <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${escapeHtml(id)}</td> + <td style="color: var(--theme_bright);">${escapeHtml(name)}</td> + <td>${escapeHtml(host)}</td> + <td style="text-align: right;">${escapeHtml(String(cores))}</td> + <td style="text-align: right;">${escapeHtml(String(timeout))}</td> + <td style="text-align: right;">${escapeHtml(String(functions))}</td> + <td style="color: var(--theme_g1); font-family: monospace; font-size: 11px;">${escapeHtml(id)}</td> `; tr.addEventListener('click', () => { document.querySelectorAll('.worker-row').forEach(r => r.classList.remove('selected')); @@ -963,24 +820,24 @@ const badge = q.state === 'cancelled' ? '<span class="status-badge failure">cancelled</span>' : q.state === 'draining' - ? '<span class="status-badge" style="background:rgba(210,153,34,0.15);color:#d29922;">draining</span>' + ? '<span class="status-badge" style="background:color-mix(in srgb, var(--theme_warn) 15%, transparent);color:var(--theme_warn);">draining</span>' : q.is_complete ? '<span class="status-badge success">complete</span>' - : '<span class="status-badge" style="background:rgba(88,166,255,0.15);color:#58a6ff;">active</span>'; + : '<span class="status-badge" style="background:color-mix(in srgb, var(--theme_p0) 15%, transparent);color:var(--theme_p0);">active</span>'; const token = q.queue_token ? `<span class="detail-mono">${escapeHtml(q.queue_token)}</span>` - : '<span style="color:#6e7681;">-</span>'; + : '<span style="color:var(--theme_faint);">-</span>'; const tr = document.createElement('tr'); tr.innerHTML = ` - <td style="padding: 6px 8px; color: #f0f6fc; border-bottom: 1px solid #21262d; text-align: right; font-family: monospace;">${escapeHtml(String(id))}</td> - <td style="padding: 6px 8px; border-bottom: 1px solid #21262d; text-align: center;">${badge}</td> - <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${q.active_count ?? 0}</td> - <td style="padding: 6px 8px; color: #3fb950; border-bottom: 1px solid #21262d; text-align: right;">${q.completed_count ?? 0}</td> - <td style="padding: 6px 8px; color: #f85149; border-bottom: 1px solid #21262d; text-align: right;">${q.failed_count ?? 0}</td> - <td style="padding: 6px 8px; color: #d29922; border-bottom: 1px solid #21262d; text-align: right;">${q.abandoned_count ?? 0}</td> - <td style="padding: 6px 8px; color: #f0883e; border-bottom: 1px solid #21262d; text-align: right;">${q.cancelled_count ?? 0}</td> - <td style="padding: 6px 8px; border-bottom: 1px solid #21262d;">${token}</td> + <td style="text-align: right; font-family: monospace; color: var(--theme_bright);">${escapeHtml(String(id))}</td> + <td style="text-align: center;">${badge}</td> + <td style="text-align: right;">${q.active_count ?? 0}</td> + <td style="text-align: right; color: var(--theme_ok);">${q.completed_count ?? 0}</td> + <td style="text-align: right; color: var(--theme_fail);">${q.failed_count ?? 0}</td> + <td style="text-align: right; color: var(--theme_warn);">${q.abandoned_count ?? 0}</td> + <td style="text-align: right; color: var(--theme_warn);">${q.cancelled_count ?? 0}</td> + <td>${token}</td> `; tbody.appendChild(tr); } @@ -1010,7 +867,7 @@ const lsn = entry.lsn ?? '-'; const succeeded = entry.succeeded; const badge = succeeded == null - ? '<span class="status-badge" style="background:#21262d;color:#8b949e;">unknown</span>' + ? '<span class="status-badge" style="background:var(--theme_border_subtle);color:var(--theme_g1);">unknown</span>' : succeeded ? '<span class="status-badge success">ok</span>' : '<span class="status-badge failure">failed</span>'; @@ -1024,20 +881,20 @@ const queueId = entry.queueId || 0; const queueCell = queueId - ? `<a href="/compute/queues/${queueId}" style="color: #58a6ff; text-decoration: none; font-family: monospace;">${escapeHtml(String(queueId))}</a>` - : '<span style="color: #6e7681;">-</span>'; + ? `<a href="/compute/queues/${queueId}" style="color: var(--theme_ln); text-decoration: none; font-family: monospace;">${escapeHtml(String(queueId))}</a>` + : '<span style="color: var(--theme_faint);">-</span>'; const tr = document.createElement('tr'); tr.innerHTML = ` - <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; text-align: right; font-family: monospace;">${escapeHtml(String(lsn))}</td> - <td style="padding: 6px 8px; border-bottom: 1px solid #21262d; text-align: right;">${queueCell}</td> - <td style="padding: 6px 8px; border-bottom: 1px solid #21262d; text-align: center;">${badge}</td> - <td style="padding: 6px 8px; color: #f0f6fc; border-bottom: 1px solid #21262d;">${escapeHtml(fn)}</td> - <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; text-align: right; font-size: 12px; white-space: nowrap;">${formatTime(startDate)}</td> - <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; text-align: right; font-size: 12px; white-space: nowrap;">${formatTime(endDate)}</td> - <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right; font-size: 12px; white-space: nowrap;">${formatDuration(startDate, endDate)}</td> - <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${escapeHtml(workerId)}</td> - <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${escapeHtml(actionId)}</td> + <td style="text-align: right; font-family: monospace; color: var(--theme_g1);">${escapeHtml(String(lsn))}</td> + <td style="text-align: right;">${queueCell}</td> + <td style="text-align: center;">${badge}</td> + <td style="color: var(--theme_bright);">${escapeHtml(fn)}</td> + <td style="text-align: right; font-size: 12px; white-space: nowrap; color: var(--theme_g1);">${formatTime(startDate)}</td> + <td style="text-align: right; font-size: 12px; white-space: nowrap; color: var(--theme_g1);">${formatTime(endDate)}</td> + <td style="text-align: right; font-size: 12px; white-space: nowrap;">${formatDuration(startDate, endDate)}</td> + <td style="font-family: monospace; font-size: 11px; color: var(--theme_g1);">${escapeHtml(workerId)}</td> + <td style="font-family: monospace; font-size: 11px; color: var(--theme_g1);">${escapeHtml(actionId)}</td> `; tbody.appendChild(tr); } diff --git a/src/zenserver/frontend/html/compute/hub.html b/src/zenserver/frontend/html/compute/hub.html index f66ba94d5..32e1b05db 100644 --- a/src/zenserver/frontend/html/compute/hub.html +++ b/src/zenserver/frontend/html/compute/hub.html @@ -3,157 +3,17 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <script src="banner.js" defer></script> - <script src="nav.js" defer></script> + <link rel="stylesheet" type="text/css" href="../zen.css" /> + <script src="../theme.js"></script> + <script src="../banner.js" defer></script> + <script src="../nav.js" defer></script> <title>Zen Hub Dashboard</title> - <style> - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: #0d1117; - color: #c9d1d9; - padding: 20px; - } - - .container { - max-width: 1400px; - margin: 0 auto; - } - - .timestamp { - font-size: 12px; - color: #6e7681; - } - - .grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 20px; - margin-bottom: 30px; - } - - .card { - background: #161b22; - border: 1px solid #30363d; - border-radius: 6px; - padding: 20px; - } - - .card-title { - font-size: 14px; - font-weight: 600; - color: #8b949e; - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .metric-value { - font-size: 36px; - font-weight: 600; - color: #f0f6fc; - line-height: 1; - } - - .metric-label { - font-size: 12px; - color: #8b949e; - margin-top: 4px; - } - - .progress-bar { - width: 100%; - height: 8px; - background: #21262d; - border-radius: 4px; - overflow: hidden; - margin-top: 8px; - } - - .progress-fill { - height: 100%; - background: #58a6ff; - transition: width 0.3s ease; - } - - .error { - color: #f85149; - padding: 12px; - background: #1c1c1c; - border-radius: 6px; - margin: 20px 0; - font-size: 13px; - } - - .section-title { - font-size: 20px; - font-weight: 600; - margin-bottom: 20px; - color: #f0f6fc; - } - - table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - } - - th { - text-align: left; - color: #8b949e; - padding: 8px 12px; - border-bottom: 1px solid #30363d; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - font-size: 11px; - } - - td { - padding: 8px 12px; - border-bottom: 1px solid #21262d; - color: #c9d1d9; - } - - tr:last-child td { - border-bottom: none; - } - - .status-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - } - - .status-badge.active { - background: rgba(63, 185, 80, 0.15); - color: #3fb950; - } - - .status-badge.inactive { - background: rgba(139, 148, 158, 0.15); - color: #8b949e; - } - - .empty-state { - color: #6e7681; - font-size: 13px; - padding: 20px 0; - text-align: center; - } - </style> </head> <body> - <div class="container"> - <zen-banner cluster-status="nominal" subtitle="HUB" tagline="Overview"></zen-banner> + <div class="container" style="max-width: 1400px; margin: 0 auto;"> + <zen-banner cluster-status="nominal" subtitle="HUB" tagline="Overview" logo-src="../favicon.ico"></zen-banner> <zen-nav> + <a href="/dashboard/">Home</a> <a href="hub.html">Hub</a> </zen-nav> <div class="timestamp">Last updated: <span id="last-update">Never</span></div> diff --git a/src/zenserver/frontend/html/compute/orchestrator.html b/src/zenserver/frontend/html/compute/orchestrator.html index 2ee57b6b3..a519dee18 100644 --- a/src/zenserver/frontend/html/compute/orchestrator.html +++ b/src/zenserver/frontend/html/compute/orchestrator.html @@ -3,47 +3,12 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <script src="banner.js" defer></script> - <script src="nav.js" defer></script> + <link rel="stylesheet" type="text/css" href="../zen.css" /> + <script src="../theme.js"></script> + <script src="../banner.js" defer></script> + <script src="../nav.js" defer></script> <title>Zen Orchestrator Dashboard</title> <style> - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: #0d1117; - color: #c9d1d9; - padding: 20px; - } - - .container { - max-width: 1400px; - margin: 0 auto; - } - - h1 { - font-size: 32px; - font-weight: 600; - margin-bottom: 10px; - color: #f0f6fc; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 30px; - } - - .timestamp { - font-size: 12px; - color: #6e7681; - } - .agent-count { display: flex; align-items: center; @@ -51,144 +16,22 @@ font-size: 14px; padding: 8px 16px; border-radius: 6px; - background: #161b22; - border: 1px solid #30363d; + background: var(--theme_g3); + border: 1px solid var(--theme_g2); } .agent-count .count { font-size: 20px; font-weight: 600; - color: #f0f6fc; - } - - .card { - background: #161b22; - border: 1px solid #30363d; - border-radius: 6px; - padding: 20px; - } - - .card-title { - font-size: 14px; - font-weight: 600; - color: #8b949e; - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .error { - color: #f85149; - padding: 12px; - background: #1c1c1c; - border-radius: 6px; - margin: 20px 0; - font-size: 13px; - } - - table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - } - - th { - text-align: left; - color: #8b949e; - padding: 8px 12px; - border-bottom: 1px solid #30363d; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - font-size: 11px; - } - - td { - padding: 8px 12px; - border-bottom: 1px solid #21262d; - color: #c9d1d9; - } - - tr:last-child td { - border-bottom: none; - } - - .total-row td { - border-top: 2px solid #30363d; - font-weight: 600; - color: #f0f6fc; - } - - .health-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - } - - .health-green { - background: #3fb950; - } - - .health-yellow { - background: #d29922; - } - - .health-red { - background: #f85149; - } - - a { - color: #58a6ff; - text-decoration: none; - } - - a:hover { - text-decoration: underline; - } - - .empty-state { - color: #6e7681; - font-size: 13px; - padding: 20px 0; - text-align: center; - } - - .history-tabs { - display: flex; - gap: 4px; - background: #0d1117; - border-radius: 6px; - padding: 2px; - } - - .history-tab { - background: transparent; - border: none; - color: #8b949e; - font-size: 12px; - font-weight: 600; - padding: 4px 12px; - border-radius: 4px; - cursor: pointer; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .history-tab:hover { - color: #c9d1d9; - } - - .history-tab.active { - background: #30363d; - color: #f0f6fc; + color: var(--theme_bright); } </style> </head> <body> - <div class="container"> - <zen-banner cluster-status="nominal" load="0"></zen-banner> + <div class="container" style="max-width: 1400px; margin: 0 auto;"> + <zen-banner cluster-status="nominal" load="0" logo-src="../favicon.ico"></zen-banner> <zen-nav> + <a href="/dashboard/">Home</a> <a href="compute.html">Node</a> <a href="orchestrator.html">Orchestrator</a> </zen-nav> @@ -513,8 +356,8 @@ '<td style="text-align: right;">' + actionsPending + '</td>' + '<td style="text-align: right;">' + actionsRunning + '</td>' + '<td style="text-align: right;">' + actionsCompleted + '</td>' + - '<td style="text-align: right; color: #8b949e; font-size: 11px;">' + formatTraffic(bytesRecv, bytesSent) + '</td>' + - '<td style="text-align: right; color: #8b949e;">' + formatLastSeen(dt) + '</td>'; + '<td style="text-align: right; font-size: 11px; color: var(--theme_g1);">' + formatTraffic(bytesRecv, bytesSent) + '</td>' + + '<td style="text-align: right; color: var(--theme_g1);">' + formatLastSeen(dt) + '</td>'; tbody.appendChild(tr); } @@ -527,7 +370,7 @@ totalTr.className = 'total-row'; totalTr.innerHTML = '<td></td>' + - '<td style="text-align: right; color: #8b949e; text-transform: uppercase; font-size: 11px;">Total' + (cidr ? ' <span style="font-family: monospace; font-weight: normal;">' + escapeHtml(cidr) + '</span>' : '') + '</td>' + + '<td style="text-align: right; color: var(--theme_g1); text-transform: uppercase; font-size: 11px;">Total' + (cidr ? ' <span style="font-family: monospace; font-weight: normal;">' + escapeHtml(cidr) + '</span>' : '') + '</td>' + '<td style="text-align: right;">' + totalCpus + '</td>' + '<td></td>' + '<td style="text-align: right;">' + formatMemory(totalMemUsed, totalMemTotal) + '</td>' + @@ -560,11 +403,11 @@ } function eventBadge(type) { - var colors = { joined: '#3fb950', left: '#f85149', returned: '#d29922' }; + var colors = { joined: 'var(--theme_ok)', left: 'var(--theme_fail)', returned: 'var(--theme_warn)' }; var labels = { joined: 'Joined', left: 'Left', returned: 'Returned' }; - var color = colors[type] || '#8b949e'; + var color = colors[type] || 'var(--theme_g1)'; var label = labels[type] || type; - return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:#0d1117;background:' + color + ';">' + escapeHtml(label) + '</span>'; + return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:var(--theme_g4);background:' + color + ';">' + escapeHtml(label) + '</span>'; } function formatTimestamp(ts) { @@ -613,7 +456,7 @@ var evt = events[i]; var tr = document.createElement('tr'); tr.innerHTML = - '<td style="color: #8b949e;">' + formatTimestamp(evt.ts) + '</td>' + + '<td style="color: var(--theme_g1);">' + formatTimestamp(evt.ts) + '</td>' + '<td>' + eventBadge(evt.type) + '</td>' + '<td>' + escapeHtml(evt.worker_id || '') + '</td>' + '<td>' + escapeHtml(evt.hostname || '') + '</td>'; @@ -652,7 +495,7 @@ var sessionBadge = ''; if (c.session_id) { - sessionBadge = ' <span style="font-family:monospace;font-size:10px;color:#6e7681;" title="Session ' + escapeHtml(c.session_id) + '">' + escapeHtml(c.session_id.substring(0, 8)) + '</span>'; + sessionBadge = ' <span style="font-family:monospace;font-size:10px;color:var(--theme_faint);" title="Session ' + escapeHtml(c.session_id) + '">' + escapeHtml(c.session_id.substring(0, 8)) + '</span>'; } var tr = document.createElement('tr'); @@ -660,18 +503,18 @@ '<td style="text-align: center;"><span class="health-dot ' + hClass + '" title="' + escapeHtml(hTitle) + '"></span></td>' + '<td>' + escapeHtml(c.id || '') + sessionBadge + '</td>' + '<td>' + escapeHtml(c.hostname || '') + '</td>' + - '<td style="font-family: monospace; font-size: 12px; color: #8b949e;">' + escapeHtml(c.address || '') + '</td>' + - '<td style="text-align: right; color: #8b949e;">' + formatLastSeen(dt) + '</td>'; + '<td style="font-family: monospace; font-size: 12px; color: var(--theme_g1);">' + escapeHtml(c.address || '') + '</td>' + + '<td style="text-align: right; color: var(--theme_g1);">' + formatLastSeen(dt) + '</td>'; tbody.appendChild(tr); } } function clientEventBadge(type) { - var colors = { connected: '#3fb950', disconnected: '#f85149', updated: '#d29922' }; + var colors = { connected: 'var(--theme_ok)', disconnected: 'var(--theme_fail)', updated: 'var(--theme_warn)' }; var labels = { connected: 'Connected', disconnected: 'Disconnected', updated: 'Updated' }; - var color = colors[type] || '#8b949e'; + var color = colors[type] || 'var(--theme_g1)'; var label = labels[type] || type; - return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:#0d1117;background:' + color + ';">' + escapeHtml(label) + '</span>'; + return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:var(--theme_g4);background:' + color + ';">' + escapeHtml(label) + '</span>'; } function renderClientHistory(events) { @@ -693,7 +536,7 @@ var evt = events[i]; var tr = document.createElement('tr'); tr.innerHTML = - '<td style="color: #8b949e;">' + formatTimestamp(evt.ts) + '</td>' + + '<td style="color: var(--theme_g1);">' + formatTimestamp(evt.ts) + '</td>' + '<td>' + clientEventBadge(evt.type) + '</td>' + '<td>' + escapeHtml(evt.client_id || '') + '</td>' + '<td>' + escapeHtml(evt.hostname || '') + '</td>'; diff --git a/src/zenserver/frontend/html/index.html b/src/zenserver/frontend/html/index.html index 6a736e914..24a136a30 100644 --- a/src/zenserver/frontend/html/index.html +++ b/src/zenserver/frontend/html/index.html @@ -10,6 +10,9 @@ </script> <link rel="shortcut icon" href="favicon.ico"> <link rel="stylesheet" type="text/css" href="zen.css" /> + <script src="theme.js"></script> + <script src="banner.js" defer></script> + <script src="nav.js" defer></script> <script type="module" src="zen.js"></script> </head> </html> diff --git a/src/zenserver/frontend/html/compute/nav.js b/src/zenserver/frontend/html/nav.js index 8ec42abd0..a5de203f2 100644 --- a/src/zenserver/frontend/html/compute/nav.js +++ b/src/zenserver/frontend/html/nav.js @@ -45,8 +45,8 @@ class ZenNav extends HTMLElement { align-items: center; gap: 4px; padding: 4px; - background: #161b22; - border: 1px solid #30363d; + background: var(--theme_g3); + border: 1px solid var(--theme_g2); border-radius: 6px; } @@ -54,7 +54,7 @@ class ZenNav extends HTMLElement { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 13px; font-weight: 500; - color: #8b949e; + color: var(--theme_g1); text-decoration: none; padding: 6px 14px; border-radius: 4px; @@ -62,13 +62,13 @@ class ZenNav extends HTMLElement { } .nav-link:hover { - color: #c9d1d9; - background: #21262d; + color: var(--theme_g0); + background: var(--theme_p4); } .nav-link.active { - color: #f0f6fc; - background: #30363d; + color: var(--theme_bright); + background: var(--theme_g2); } </style> <nav class="nav-bar">${links}</nav> diff --git a/src/zenserver/frontend/html/pages/cache.js b/src/zenserver/frontend/html/pages/cache.js new file mode 100644 index 000000000..3b838958a --- /dev/null +++ b/src/zenserver/frontend/html/pages/cache.js @@ -0,0 +1,690 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Modal } from "../util/modal.js" +import { Table, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("cache"); + + // Cache Service Stats + const stats_section = this._collapsible_section("Cache Service Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/z$.yaml?cidstorestats=true&cachestorestats=true", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + this._details_host = stats_section; + this._details_container = null; + this._selected_category = null; + + const stats = await new Fetcher().resource("stats", "z$").json(); + if (stats) + { + this._render_stats(stats); + } + + this._connect_stats_ws(); + + // Cache Namespaces + var section = this._collapsible_section("Cache Namespaces"); + + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all()); + + var columns = [ + "namespace", + "dir", + "buckets", + "entries", + "size disk", + "size mem", + "actions", + ]; + + var zcache_info = await new Fetcher().resource("/z$/").json(); + this._cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric); + + for (const namespace of zcache_info["Namespaces"] || []) + { + new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { + const row = this._cache_table.add_row( + "", + data["Configuration"]["RootDir"], + data["Buckets"].length, + data["EntryCount"], + Friendly.bytes(data["StorageSize"].DiskSize), + Friendly.bytes(data["StorageSize"].MemorySize) + ); + var cell = row.get_cell(0); + cell.tag().text(namespace).on_click(() => this.view_namespace(namespace)); + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("view").on_click(() => this.view_namespace(namespace)); + action_tb.left().add("drop").on_click(() => this.drop_namespace(namespace)); + + row.attr("zs_name", namespace); + }); + } + + // Namespace detail area (inside namespaces section so it collapses together) + this._namespace_host = section; + this._namespace_container = null; + this._selected_namespace = null; + + // Restore namespace from URL if present + const ns_param = this.get_param("namespace"); + if (ns_param) + { + this.view_namespace(ns_param); + } + } + + _collapsible_section(name) + { + const section = this.add_section(name); + const container = section._parent.inner(); + const heading = container.firstElementChild; + + heading.style.cursor = "pointer"; + heading.style.userSelect = "none"; + + const indicator = document.createElement("span"); + indicator.textContent = " \u25BC"; + indicator.style.fontSize = "0.7em"; + heading.appendChild(indicator); + + let collapsed = false; + heading.addEventListener("click", (e) => { + if (e.target !== heading && e.target !== indicator) + { + return; + } + collapsed = !collapsed; + indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; + let sibling = heading.nextElementSibling; + while (sibling) + { + sibling.style.display = collapsed ? "none" : ""; + sibling = sibling.nextElementSibling; + } + }); + + return section; + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + const stats = all_stats["z$"]; + if (stats) + { + this._render_stats(stats); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _render_stats(stats) + { + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + const grid = this._stats_grid; + + this._last_stats = stats; + grid.inner().innerHTML = ""; + + // Store I/O tile + { + const store = safe(stats, "cache.store"); + if (store) + { + const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); + if (this._selected_category === "store") tile.classify("stats-tile-selected"); + tile.on_click(() => this._select_category("store")); + tile.tag().classify("card-title").text("Store I/O"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const storeHits = store.hits || 0; + const storeMisses = store.misses || 0; + const storeTotal = storeHits + storeMisses; + const storeRatio = storeTotal > 0 ? ((storeHits / storeTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(left, storeRatio, "store hit ratio", true); + this._metric(left, Friendly.sep(storeHits), "hits"); + this._metric(left, Friendly.sep(storeMisses), "misses"); + this._metric(left, Friendly.sep(store.writes || 0), "writes"); + this._metric(left, Friendly.sep(store.rejected_reads || 0), "rejected reads"); + this._metric(left, Friendly.sep(store.rejected_writes || 0), "rejected writes"); + + const right = columns.tag().classify("tile-metrics"); + const readRateMean = safe(store, "read.bytes.rate_mean") || 0; + const readRate1 = safe(store, "read.bytes.rate_1") || 0; + const readRate5 = safe(store, "read.bytes.rate_5") || 0; + const writeRateMean = safe(store, "write.bytes.rate_mean") || 0; + const writeRate1 = safe(store, "write.bytes.rate_1") || 0; + const writeRate5 = safe(store, "write.bytes.rate_5") || 0; + this._metric(right, Friendly.bytes(readRateMean) + "/s", "read rate (mean)", true); + this._metric(right, Friendly.bytes(readRate1) + "/s", "read rate (1m)"); + this._metric(right, Friendly.bytes(readRate5) + "/s", "read rate (5m)"); + this._metric(right, Friendly.bytes(writeRateMean) + "/s", "write rate (mean)"); + this._metric(right, Friendly.bytes(writeRate1) + "/s", "write rate (1m)"); + this._metric(right, Friendly.bytes(writeRate5) + "/s", "write rate (5m)"); + } + } + + // Hit/Miss tile + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Hit Ratio"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const hits = safe(stats, "cache.hits") || 0; + const misses = safe(stats, "cache.misses") || 0; + const writes = safe(stats, "cache.writes") || 0; + const total = hits + misses; + const ratio = total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "-"; + + this._metric(left, ratio, "hit ratio", true); + this._metric(left, Friendly.sep(hits), "hits"); + this._metric(left, Friendly.sep(misses), "misses"); + this._metric(left, Friendly.sep(writes), "writes"); + + const right = columns.tag().classify("tile-metrics"); + const cidHits = safe(stats, "cache.cidhits") || 0; + const cidMisses = safe(stats, "cache.cidmisses") || 0; + const cidWrites = safe(stats, "cache.cidwrites") || 0; + const cidTotal = cidHits + cidMisses; + const cidRatio = cidTotal > 0 ? ((cidHits / cidTotal) * 100).toFixed(1) + "%" : "-"; + + this._metric(right, cidRatio, "cid hit ratio", true); + this._metric(right, Friendly.sep(cidHits), "cid hits"); + this._metric(right, Friendly.sep(cidMisses), "cid misses"); + this._metric(right, Friendly.sep(cidWrites), "cid writes"); + } + + // HTTP Requests tile + { + const req = safe(stats, "requests"); + if (req) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP Requests"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const reqData = req.requests || req; + this._metric(left, Friendly.sep(reqData.count || 0), "total requests", true); + if (reqData.rate_mean > 0) + { + this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); + } + if (reqData.rate_1 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); + } + if (reqData.rate_5 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_5, 1) + "/s", "req/sec (5m)"); + } + if (reqData.rate_15 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_15, 1) + "/s", "req/sec (15m)"); + } + const badRequests = safe(stats, "cache.badrequestcount") || 0; + this._metric(left, Friendly.sep(badRequests), "bad requests"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); + if (reqData.t_p75) + { + this._metric(right, Friendly.duration(reqData.t_p75), "p75"); + } + if (reqData.t_p95) + { + this._metric(right, Friendly.duration(reqData.t_p95), "p95"); + } + if (reqData.t_p99) + { + this._metric(right, Friendly.duration(reqData.t_p99), "p99"); + } + if (reqData.t_p999) + { + this._metric(right, Friendly.duration(reqData.t_p999), "p999"); + } + if (reqData.t_max) + { + this._metric(right, Friendly.duration(reqData.t_max), "max"); + } + } + } + + // RPC tile + { + const rpc = safe(stats, "cache.rpc"); + if (rpc) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("RPC"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, Friendly.sep(rpc.count || 0), "rpc calls", true); + this._metric(left, Friendly.sep(rpc.ops || 0), "batch ops"); + + const right = columns.tag().classify("tile-metrics"); + if (rpc.records) + { + this._metric(right, Friendly.sep(rpc.records.count || 0), "record calls"); + this._metric(right, Friendly.sep(rpc.records.ops || 0), "record ops"); + } + if (rpc.values) + { + this._metric(right, Friendly.sep(rpc.values.count || 0), "value calls"); + this._metric(right, Friendly.sep(rpc.values.ops || 0), "value ops"); + } + if (rpc.chunks) + { + this._metric(right, Friendly.sep(rpc.chunks.count || 0), "chunk calls"); + this._metric(right, Friendly.sep(rpc.chunks.ops || 0), "chunk ops"); + } + } + } + + // Storage tile + { + const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); + if (this._selected_category === "storage") tile.classify("stats-tile-selected"); + tile.on_click(() => this._select_category("storage")); + tile.tag().classify("card-title").text("Storage"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, safe(stats, "cache.size.disk") != null ? Friendly.bytes(safe(stats, "cache.size.disk")) : "-", "cache disk", true); + this._metric(left, safe(stats, "cache.size.memory") != null ? Friendly.bytes(safe(stats, "cache.size.memory")) : "-", "cache memory"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, safe(stats, "cid.size.total") != null ? Friendly.bytes(safe(stats, "cid.size.total")) : "-", "cid total", true); + this._metric(right, safe(stats, "cid.size.tiny") != null ? Friendly.bytes(safe(stats, "cid.size.tiny")) : "-", "cid tiny"); + this._metric(right, safe(stats, "cid.size.small") != null ? Friendly.bytes(safe(stats, "cid.size.small")) : "-", "cid small"); + this._metric(right, safe(stats, "cid.size.large") != null ? Friendly.bytes(safe(stats, "cid.size.large")) : "-", "cid large"); + } + + // Upstream tile (only if upstream is active) + { + const upstream = safe(stats, "upstream"); + if (upstream) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Upstream"); + const body = tile.tag().classify("tile-metrics"); + + const upstreamHits = safe(stats, "cache.upstream_hits") || 0; + this._metric(body, Friendly.sep(upstreamHits), "upstream hits", true); + + if (upstream.url) + { + this._metric(body, upstream.url, "endpoint"); + } + } + } + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } + + async _select_category(category) + { + // Toggle off if already selected + if (this._selected_category === category) + { + this._selected_category = null; + this._clear_details(); + this._render_stats(this._last_stats); + return; + } + + this._selected_category = category; + this._render_stats(this._last_stats); + + // Fetch detailed stats + const detailed = await new Fetcher() + .resource("stats", "z$") + .param("cachestorestats", "true") + .param("cidstorestats", "true") + .json(); + + if (!detailed || this._selected_category !== category) + { + return; + } + + this._clear_details(); + + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + + if (category === "store") + { + this._render_store_details(detailed, safe); + } + else if (category === "storage") + { + this._render_storage_details(detailed, safe); + } + } + + _clear_details() + { + if (this._details_container) + { + this._details_container.inner().remove(); + this._details_container = null; + } + } + + _render_store_details(stats, safe) + { + const namespaces = safe(stats, "cache.store.namespaces") || []; + if (namespaces.length === 0) + { + return; + } + + const container = this._details_host.tag(); + this._details_container = container; + + const columns = [ + "namespace", + "bucket", + "hits", + "misses", + "writes", + "hit ratio", + "read count", + "read bandwidth", + "write count", + "write bandwidth", + ]; + const table = new Table(container, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + for (const ns of namespaces) + { + const nsHits = ns.hits || 0; + const nsMisses = ns.misses || 0; + const nsTotal = nsHits + nsMisses; + const nsRatio = nsTotal > 0 ? ((nsHits / nsTotal) * 100).toFixed(1) + "%" : "-"; + + const readCount = safe(ns, "read.request.count") || 0; + const readBytes = safe(ns, "read.bytes.count") || 0; + const writeCount = safe(ns, "write.request.count") || 0; + const writeBytes = safe(ns, "write.bytes.count") || 0; + + table.add_row( + ns.namespace, + "", + Friendly.sep(nsHits), + Friendly.sep(nsMisses), + Friendly.sep(ns.writes || 0), + nsRatio, + Friendly.sep(readCount), + Friendly.bytes(readBytes), + Friendly.sep(writeCount), + Friendly.bytes(writeBytes), + ); + + if (ns.buckets && ns.buckets.length > 0) + { + for (const bucket of ns.buckets) + { + const bHits = bucket.hits || 0; + const bMisses = bucket.misses || 0; + const bTotal = bHits + bMisses; + const bRatio = bTotal > 0 ? ((bHits / bTotal) * 100).toFixed(1) + "%" : "-"; + + const bReadCount = safe(bucket, "read.request.count") || 0; + const bReadBytes = safe(bucket, "read.bytes.count") || 0; + const bWriteCount = safe(bucket, "write.request.count") || 0; + const bWriteBytes = safe(bucket, "write.bytes.count") || 0; + + table.add_row( + ns.namespace, + bucket.bucket, + Friendly.sep(bHits), + Friendly.sep(bMisses), + Friendly.sep(bucket.writes || 0), + bRatio, + Friendly.sep(bReadCount), + Friendly.bytes(bReadBytes), + Friendly.sep(bWriteCount), + Friendly.bytes(bWriteBytes), + ); + } + } + } + } + + _render_storage_details(stats, safe) + { + const namespaces = safe(stats, "cache.store.namespaces") || []; + if (namespaces.length === 0) + { + return; + } + + const container = this._details_host.tag(); + this._details_container = container; + + const columns = [ + "namespace", + "bucket", + "disk", + "memory", + ]; + const table = new Table(container, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + for (const ns of namespaces) + { + const diskSize = safe(ns, "size.disk") || 0; + const memSize = safe(ns, "size.memory") || 0; + + table.add_row( + ns.namespace, + "", + Friendly.bytes(diskSize), + Friendly.bytes(memSize), + ); + + if (ns.buckets && ns.buckets.length > 0) + { + for (const bucket of ns.buckets) + { + const bDisk = safe(bucket, "size.disk") || 0; + const bMem = safe(bucket, "size.memory") || 0; + + table.add_row( + ns.namespace, + bucket.bucket, + Friendly.bytes(bDisk), + Friendly.bytes(bMem), + ); + } + } + } + } + + async view_namespace(namespace) + { + // Toggle off if already selected + if (this._selected_namespace === namespace) + { + this._selected_namespace = null; + this._clear_namespace(); + this._clear_param("namespace"); + return; + } + + this._selected_namespace = namespace; + this._clear_namespace(); + this.set_param("namespace", namespace); + + const info = await new Fetcher().resource(`/z$/${namespace}/`).json(); + if (this._selected_namespace !== namespace) + { + return; + } + + const section = this._namespace_host.add_section(namespace); + this._namespace_container = section; + + // Buckets table + const bucket_section = section.add_section("Buckets"); + const bucket_columns = ["name", "disk", "memory", "entries", "actions"]; + const bucket_table = bucket_section.add_widget( + Table, + bucket_columns, + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric + ); + + // Right-align header for numeric columns (skip # and name) + const header = bucket_table._element.firstElementChild; + for (let i = 2; i < header.children.length - 1; i++) + { + header.children[i].style.textAlign = "right"; + } + + let totalDisk = 0, totalMem = 0, totalEntries = 0; + const total_row = bucket_table.add_row("TOTAL"); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(1).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(2).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).style("textAlign", "right").style("fontWeight", "bold"); + + for (const bucket of info["Buckets"]) + { + const row = bucket_table.add_row(bucket); + new Fetcher().resource(`/z$/${namespace}/${bucket}`).json().then((data) => { + row.get_cell(1).text(Friendly.bytes(data["StorageSize"]["DiskSize"])).style("textAlign", "right"); + row.get_cell(2).text(Friendly.bytes(data["StorageSize"]["MemorySize"])).style("textAlign", "right"); + row.get_cell(3).text(Friendly.sep(data["DiskEntryCount"])).style("textAlign", "right"); + + const cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("drop").on_click(() => this.drop_bucket(namespace, bucket)); + + totalDisk += data["StorageSize"]["DiskSize"]; + totalMem += data["StorageSize"]["MemorySize"]; + totalEntries += data["DiskEntryCount"]; + total_row.get_cell(1).text(Friendly.bytes(totalDisk)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(2).text(Friendly.bytes(totalMem)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).text(Friendly.sep(totalEntries)).style("textAlign", "right").style("fontWeight", "bold"); + }); + } + + } + + _clear_param(name) + { + this._params.delete(name); + const url = new URL(window.location); + url.searchParams.delete(name); + history.replaceState(null, "", url); + } + + _clear_namespace() + { + if (this._namespace_container) + { + this._namespace_container._parent.inner().remove(); + this._namespace_container = null; + } + } + + drop_bucket(namespace, bucket) + { + const drop = async () => { + await new Fetcher().resource("z$", namespace, bucket).delete(); + // Refresh the namespace view + this._selected_namespace = null; + this._clear_namespace(); + this.view_namespace(namespace); + }; + + new Modal() + .title("Confirmation") + .message(`Drop bucket '${bucket}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + drop_namespace(namespace) + { + const drop = async () => { + await new Fetcher().resource("z$", namespace).delete(); + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message(`Drop cache namespace '${namespace}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + async drop_all() + { + const drop = async () => { + for (const row of this._cache_table) + { + const namespace = row.attr("zs_name"); + await new Fetcher().resource("z$", namespace).delete(); + } + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message("Drop every cache namespace?") + .option("Yes", () => drop()) + .option("No"); + } +} diff --git a/src/zenserver/frontend/html/pages/compute.js b/src/zenserver/frontend/html/pages/compute.js new file mode 100644 index 000000000..ab3d49c27 --- /dev/null +++ b/src/zenserver/frontend/html/pages/compute.js @@ -0,0 +1,693 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Table } from "../util/widgets.js" + +const MAX_HISTORY_POINTS = 60; + +// Windows FILETIME: 100ns ticks since 1601-01-01 +const FILETIME_EPOCH_OFFSET_MS = 11644473600000n; +function filetimeToDate(ticks) +{ + if (!ticks) return null; + const ms = BigInt(ticks) / 10000n - FILETIME_EPOCH_OFFSET_MS; + return new Date(Number(ms)); +} + +function formatTime(date) +{ + if (!date) return "-"; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function formatDuration(startDate, endDate) +{ + if (!startDate || !endDate) return "-"; + const ms = endDate - startDate; + if (ms < 0) return "-"; + if (ms < 1000) return ms + " ms"; + if (ms < 60000) return (ms / 1000).toFixed(2) + " s"; + const m = Math.floor(ms / 60000); + const s = ((ms % 60000) / 1000).toFixed(0).padStart(2, "0"); + return `${m}m ${s}s`; +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("compute"); + + this._history = { timestamps: [], pending: [], running: [], completed: [], cpu: [] }; + this._selected_worker = null; + this._chart_js = null; + this._queue_chart = null; + this._cpu_chart = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Action Queue section + const queue_section = this._collapsible_section("Action Queue"); + this._queue_grid = queue_section.tag().classify("grid").classify("stats-tiles"); + this._chart_host = queue_section; + + // Performance Metrics section + const perf_section = this._collapsible_section("Performance Metrics"); + this._perf_host = perf_section; + this._perf_grid = null; + + // Workers section + const workers_section = this._collapsible_section("Workers"); + this._workers_host = workers_section; + this._workers_table = null; + this._worker_detail_container = null; + + // Queues section + const queues_section = this._collapsible_section("Queues"); + this._queues_host = queues_section; + this._queues_table = null; + + // Action History section + const history_section = this._collapsible_section("Recent Actions"); + this._history_host = history_section; + this._history_table = null; + + // System Resources section + const sys_section = this._collapsible_section("System Resources"); + this._sys_grid = sys_section.tag().classify("grid").classify("stats-tiles"); + + // Load Chart.js dynamically + this._load_chartjs(); + + // Initial fetch + await this._fetch_all(); + + // Poll + this._poll_timer = setInterval(() => { + if (!this._ws_paused) + { + this._fetch_all(); + } + }, 2000); + } + + _collapsible_section(name) + { + const section = this.add_section(name); + const container = section._parent.inner(); + const heading = container.firstElementChild; + + heading.style.cursor = "pointer"; + heading.style.userSelect = "none"; + + const indicator = document.createElement("span"); + indicator.textContent = " \u25BC"; + indicator.style.fontSize = "0.7em"; + heading.appendChild(indicator); + + let collapsed = false; + heading.addEventListener("click", (e) => { + if (e.target !== heading && e.target !== indicator) + { + return; + } + collapsed = !collapsed; + indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; + let sibling = heading.nextElementSibling; + while (sibling) + { + sibling.style.display = collapsed ? "none" : ""; + sibling = sibling.nextElementSibling; + } + }); + + return section; + } + + async _load_chartjs() + { + if (window.Chart) + { + this._chart_js = window.Chart; + this._init_charts(); + return; + } + + try + { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"; + script.onload = () => { + this._chart_js = window.Chart; + this._init_charts(); + }; + document.head.appendChild(script); + } + catch (e) { /* Chart.js not available */ } + } + + _init_charts() + { + if (!this._chart_js) + { + return; + } + + // Queue history chart + { + const card = this._chart_host.tag().classify("card"); + card.tag().classify("card-title").text("Action Queue History"); + const container = card.tag(); + container.style("position", "relative").style("height", "300px").style("marginTop", "20px"); + const canvas = document.createElement("canvas"); + container.inner().appendChild(canvas); + + this._queue_chart = new this._chart_js(canvas.getContext("2d"), { + type: "line", + data: { + labels: [], + datasets: [ + { label: "Pending", data: [], borderColor: "#f0883e", backgroundColor: "rgba(240, 136, 62, 0.1)", tension: 0.4, fill: true }, + { label: "Running", data: [], borderColor: "#58a6ff", backgroundColor: "rgba(88, 166, 255, 0.1)", tension: 0.4, fill: true }, + { label: "Completed", data: [], borderColor: "#3fb950", backgroundColor: "rgba(63, 185, 80, 0.1)", tension: 0.4, fill: true }, + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: true, labels: { color: "#8b949e" } } }, + scales: { x: { display: false }, y: { beginAtZero: true, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } } } + } + }); + } + + // CPU sparkline (will be appended to CPU card later) + this._cpu_canvas = document.createElement("canvas"); + this._cpu_chart = new this._chart_js(this._cpu_canvas.getContext("2d"), { + type: "line", + data: { + labels: [], + datasets: [{ + data: [], + borderColor: "#58a6ff", + backgroundColor: "rgba(88, 166, 255, 0.15)", + borderWidth: 1.5, + tension: 0.4, + fill: true, + pointRadius: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } } + } + }); + } + + async _fetch_all() + { + try + { + const [stats, sysinfo, workers_data, queues_data, history_data] = await Promise.all([ + new Fetcher().resource("/stats/compute").json().catch(() => null), + new Fetcher().resource("/compute/sysinfo").json().catch(() => null), + new Fetcher().resource("/compute/workers").json().catch(() => null), + new Fetcher().resource("/compute/queues").json().catch(() => null), + new Fetcher().resource("/compute/jobs/history").param("limit", "50").json().catch(() => null), + ]); + + if (stats) + { + this._render_queue_stats(stats); + this._update_queue_chart(stats); + this._render_perf(stats); + } + if (sysinfo) + { + this._render_sysinfo(sysinfo); + } + if (workers_data) + { + this._render_workers(workers_data); + } + if (queues_data) + { + this._render_queues(queues_data); + } + if (history_data) + { + this._render_action_history(history_data); + } + } + catch (e) { /* service unavailable */ } + } + + _render_queue_stats(data) + { + const grid = this._queue_grid; + grid.inner().innerHTML = ""; + + const tiles = [ + { title: "Pending Actions", value: data.actions_pending || 0, label: "waiting to be scheduled" }, + { title: "Running Actions", value: data.actions_submitted || 0, label: "currently executing" }, + { title: "Completed Actions", value: data.actions_complete || 0, label: "results available" }, + ]; + + for (const t of tiles) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text(t.title); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(t.value), t.label, true); + } + } + + _update_queue_chart(data) + { + const h = this._history; + h.timestamps.push(new Date().toLocaleTimeString()); + h.pending.push(data.actions_pending || 0); + h.running.push(data.actions_submitted || 0); + h.completed.push(data.actions_complete || 0); + + while (h.timestamps.length > MAX_HISTORY_POINTS) + { + h.timestamps.shift(); + h.pending.shift(); + h.running.shift(); + h.completed.shift(); + } + + if (this._queue_chart) + { + this._queue_chart.data.labels = h.timestamps; + this._queue_chart.data.datasets[0].data = h.pending; + this._queue_chart.data.datasets[1].data = h.running; + this._queue_chart.data.datasets[2].data = h.completed; + this._queue_chart.update("none"); + } + } + + _render_perf(data) + { + if (!this._perf_grid) + { + this._perf_grid = this._perf_host.tag().classify("grid").classify("stats-tiles"); + } + const grid = this._perf_grid; + grid.inner().innerHTML = ""; + + const retired = data.actions_retired || {}; + + // Completion rate card + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Completion Rate"); + const body = tile.tag().classify("tile-columns"); + + const left = body.tag().classify("tile-metrics"); + this._metric(left, this._fmt_rate(retired.rate_1), "1 min rate", true); + this._metric(left, this._fmt_rate(retired.rate_5), "5 min rate"); + this._metric(left, this._fmt_rate(retired.rate_15), "15 min rate"); + + const right = body.tag().classify("tile-metrics"); + this._metric(right, Friendly.sep(retired.count || 0), "total retired", true); + this._metric(right, this._fmt_rate(retired.rate_mean), "mean rate"); + } + } + + _fmt_rate(rate) + { + if (rate == null) return "-"; + return rate.toFixed(2) + "/s"; + } + + _render_workers(data) + { + const workerIds = data.workers || []; + + if (this._workers_table) + { + this._workers_table.clear(); + } + else + { + this._workers_table = this._workers_host.add_widget( + Table, + ["name", "platform", "cores", "timeout", "functions", "worker ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workerIds.length === 0) + { + return; + } + + // Fetch each worker's descriptor + Promise.all( + workerIds.map(id => + new Fetcher().resource("/compute/workers", id).json() + .then(desc => ({ id, desc })) + .catch(() => ({ id, desc: null })) + ) + ).then(results => { + this._workers_table.clear(); + for (const { id, desc } of results) + { + const name = desc ? (desc.name || "-") : "-"; + const host = desc ? (desc.host || "-") : "-"; + const cores = desc ? (desc.cores != null ? desc.cores : "-") : "-"; + const timeout = desc ? (desc.timeout != null ? desc.timeout + "s" : "-") : "-"; + const functions = desc ? (desc.functions ? desc.functions.length : 0) : "-"; + + const row = this._workers_table.add_row( + "", + host, + String(cores), + String(timeout), + String(functions), + id, + ); + + // Make name clickable to expand detail + const cell = row.get_cell(0); + cell.tag().text(name).on_click(() => this._toggle_worker_detail(id, desc)); + + // Highlight selected + if (id === this._selected_worker) + { + row.style("background", "var(--theme_p3)"); + } + } + + this._worker_descriptors = Object.fromEntries(results.map(r => [r.id, r.desc])); + + // Re-render detail if still selected + if (this._selected_worker && this._worker_descriptors[this._selected_worker]) + { + this._show_worker_detail(this._selected_worker, this._worker_descriptors[this._selected_worker]); + } + else if (this._selected_worker) + { + this._selected_worker = null; + this._clear_worker_detail(); + } + }); + } + + _toggle_worker_detail(id, desc) + { + if (this._selected_worker === id) + { + this._selected_worker = null; + this._clear_worker_detail(); + return; + } + this._selected_worker = id; + this._show_worker_detail(id, desc); + } + + _clear_worker_detail() + { + if (this._worker_detail_container) + { + this._worker_detail_container._parent.inner().remove(); + this._worker_detail_container = null; + } + } + + _show_worker_detail(id, desc) + { + this._clear_worker_detail(); + if (!desc) + { + return; + } + + const section = this._workers_host.add_section(desc.name || id); + this._worker_detail_container = section; + + // Basic info table + const info_table = section.add_widget( + Table, ["property", "value"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + const fields = [ + ["Worker ID", id], + ["Path", desc.path || "-"], + ["Platform", desc.host || "-"], + ["Build System", desc.buildsystem_version || "-"], + ["Cores", desc.cores != null ? String(desc.cores) : "-"], + ["Timeout", desc.timeout != null ? desc.timeout + "s" : "-"], + ]; + for (const [label, value] of fields) + { + info_table.add_row(label, value); + } + + // Functions + const functions = desc.functions || []; + if (functions.length > 0) + { + const fn_section = section.add_section("Functions"); + const fn_table = fn_section.add_widget( + Table, ["name", "version"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of functions) + { + fn_table.add_row(f.name || "-", f.version || "-"); + } + } + + // Executables + const executables = desc.executables || []; + if (executables.length > 0) + { + const exec_section = section.add_section("Executables"); + const exec_table = exec_section.add_widget( + Table, ["path", "hash", "size"], Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric + ); + let totalSize = 0; + for (const e of executables) + { + exec_table.add_row(e.name || "-", e.hash || "-", e.size != null ? Friendly.bytes(e.size) : "-"); + totalSize += e.size || 0; + } + const total_row = exec_table.add_row("TOTAL", "", Friendly.bytes(totalSize)); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(2).style("fontWeight", "bold"); + } + + // Files + const files = desc.files || []; + if (files.length > 0) + { + const files_section = section.add_section("Files"); + const files_table = files_section.add_widget( + Table, ["name", "hash"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of files) + { + files_table.add_row(typeof f === "string" ? f : (f.name || "-"), typeof f === "string" ? "" : (f.hash || "")); + } + } + + // Directories + const dirs = desc.dirs || []; + if (dirs.length > 0) + { + const dirs_section = section.add_section("Directories"); + for (const d of dirs) + { + dirs_section.tag().classify("detail-tag").text(d); + } + } + + // Environment + const env = desc.environment || []; + if (env.length > 0) + { + const env_section = section.add_section("Environment"); + for (const e of env) + { + env_section.tag().classify("detail-tag").text(e); + } + } + } + + _render_queues(data) + { + const queues = data.queues || []; + + if (this._queues_table) + { + this._queues_table.clear(); + } + else + { + this._queues_table = this._queues_host.add_widget( + Table, + ["ID", "status", "active", "completed", "failed", "abandoned", "cancelled", "token"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + for (const q of queues) + { + const id = q.queue_id != null ? String(q.queue_id) : "-"; + const status = q.state === "cancelled" ? "cancelled" + : q.state === "draining" ? "draining" + : q.is_complete ? "complete" : "active"; + + this._queues_table.add_row( + id, + status, + String(q.active_count ?? 0), + String(q.completed_count ?? 0), + String(q.failed_count ?? 0), + String(q.abandoned_count ?? 0), + String(q.cancelled_count ?? 0), + q.queue_token || "-", + ); + } + } + + _render_action_history(data) + { + const entries = data.history || []; + + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["LSN", "queue", "status", "function", "started", "finished", "duration", "worker ID", "action ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + // Entries arrive oldest-first; reverse to show newest at top + for (const entry of [...entries].reverse()) + { + const lsn = entry.lsn != null ? String(entry.lsn) : "-"; + const queueId = entry.queueId ? String(entry.queueId) : "-"; + const status = entry.succeeded == null ? "unknown" + : entry.succeeded ? "ok" : "failed"; + const desc = entry.actionDescriptor || {}; + const fn = desc.Function || "-"; + const startDate = filetimeToDate(entry.time_Running); + const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed); + + this._history_table.add_row( + lsn, + queueId, + status, + fn, + formatTime(startDate), + formatTime(endDate), + formatDuration(startDate, endDate), + entry.workerId || "-", + entry.actionId || "-", + ); + } + } + + _render_sysinfo(data) + { + const grid = this._sys_grid; + grid.inner().innerHTML = ""; + + // CPU card + { + const cpuUsage = data.cpu_usage || 0; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("CPU Usage"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, cpuUsage.toFixed(1) + "%", "percent", true); + + // Progress bar + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", cpuUsage + "%"); + + // CPU sparkline + this._history.cpu.push(cpuUsage); + while (this._history.cpu.length > MAX_HISTORY_POINTS) this._history.cpu.shift(); + if (this._cpu_chart) + { + const sparkContainer = body.tag(); + sparkContainer.style("position", "relative").style("height", "60px").style("marginTop", "12px"); + sparkContainer.inner().appendChild(this._cpu_canvas); + + this._cpu_chart.data.labels = this._history.cpu.map(() => ""); + this._cpu_chart.data.datasets[0].data = this._history.cpu; + this._cpu_chart.update("none"); + } + + // CPU details + this._stat_row(body, "Packages", data.cpu_count != null ? String(data.cpu_count) : "-"); + this._stat_row(body, "Physical Cores", data.core_count != null ? String(data.core_count) : "-"); + this._stat_row(body, "Logical Processors", data.lp_count != null ? String(data.lp_count) : "-"); + } + + // Memory card + { + const memUsed = data.memory_used || 0; + const memTotal = data.memory_total || 1; + const memPercent = (memUsed / memTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Memory"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(memUsed)); + this._stat_row(body, "Total", Friendly.bytes(memTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", memPercent + "%"); + } + + // Disk card + { + const diskUsed = data.disk_used || 0; + const diskTotal = data.disk_total || 1; + const diskPercent = (diskUsed / diskTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Disk"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(diskUsed)); + this._stat_row(body, "Total", Friendly.bytes(diskTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", diskPercent + "%"); + } + } + + _stat_row(parent, label, value) + { + const row = parent.tag().classify("stats-row"); + row.tag().classify("stats-label").text(label); + row.tag().classify("stats-value").text(value); + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } +} diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js index f418b17ba..1e4c82e3f 100644 --- a/src/zenserver/frontend/html/pages/entry.js +++ b/src/zenserver/frontend/html/pages/entry.js @@ -262,8 +262,8 @@ export class Page extends ZenPage io_hash = ret; } - size = (size !== undefined) ? Friendly.kib(size) : ""; - raw_size = (raw_size !== undefined) ? Friendly.kib(raw_size) : ""; + size = (size !== undefined) ? Friendly.bytes(size) : ""; + raw_size = (raw_size !== undefined) ? Friendly.bytes(raw_size) : ""; const row = table.add_row(file_name, size, raw_size); diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js new file mode 100644 index 000000000..f9e4fff33 --- /dev/null +++ b/src/zenserver/frontend/html/pages/hub.js @@ -0,0 +1,122 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("hub"); + + // Capacity + const stats_section = this.add_section("Capacity"); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + // Modules + const mod_section = this.add_section("Modules"); + this._mod_host = mod_section; + this._mod_table = null; + + await this._update(); + this._poll_timer = setInterval(() => this._update(), 2000); + } + + async _update() + { + try + { + const [stats, status] = await Promise.all([ + new Fetcher().resource("/hub/stats").json(), + new Fetcher().resource("/hub/status").json(), + ]); + + this._render_capacity(stats); + this._render_modules(status); + } + catch (e) { /* service unavailable */ } + } + + _render_capacity(data) + { + const grid = this._stats_grid; + grid.inner().innerHTML = ""; + + const current = data.currentInstanceCount || 0; + const max = data.maxInstanceCount || 0; + const limit = data.instanceLimit || 0; + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Active Modules"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(current), "currently provisioned", true); + } + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Peak Modules"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(max), "high watermark", true); + } + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Instance Limit"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(limit), "maximum allowed", true); + if (limit > 0) + { + const pct = ((current / limit) * 100).toFixed(0) + "%"; + this._metric(body, pct, "utilization"); + } + } + } + + _render_modules(data) + { + const modules = data.modules || []; + + if (this._mod_table) + { + this._mod_table.clear(); + } + else + { + this._mod_table = this._mod_host.add_widget( + Table, + ["module ID", "status"], + Table.Flag_FitLeft|Table.Flag_PackRight + ); + } + + if (modules.length === 0) + { + return; + } + + for (const m of modules) + { + this._mod_table.add_row( + m.moduleId || "", + m.provisioned ? "provisioned" : "inactive", + ); + } + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } +} diff --git a/src/zenserver/frontend/html/pages/info.js b/src/zenserver/frontend/html/pages/info.js new file mode 100644 index 000000000..f92765c78 --- /dev/null +++ b/src/zenserver/frontend/html/pages/info.js @@ -0,0 +1,261 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("info"); + + const [info, gc, services, version] = await Promise.all([ + new Fetcher().resource("/health/info").json(), + new Fetcher().resource("/admin/gc").json().catch(() => null), + new Fetcher().resource("/api/").json().catch(() => ({})), + new Fetcher().resource("/health/version").param("detailed", "true").text(), + ]); + + const section = this.add_section("Server Info"); + const grid = section.tag().classify("grid").classify("info-tiles"); + + // Application + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Application"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "version", version || info.BuildVersion || "-"); + this._prop(list, "http server", info.HttpServerClass || "-"); + this._prop(list, "port", info.Port || "-"); + this._prop(list, "pid", info.Pid || "-"); + this._prop(list, "dedicated", info.IsDedicated ? "yes" : "no"); + + if (info.StartTimeMs) + { + const start = new Date(info.StartTimeMs); + const elapsed = Date.now() - info.StartTimeMs; + this._prop(list, "started", start.toLocaleString()); + this._prop(list, "uptime", this._format_duration(elapsed)); + } + + this._prop(list, "data root", info.DataRoot || "-"); + this._prop(list, "log path", info.AbsLogPath || "-"); + } + + // System + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("System"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "hostname", info.Hostname || "-"); + this._prop(list, "platform", info.Platform || "-"); + this._prop(list, "os", info.OS || "-"); + this._prop(list, "arch", info.Arch || "-"); + + const sys = info.System; + if (sys) + { + this._prop(list, "cpus", sys.cpu_count || "-"); + this._prop(list, "cores", sys.core_count || "-"); + this._prop(list, "logical processors", sys.lp_count || "-"); + this._prop(list, "total memory", sys.total_memory_mb ? Friendly.bytes(sys.total_memory_mb * 1048576) : "-"); + this._prop(list, "available memory", sys.avail_memory_mb ? Friendly.bytes(sys.avail_memory_mb * 1048576) : "-"); + if (sys.uptime_seconds) + { + this._prop(list, "system uptime", this._format_duration(sys.uptime_seconds * 1000)); + } + } + } + + // Runtime Configuration + if (info.RuntimeConfig) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Runtime Configuration"); + const list = tile.tag().classify("info-props"); + + for (const key in info.RuntimeConfig) + { + this._prop(list, key, info.RuntimeConfig[key] || "-"); + } + } + + // Build Configuration + if (info.BuildConfig) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Build Configuration"); + const list = tile.tag().classify("info-props"); + + for (const key in info.BuildConfig) + { + this._prop(list, key, info.BuildConfig[key] ? "yes" : "no"); + } + } + + // Services + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Services"); + const list = tile.tag().classify("info-props"); + + const svc_list = (services.services || []).map(s => s.base_uri).sort(); + for (const uri of svc_list) + { + this._prop(list, uri, "registered"); + } + } + + // Garbage Collection + if (gc) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Garbage Collection"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "status", gc.Status || "-"); + + if (gc.AreDiskWritesBlocked !== undefined) + { + this._prop(list, "disk writes blocked", gc.AreDiskWritesBlocked ? "yes" : "no"); + } + + if (gc.DiskSize) + { + this._prop(list, "disk size", gc.DiskSize); + this._prop(list, "disk used", gc.DiskUsed); + this._prop(list, "disk free", gc.DiskFree); + } + + const cfg = gc.Config; + if (cfg) + { + this._prop(list, "gc enabled", cfg.Enabled ? "yes" : "no"); + if (cfg.Interval) + { + this._prop(list, "interval", this._friendly_duration(cfg.Interval)); + } + if (cfg.LightweightInterval) + { + this._prop(list, "lightweight interval", this._friendly_duration(cfg.LightweightInterval)); + } + if (cfg.MaxCacheDuration) + { + this._prop(list, "max cache duration", this._friendly_duration(cfg.MaxCacheDuration)); + } + if (cfg.MaxProjectStoreDuration) + { + this._prop(list, "max project duration", this._friendly_duration(cfg.MaxProjectStoreDuration)); + } + if (cfg.MaxBuildStoreDuration) + { + this._prop(list, "max build duration", this._friendly_duration(cfg.MaxBuildStoreDuration)); + } + } + + if (gc.FullGC) + { + if (gc.FullGC.LastTime) + { + this._prop(list, "last full gc", this._friendly_timestamp(gc.FullGC.LastTime)); + } + if (gc.FullGC.TimeToNext) + { + this._prop(list, "next full gc", this._friendly_duration(gc.FullGC.TimeToNext)); + } + } + + if (gc.LightweightGC) + { + if (gc.LightweightGC.LastTime) + { + this._prop(list, "last lightweight gc", this._friendly_timestamp(gc.LightweightGC.LastTime)); + } + if (gc.LightweightGC.TimeToNext) + { + this._prop(list, "next lightweight gc", this._friendly_duration(gc.LightweightGC.TimeToNext)); + } + } + } + } + + _prop(parent, label, value) + { + const row = parent.tag().classify("info-prop"); + row.tag().classify("info-prop-label").text(label); + const val = row.tag().classify("info-prop-value"); + const str = String(value); + if (str.match(/^[A-Za-z]:[\\/]/) || str.startsWith("/")) + { + val.tag("a").text(str).attr("href", "vscode://" + str.replace(/\\/g, "/")); + } + else + { + val.text(str); + } + } + + _friendly_timestamp(value) + { + const d = new Date(value); + if (isNaN(d.getTime())) + { + return String(value); + } + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } + + _friendly_duration(value) + { + if (typeof value === "number") + { + return this._format_duration(value); + } + + const str = String(value); + const match = str.match(/^[+-]?(?:(\d+)\.)?(\d+):(\d+):(\d+)(?:\.(\d+))?$/); + if (!match) + { + return str; + } + + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3], 10); + const seconds = parseInt(match[4], 10); + const total_seconds = days * 86400 + hours * 3600 + minutes * 60 + seconds; + + return this._format_duration(total_seconds * 1000); + } + + _format_duration(ms) + { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) + { + return `${days}d ${hours % 24}h ${minutes % 60}m`; + } + if (hours > 0) + { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) + { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + } +} diff --git a/src/zenserver/frontend/html/pages/map.js b/src/zenserver/frontend/html/pages/map.js index 58046b255..ac8f298aa 100644 --- a/src/zenserver/frontend/html/pages/map.js +++ b/src/zenserver/frontend/html/pages/map.js @@ -116,9 +116,9 @@ export class Page extends ZenPage for (const name of sorted_keys) nodes.push(new_nodes[name] / branch_size); - var stats = Friendly.kib(branch_size); + var stats = Friendly.bytes(branch_size); stats += " / "; - stats += Friendly.kib(total_size); + stats += Friendly.bytes(total_size); stats += " ("; stats += 0|((branch_size * 100) / total_size); stats += "%)"; diff --git a/src/zenserver/frontend/html/pages/metrics.js b/src/zenserver/frontend/html/pages/metrics.js new file mode 100644 index 000000000..e7a2eca67 --- /dev/null +++ b/src/zenserver/frontend/html/pages/metrics.js @@ -0,0 +1,232 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { PropTable, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +class TemporalStat +{ + constructor(data, as_bytes) + { + this._data = data; + this._as_bytes = as_bytes; + } + + toString() + { + const columns = [ + /* count */ {}, + /* rate */ {}, + /* t */ {}, {}, + ]; + const data = this._data; + for (var key in data) + { + var out = columns[0]; + if (key.startsWith("rate_")) out = columns[1]; + else if (key.startsWith("t_p")) out = columns[3]; + else if (key.startsWith("t_")) out = columns[2]; + out[key] = data[key]; + } + + var friendly = this._as_bytes ? Friendly.bytes : Friendly.sep; + + var content = ""; + for (var i = 0; i < columns.length; ++i) + { + const column = columns[i]; + for (var key in column) + { + var value = column[key]; + if (i) + { + value = Friendly.sep(value, 2); + key = key.padStart(9); + content += key + ": " + value; + } + else + content += friendly(value); + content += "\r\n"; + } + } + + return content; + } + + tag() + { + return "pre"; + } +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("metrics"); + + const metrics_section = this.add_section("metrics"); + const top_toolbar = metrics_section.add_widget(Toolbar); + const tb_right = top_toolbar.right(); + this._refresh_label = tb_right.add("", "label"); + this._pause_btn = tb_right.add("pause").on_click(() => this._toggle_pause()); + + this._paused = false; + this._last_refresh = Date.now(); + this._provider_views = []; + + const providers_data = await new Fetcher().resource("stats").json(); + const providers = providers_data["providers"] || []; + + const stats_list = await Promise.all(providers.map((provider) => + new Fetcher() + .resource("stats", provider) + .param("cidstorestats", "true") + .param("cachestorestats", "true") + .json() + .then((stats) => ({ provider, stats })) + )); + + for (const { provider, stats } of stats_list) + { + this._condense(stats); + this._provider_views.push(this._render_provider(provider, stats)); + } + + this._last_refresh = Date.now(); + this._update_refresh_label(); + + this._timer_id = setInterval(() => this._refresh(), 5000); + this._tick_id = setInterval(() => this._update_refresh_label(), 1000); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) + this._pause_timer(false); + else if (!this._paused) + this._resume_timer(); + }); + } + + _render_provider(provider, stats) + { + const section = this.add_section(provider); + const toolbar = section.add_widget(Toolbar); + + toolbar.right().add("detailed →").on_click(() => { + window.location = "?page=stat&provider=" + provider; + }); + + const table = section.add_widget(PropTable); + let current_stats = stats; + let current_category = undefined; + + const show_category = (cat) => { + current_category = cat; + table.clear(); + table.add_object(current_stats[cat], true, 3); + }; + + var first = undefined; + for (var name in stats) + { + first = first || name; + toolbar.left().add(name).on_click(show_category, name); + } + + if (first) + show_category(first); + + return { + provider, + set_stats: (new_stats) => { + current_stats = new_stats; + if (current_category && current_stats[current_category]) + show_category(current_category); + }, + }; + } + + async _refresh() + { + const updates = await Promise.all(this._provider_views.map((view) => + new Fetcher() + .resource("stats", view.provider) + .param("cidstorestats", "true") + .param("cachestorestats", "true") + .json() + .then((stats) => ({ view, stats })) + )); + + for (const { view, stats } of updates) + { + this._condense(stats); + view.set_stats(stats); + } + + this._last_refresh = Date.now(); + this._update_refresh_label(); + } + + _update_refresh_label() + { + const elapsed = Math.floor((Date.now() - this._last_refresh) / 1000); + this._refresh_label.inner().textContent = "refreshed " + elapsed + "s ago"; + } + + _toggle_pause() + { + if (this._paused) + this._resume_timer(); + else + this._pause_timer(true); + } + + _pause_timer(user_paused=true) + { + clearInterval(this._timer_id); + this._timer_id = undefined; + if (user_paused) + { + this._paused = true; + this._pause_btn.inner().textContent = "resume"; + } + } + + _resume_timer() + { + this._paused = false; + this._pause_btn.inner().textContent = "pause"; + this._timer_id = setInterval(() => this._refresh(), 5000); + this._refresh(); + } + + _condense(stats) + { + const impl = function(node) + { + for (var name in node) + { + const candidate = node[name]; + if (!(candidate instanceof Object)) + continue; + + if (candidate["rate_mean"] != undefined) + { + const as_bytes = (name.indexOf("bytes") >= 0); + node[name] = new TemporalStat(candidate, as_bytes); + continue; + } + + impl(candidate); + } + } + + for (var name in stats) + impl(stats[name]); + } +} diff --git a/src/zenserver/frontend/html/pages/oplog.js b/src/zenserver/frontend/html/pages/oplog.js index a286f8651..fb857affb 100644 --- a/src/zenserver/frontend/html/pages/oplog.js +++ b/src/zenserver/frontend/html/pages/oplog.js @@ -81,7 +81,7 @@ export class Page extends ZenPage const right = nav.right(); right.add(Friendly.sep(oplog_info["opcount"])); - right.add("(" + Friendly.kib(oplog_info["totalsize"]) + ")"); + right.add("(" + Friendly.bytes(oplog_info["totalsize"]) + ")"); right.sep(); var search_input = right.add("search:", "label").tag("input") diff --git a/src/zenserver/frontend/html/pages/orchestrator.js b/src/zenserver/frontend/html/pages/orchestrator.js new file mode 100644 index 000000000..24805c722 --- /dev/null +++ b/src/zenserver/frontend/html/pages/orchestrator.js @@ -0,0 +1,405 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("orchestrator"); + + // Agents section + const agents_section = this._collapsible_section("Compute Agents"); + this._agents_host = agents_section; + this._agents_table = null; + + // Clients section + const clients_section = this._collapsible_section("Connected Clients"); + this._clients_host = clients_section; + this._clients_table = null; + + // Event history + const history_section = this._collapsible_section("Worker Events"); + this._history_host = history_section; + this._history_table = null; + + const client_history_section = this._collapsible_section("Client Events"); + this._client_history_host = client_history_section; + this._client_history_table = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Initial fetch + await this._fetch_all(); + + // Connect WebSocket for live updates, fall back to polling + this._connect_ws(); + } + + _collapsible_section(name) + { + const section = this.add_section(name); + const container = section._parent.inner(); + const heading = container.firstElementChild; + + heading.style.cursor = "pointer"; + heading.style.userSelect = "none"; + + const indicator = document.createElement("span"); + indicator.textContent = " \u25BC"; + indicator.style.fontSize = "0.7em"; + heading.appendChild(indicator); + + let collapsed = false; + heading.addEventListener("click", (e) => { + if (e.target !== heading && e.target !== indicator) + { + return; + } + collapsed = !collapsed; + indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; + let sibling = heading.nextElementSibling; + while (sibling) + { + sibling.style.display = collapsed ? "none" : ""; + sibling = sibling.nextElementSibling; + } + }); + + return section; + } + + async _fetch_all() + { + try + { + const [agents, history, clients, client_history] = await Promise.all([ + new Fetcher().resource("/orch/agents").json(), + new Fetcher().resource("/orch/history").param("limit", "50").json().catch(() => null), + new Fetcher().resource("/orch/clients").json().catch(() => null), + new Fetcher().resource("/orch/clients/history").param("limit", "50").json().catch(() => null), + ]); + + this._render_agents(agents); + if (history) + { + this._render_history(history.events || []); + } + if (clients) + { + this._render_clients(clients.clients || []); + } + if (client_history) + { + this._render_client_history(client_history.client_events || []); + } + } + catch (e) { /* service unavailable */ } + } + + _connect_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/orch/ws`); + + ws.onopen = () => { + if (this._poll_timer) + { + clearInterval(this._poll_timer); + this._poll_timer = null; + } + }; + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const data = JSON.parse(ev.data); + this._render_agents(data); + if (data.events) + { + this._render_history(data.events); + } + if (data.clients) + { + this._render_clients(data.clients); + } + if (data.client_events) + { + this._render_client_history(data.client_events); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { + this._start_polling(); + setTimeout(() => this._connect_ws(), 3000); + }; + + ws.onerror = () => { /* onclose will fire */ }; + } + catch (e) + { + this._start_polling(); + } + } + + _start_polling() + { + if (!this._poll_timer) + { + this._poll_timer = setInterval(() => this._fetch_all(), 2000); + } + } + + _render_agents(data) + { + const workers = data.workers || []; + + if (this._agents_table) + { + this._agents_table.clear(); + } + else + { + this._agents_table = this._agents_host.add_widget( + Table, + ["hostname", "CPUs", "CPU usage", "memory", "queues", "pending", "running", "completed", "traffic", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workers.length === 0) + { + return; + } + + let totalCpus = 0, totalWeightedCpu = 0; + let totalMemUsed = 0, totalMemTotal = 0; + let totalQueues = 0, totalPending = 0, totalRunning = 0, totalCompleted = 0; + let totalRecv = 0, totalSent = 0; + + for (const w of workers) + { + const cpus = w.cpus || 0; + const cpuUsage = w.cpu_usage; + const memUsed = w.memory_used || 0; + const memTotal = w.memory_total || 0; + const queues = w.active_queues || 0; + const pending = w.actions_pending || 0; + const running = w.actions_running || 0; + const completed = w.actions_completed || 0; + const recv = w.bytes_received || 0; + const sent = w.bytes_sent || 0; + + totalCpus += cpus; + if (cpus > 0 && typeof cpuUsage === "number") + { + totalWeightedCpu += cpuUsage * cpus; + } + totalMemUsed += memUsed; + totalMemTotal += memTotal; + totalQueues += queues; + totalPending += pending; + totalRunning += running; + totalCompleted += completed; + totalRecv += recv; + totalSent += sent; + + const hostname = w.hostname || ""; + const row = this._agents_table.add_row( + hostname, + cpus > 0 ? Friendly.sep(cpus) : "-", + typeof cpuUsage === "number" ? cpuUsage.toFixed(1) + "%" : "-", + memTotal > 0 ? Friendly.bytes(memUsed) + " / " + Friendly.bytes(memTotal) : "-", + queues > 0 ? Friendly.sep(queues) : "-", + Friendly.sep(pending), + Friendly.sep(running), + Friendly.sep(completed), + this._format_traffic(recv, sent), + this._format_last_seen(w.dt), + ); + + // Link hostname to worker dashboard + if (w.uri) + { + const cell = row.get_cell(0); + cell.inner().textContent = ""; + cell.tag("a").text(hostname).attr("href", w.uri + "/dashboard/compute/").attr("target", "_blank"); + } + } + + // Total row + const total = this._agents_table.add_row( + "TOTAL", + Friendly.sep(totalCpus), + "", + totalMemTotal > 0 ? Friendly.bytes(totalMemUsed) + " / " + Friendly.bytes(totalMemTotal) : "-", + Friendly.sep(totalQueues), + Friendly.sep(totalPending), + Friendly.sep(totalRunning), + Friendly.sep(totalCompleted), + this._format_traffic(totalRecv, totalSent), + "", + ); + total.get_cell(0).style("fontWeight", "bold"); + } + + _render_clients(clients) + { + if (this._clients_table) + { + this._clients_table.clear(); + } + else + { + this._clients_table = this._clients_host.add_widget( + Table, + ["client ID", "hostname", "address", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const c of clients) + { + this._clients_table.add_row( + c.id || "", + c.hostname || "", + c.address || "", + this._format_last_seen(c.dt), + ); + } + } + + _render_history(events) + { + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["time", "event", "worker", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.worker_id || "", + evt.hostname || "", + ); + } + } + + _render_client_history(events) + { + if (this._client_history_table) + { + this._client_history_table.clear(); + } + else + { + this._client_history_table = this._client_history_host.add_widget( + Table, + ["time", "event", "client", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._client_history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.client_id || "", + evt.hostname || "", + ); + } + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } + + _format_last_seen(dtMs) + { + if (dtMs == null) + { + return "-"; + } + const seconds = Math.floor(dtMs / 1000); + if (seconds < 60) + { + return seconds + "s ago"; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + { + return minutes + "m " + (seconds % 60) + "s ago"; + } + const hours = Math.floor(minutes / 60); + return hours + "h " + (minutes % 60) + "m ago"; + } + + _format_traffic(recv, sent) + { + if (!recv && !sent) + { + return "-"; + } + return Friendly.bytes(recv) + " / " + Friendly.bytes(sent); + } + + _format_timestamp(ts) + { + if (!ts) + { + return "-"; + } + let date; + if (typeof ts === "number") + { + // .NET-style ticks: convert to Unix ms + const unixMs = (ts - 621355968000000000) / 10000; + date = new Date(unixMs); + } + else + { + date = new Date(ts); + } + if (isNaN(date.getTime())) + { + return "-"; + } + return date.toLocaleTimeString(); + } +} diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js index 592b699dc..dd8032c28 100644 --- a/src/zenserver/frontend/html/pages/page.js +++ b/src/zenserver/frontend/html/pages/page.js @@ -70,14 +70,41 @@ export class ZenPage extends PageBase add_branding(parent) { - var root = parent.tag().id("branding"); + var banner = parent.tag("zen-banner"); + banner.attr("subtitle", "SERVER"); + banner.attr("tagline", "Local Storage Service"); + banner.attr("logo-src", "favicon.ico"); + banner.attr("load", "0"); + + this._banner = banner; + this._poll_status(); + } - const logo_container = root.tag("div").id("logo"); - logo_container.tag("img").attr("src", "favicon.ico").id("zen_icon"); - logo_container.tag("span").id("zen_text").text("zenserver"); - logo_container.tag().id("go_home").on_click(() => window.location.search = ""); + async _poll_status() + { + try + { + var cbo = await new Fetcher().resource("/status/status").cbo(); + if (cbo) + { + var obj = cbo.as_object(); - root.tag("img").attr("src", "epicgames.ico").id("epic_logo"); + var hostname = obj.find("hostname"); + if (hostname) + { + this._banner.attr("tagline", "Local Storage Service \u2014 " + hostname.as_value()); + } + + var cpu = obj.find("cpuUsagePercent"); + if (cpu) + { + this._banner.attr("load", cpu.as_value().toFixed(1)); + } + } + } + catch (e) { console.warn("status poll:", e); } + + setTimeout(() => this._poll_status(), 2000); } add_service_nav(parent) @@ -88,30 +115,34 @@ export class ZenPage extends PageBase // which links to show based on the services that are currently registered. const service_dashboards = [ - { base_uri: "/compute/", label: "Compute", href: "/dashboard/compute/compute.html" }, - { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/compute/orchestrator.html" }, - { base_uri: "/hub/", label: "Hub", href: "/dashboard/compute/hub.html" }, + { base_uri: "/compute/", label: "Compute", href: "/dashboard/?page=compute" }, + { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/?page=orchestrator" }, + { base_uri: "/hub/", label: "Hub", href: "/dashboard/?page=hub" }, ]; + nav.tag("a").text("Home").attr("href", "/dashboard/"); + + nav.tag("a").text("Sessions").attr("href", "/dashboard/?page=sessions"); + nav.tag("a").text("Cache").attr("href", "/dashboard/?page=cache"); + nav.tag("a").text("Projects").attr("href", "/dashboard/?page=projects"); + this._info_link = nav.tag("a").text("Info").attr("href", "/dashboard/?page=info"); + new Fetcher().resource("/api/").json().then((data) => { const services = data.services || []; const uris = new Set(services.map(s => s.base_uri)); const links = service_dashboards.filter(d => uris.has(d.base_uri)); - if (links.length === 0) - { - nav.inner().style.display = "none"; - return; - } - + // Insert service links before the Info link + const info_elem = this._info_link.inner(); for (const link of links) { - nav.tag("a").text(link.label).attr("href", link.href); + const a = document.createElement("a"); + a.textContent = link.label; + a.href = link.href; + info_elem.parentNode.insertBefore(a, info_elem); } - }).catch(() => { - nav.inner().style.display = "none"; - }); + }).catch(() => {}); } set_title(...args) diff --git a/src/zenserver/frontend/html/pages/project.js b/src/zenserver/frontend/html/pages/project.js index 42ae30c8c..3a7a45527 100644 --- a/src/zenserver/frontend/html/pages/project.js +++ b/src/zenserver/frontend/html/pages/project.js @@ -59,7 +59,7 @@ export class Page extends ZenPage info = await info; row.get_cell(1).text(info["markerpath"]); - row.get_cell(2).text(Friendly.kib(info["totalsize"])); + row.get_cell(2).text(Friendly.bytes(info["totalsize"])); row.get_cell(3).text(Friendly.sep(info["opcount"])); row.get_cell(4).text(info["expired"]); } diff --git a/src/zenserver/frontend/html/pages/projects.js b/src/zenserver/frontend/html/pages/projects.js new file mode 100644 index 000000000..9c1e519d4 --- /dev/null +++ b/src/zenserver/frontend/html/pages/projects.js @@ -0,0 +1,447 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Modal } from "../util/modal.js" +import { Table, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("projects"); + + // Project Service Stats + const stats_section = this._collapsible_section("Project Service Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/prj.yaml", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + const stats = await new Fetcher().resource("stats", "prj").json(); + if (stats) + { + this._render_stats(stats); + } + + this._connect_stats_ws(); + + // Projects list + var section = this._collapsible_section("Projects"); + + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all()); + + var columns = [ + "name", + "project dir", + "engine dir", + "oplogs", + "actions", + ]; + + this._project_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + var projects = await new Fetcher().resource("/prj/list").json(); + projects.sort((a, b) => (b.LastAccessTime || 0) - (a.LastAccessTime || 0)); + + for (const project of projects) + { + var row = this._project_table.add_row( + "", + "", + "", + "", + ); + + var cell = row.get_cell(0); + cell.tag().text(project.Id).on_click(() => this.view_project(project.Id)); + + if (project.ProjectRootDir) + { + row.get_cell(1).tag("a").text(project.ProjectRootDir) + .attr("href", "vscode://" + project.ProjectRootDir.replace(/\\/g, "/")); + } + if (project.EngineRootDir) + { + row.get_cell(2).tag("a").text(project.EngineRootDir) + .attr("href", "vscode://" + project.EngineRootDir.replace(/\\/g, "/")); + } + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true).left(); + action_tb.add("view").on_click(() => this.view_project(project.Id)); + action_tb.add("drop").on_click(() => this.drop_project(project.Id)); + + row.attr("zs_name", project.Id); + + // Fetch project details to get oplog count + new Fetcher().resource("prj", project.Id).json().then((info) => { + const oplogs = info["oplogs"] || []; + row.get_cell(3).text(Friendly.sep(oplogs.length)).style("textAlign", "right"); + // Right-align the corresponding header cell + const header = this._project_table._element.firstElementChild; + if (header && header.children[4]) + { + header.children[4].style.textAlign = "right"; + } + }).catch(() => {}); + } + + // Project detail area (inside projects section so it collapses together) + this._project_host = section; + this._project_container = null; + this._selected_project = null; + + // Restore project from URL if present + const prj_param = this.get_param("project"); + if (prj_param) + { + this.view_project(prj_param); + } + } + + _collapsible_section(name) + { + const section = this.add_section(name); + const container = section._parent.inner(); + const heading = container.firstElementChild; + + heading.style.cursor = "pointer"; + heading.style.userSelect = "none"; + + const indicator = document.createElement("span"); + indicator.textContent = " \u25BC"; + indicator.style.fontSize = "0.7em"; + heading.appendChild(indicator); + + let collapsed = false; + heading.addEventListener("click", (e) => { + if (e.target !== heading && e.target !== indicator) + { + return; + } + collapsed = !collapsed; + indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; + let sibling = heading.nextElementSibling; + while (sibling) + { + sibling.style.display = collapsed ? "none" : ""; + sibling = sibling.nextElementSibling; + } + }); + + return section; + } + + _clear_param(name) + { + this._params.delete(name); + const url = new URL(window.location); + url.searchParams.delete(name); + history.replaceState(null, "", url); + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + const stats = all_stats["prj"]; + if (stats) + { + this._render_stats(stats); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _render_stats(stats) + { + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + const grid = this._stats_grid; + + grid.inner().innerHTML = ""; + + // HTTP Requests tile + { + const req = safe(stats, "requests"); + if (req) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP Requests"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const reqData = req.requests || req; + this._metric(left, Friendly.sep(safe(stats, "store.requestcount") || 0), "total requests", true); + if (reqData.rate_mean > 0) + { + this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); + } + if (reqData.rate_1 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); + } + const badRequests = safe(stats, "store.badrequestcount") || 0; + this._metric(left, Friendly.sep(badRequests), "bad requests"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); + if (reqData.t_p75) + { + this._metric(right, Friendly.duration(reqData.t_p75), "p75"); + } + if (reqData.t_p95) + { + this._metric(right, Friendly.duration(reqData.t_p95), "p95"); + } + if (reqData.t_p99) + { + this._metric(right, Friendly.duration(reqData.t_p99), "p99"); + } + } + } + + // Store Operations tile + { + const store = safe(stats, "store"); + if (store) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Store Operations"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const proj = store.project || {}; + this._metric(left, Friendly.sep(proj.readcount || 0), "project reads", true); + this._metric(left, Friendly.sep(proj.writecount || 0), "project writes"); + this._metric(left, Friendly.sep(proj.deletecount || 0), "project deletes"); + + const right = columns.tag().classify("tile-metrics"); + const oplog = store.oplog || {}; + this._metric(right, Friendly.sep(oplog.readcount || 0), "oplog reads", true); + this._metric(right, Friendly.sep(oplog.writecount || 0), "oplog writes"); + this._metric(right, Friendly.sep(oplog.deletecount || 0), "oplog deletes"); + } + } + + // Op & Chunk tile + { + const store = safe(stats, "store"); + if (store) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Ops & Chunks"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const op = store.op || {}; + const opTotal = (op.hitcount || 0) + (op.misscount || 0); + const opRatio = opTotal > 0 ? (((op.hitcount || 0) / opTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(left, opRatio, "op hit ratio", true); + this._metric(left, Friendly.sep(op.hitcount || 0), "op hits"); + this._metric(left, Friendly.sep(op.misscount || 0), "op misses"); + this._metric(left, Friendly.sep(op.writecount || 0), "op writes"); + + const right = columns.tag().classify("tile-metrics"); + const chunk = store.chunk || {}; + const chunkTotal = (chunk.hitcount || 0) + (chunk.misscount || 0); + const chunkRatio = chunkTotal > 0 ? (((chunk.hitcount || 0) / chunkTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(right, chunkRatio, "chunk hit ratio", true); + this._metric(right, Friendly.sep(chunk.hitcount || 0), "chunk hits"); + this._metric(right, Friendly.sep(chunk.misscount || 0), "chunk misses"); + this._metric(right, Friendly.sep(chunk.writecount || 0), "chunk writes"); + } + } + + // Storage tile + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Storage"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, safe(stats, "store.size.disk") != null ? Friendly.bytes(safe(stats, "store.size.disk")) : "-", "store disk", true); + this._metric(left, safe(stats, "store.size.memory") != null ? Friendly.bytes(safe(stats, "store.size.memory")) : "-", "store memory"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, safe(stats, "cid.size.total") != null ? Friendly.bytes(safe(stats, "cid.size.total")) : "-", "cid total", true); + this._metric(right, safe(stats, "cid.size.tiny") != null ? Friendly.bytes(safe(stats, "cid.size.tiny")) : "-", "cid tiny"); + this._metric(right, safe(stats, "cid.size.small") != null ? Friendly.bytes(safe(stats, "cid.size.small")) : "-", "cid small"); + this._metric(right, safe(stats, "cid.size.large") != null ? Friendly.bytes(safe(stats, "cid.size.large")) : "-", "cid large"); + } + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } + + async view_project(project_id) + { + // Toggle off if already selected + if (this._selected_project === project_id) + { + this._selected_project = null; + this._clear_project_detail(); + this._clear_param("project"); + return; + } + + this._selected_project = project_id; + this._clear_project_detail(); + this.set_param("project", project_id); + + const info = await new Fetcher().resource("prj", project_id).json(); + if (this._selected_project !== project_id) + { + return; + } + + const section = this._project_host.add_section(project_id); + this._project_container = section; + + // Oplogs table + const oplog_section = section.add_section("Oplogs"); + const oplog_table = oplog_section.add_widget( + Table, + ["name", "marker", "size", "ops", "expired", "actions"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric + ); + + let totalSize = 0, totalOps = 0; + const total_row = oplog_table.add_row("TOTAL"); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(2).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).style("textAlign", "right").style("fontWeight", "bold"); + + // Right-align header for numeric columns (size, ops) + const header = oplog_table._element.firstElementChild; + for (let i = 3; i < header.children.length - 1; i++) + { + header.children[i].style.textAlign = "right"; + } + + for (const oplog of info["oplogs"] || []) + { + const name = oplog["id"]; + const row = oplog_table.add_row(""); + + var cell = row.get_cell(0); + cell.tag().text(name).link("", { + "page": "oplog", + "project": project_id, + "oplog": name, + }); + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true).left(); + action_tb.add("list").link("", { "page": "oplog", "project": project_id, "oplog": name }); + action_tb.add("tree").link("", { "page": "tree", "project": project_id, "oplog": name }); + action_tb.add("drop").on_click(() => this.drop_oplog(project_id, name)); + + new Fetcher().resource("prj", project_id, "oplog", name).json().then((data) => { + row.get_cell(1).text(data["markerpath"]); + row.get_cell(2).text(Friendly.bytes(data["totalsize"])).style("textAlign", "right"); + row.get_cell(3).text(Friendly.sep(data["opcount"])).style("textAlign", "right"); + row.get_cell(4).text(data["expired"]); + + totalSize += data["totalsize"] || 0; + totalOps += data["opcount"] || 0; + total_row.get_cell(2).text(Friendly.bytes(totalSize)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).text(Friendly.sep(totalOps)).style("textAlign", "right").style("fontWeight", "bold"); + }).catch(() => {}); + } + } + + _clear_project_detail() + { + if (this._project_container) + { + this._project_container._parent.inner().remove(); + this._project_container = null; + } + } + + drop_oplog(project_id, oplog_id) + { + const drop = async () => { + await new Fetcher().resource("prj", project_id, "oplog", oplog_id).delete(); + // Refresh the project view + this._selected_project = null; + this._clear_project_detail(); + this.view_project(project_id); + }; + + new Modal() + .title("Confirmation") + .message(`Drop oplog '${oplog_id}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + drop_project(project_id) + { + const drop = async () => { + await new Fetcher().resource("prj", project_id).delete(); + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message(`Drop project '${project_id}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + async drop_all() + { + const drop = async () => { + for (const row of this._project_table) + { + const project_id = row.attr("zs_name"); + await new Fetcher().resource("prj", project_id).delete(); + } + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message("Drop every project?") + .option("Yes", () => drop()) + .option("No"); + } +} diff --git a/src/zenserver/frontend/html/pages/sessions.js b/src/zenserver/frontend/html/pages/sessions.js new file mode 100644 index 000000000..95533aa96 --- /dev/null +++ b/src/zenserver/frontend/html/pages/sessions.js @@ -0,0 +1,61 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("sessions"); + + const data = await new Fetcher().resource("/sessions/").json(); + const sessions = data.sessions || []; + + const section = this.add_section("Sessions"); + + if (sessions.length === 0) + { + section.tag().classify("empty-state").text("No active sessions."); + return; + } + + const columns = [ + "id", + "created", + "updated", + "metadata", + ]; + const table = section.add_widget(Table, columns, Table.Flag_FitLeft); + + for (const session of sessions) + { + const created = session.created_at ? new Date(session.created_at).toLocaleString() : "-"; + const updated = session.updated_at ? new Date(session.updated_at).toLocaleString() : "-"; + const meta = this._format_metadata(session.metadata); + + const row = table.add_row( + session.id || "-", + created, + updated, + meta, + ); + } + } + + _format_metadata(metadata) + { + if (!metadata || Object.keys(metadata).length === 0) + { + return "-"; + } + + return Object.entries(metadata) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + } +} diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js index 2cf12bf12..3a68a725d 100644 --- a/src/zenserver/frontend/html/pages/start.js +++ b/src/zenserver/frontend/html/pages/start.js @@ -13,109 +13,117 @@ export class Page extends ZenPage { async main() { + // Discover which services are available + const api_data = await new Fetcher().resource("/api/").json(); + const available = new Set((api_data.services || []).map(s => s.base_uri)); + // project list - var section = this.add_section("projects"); + var project_table = null; + if (available.has("/prj/")) + { + var section = this.add_section("Cooked Projects"); - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("projects")); + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("projects")); - var columns = [ - "name", - "project_dir", - "engine_dir", - "actions", - ]; - var project_table = section.add_widget(Table, columns); + var columns = [ + "name", + "project_dir", + "engine_dir", + "actions", + ]; + project_table = section.add_widget(Table, columns); - for (const project of await new Fetcher().resource("/prj/list").json()) - { - var row = project_table.add_row( - "", - project.ProjectRootDir, - project.EngineRootDir, - ); + var projects = await new Fetcher().resource("/prj/list").json(); + projects.sort((a, b) => (b.LastAccessTime || 0) - (a.LastAccessTime || 0)); + projects = projects.slice(0, 25); + projects.sort((a, b) => a.Id.localeCompare(b.Id)); - var cell = row.get_cell(0); - cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id); + for (const project of projects) + { + var row = project_table.add_row( + "", + project.ProjectRootDir, + project.EngineRootDir, + ); + + var cell = row.get_cell(0); + cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id); - var cell = row.get_cell(-1); - var action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id); - action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id); + var cell = row.get_cell(-1); + var action_tb = new Toolbar(cell, true); + action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id); + action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id); - row.attr("zs_name", project.Id); + row.attr("zs_name", project.Id); + } } // cache - var section = this.add_section("cache"); - - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - - columns = [ - "namespace", - "dir", - "buckets", - "entries", - "size disk", - "size mem", - "actions", - ] - var zcache_info = new Fetcher().resource("/z$/").json(); - const cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); - for (const namespace of (await zcache_info)["Namespaces"]) + var cache_table = null; + if (available.has("/z$/")) { - new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { - const row = cache_table.add_row( - "", - data["Configuration"]["RootDir"], - data["Buckets"].length, - data["EntryCount"], - Friendly.kib(data["StorageSize"].DiskSize), - Friendly.kib(data["StorageSize"].MemorySize) - ); - var cell = row.get_cell(0); - cell.tag().text(namespace).on_click(() => this.view_zcache(namespace)); - row.get_cell(1).tag().text(namespace); + var section = this.add_section("Cache"); - cell = row.get_cell(-1); - const action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click(() => this.view_zcache(namespace)); - action_tb.left().add("drop").on_click(() => this.drop_zcache(namespace)); + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - row.attr("zs_name", namespace); - }); + var columns = [ + "namespace", + "dir", + "buckets", + "entries", + "size disk", + "size mem", + "actions", + ]; + var zcache_info = await new Fetcher().resource("/z$/").json(); + cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); + for (const namespace of zcache_info["Namespaces"] || []) + { + new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { + const row = cache_table.add_row( + "", + data["Configuration"]["RootDir"], + data["Buckets"].length, + data["EntryCount"], + Friendly.bytes(data["StorageSize"].DiskSize), + Friendly.bytes(data["StorageSize"].MemorySize) + ); + var cell = row.get_cell(0); + cell.tag().text(namespace).on_click(() => this.view_zcache(namespace)); + row.get_cell(1).tag().text(namespace); + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("view").on_click(() => this.view_zcache(namespace)); + action_tb.left().add("drop").on_click(() => this.drop_zcache(namespace)); + + row.attr("zs_name", namespace); + }); + } } - // stats + // stats tiles const safe_lookup = (obj, path, pretty=undefined) => { const ret = path.split(".").reduce((a,b) => a && a[b], obj); - if (ret === undefined) return "-"; + if (ret === undefined) return undefined; return pretty ? pretty(ret) : ret; }; - section = this.add_section("stats"); - columns = [ - "name", - "req count", - "size disk", - "size mem", - "cid total", - ]; - const stats_table = section.add_widget(Table, columns, Table.Flag_PackRight); - var providers = new Fetcher().resource("stats").json(); - for (var provider of (await providers)["providers"]) - { - var stats = await new Fetcher().resource("stats", provider).json(); - var size_stat = (stats.store || stats.cache); - var values = [ - "", - safe_lookup(stats, "requests.count"), - safe_lookup(size_stat, "size.disk", Friendly.kib), - safe_lookup(size_stat, "size.memory", Friendly.kib), - safe_lookup(stats, "cid.size.total"), - ]; - row = stats_table.add_row(...values); - row.get_cell(0).tag().text(provider).on_click((x) => this.view_stat(x), provider); - } + var section = this.add_section("Stats"); + section.tag().classify("dropall").text("metrics dashboard →").on_click(() => { + window.location = "?page=metrics"; + }); + + var providers_data = await new Fetcher().resource("stats").json(); + var provider_list = providers_data["providers"] || []; + var all_stats = {}; + await Promise.all(provider_list.map(async (provider) => { + all_stats[provider] = await new Fetcher().resource("stats", provider).json(); + })); + + this._stats_grid = section.tag().classify("grid").classify("stats-tiles"); + this._safe_lookup = safe_lookup; + this._render_stats(all_stats); // version var ver_tag = this.tag().id("version"); @@ -125,6 +133,159 @@ export class Page extends ZenPage this._project_table = project_table; this._cache_table = cache_table; + + // WebSocket for live stats updates + this._connect_stats_ws(); + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + this._render_stats(all_stats); + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _render_stats(all_stats) + { + const grid = this._stats_grid; + const safe_lookup = this._safe_lookup; + + // Clear existing tiles + grid.inner().innerHTML = ""; + + // HTTP tile — aggregate request stats across all providers + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP"); + const columns = tile.tag().classify("tile-columns"); + + // Left column: request stats + const left = columns.tag().classify("tile-metrics"); + + let total_requests = 0; + let total_rate = 0; + for (const p in all_stats) + { + total_requests += (safe_lookup(all_stats[p], "requests.count") || 0); + total_rate += (safe_lookup(all_stats[p], "requests.rate_1") || 0); + } + + this._add_tile_metric(left, Friendly.sep(total_requests), "total requests", true); + if (total_rate > 0) + this._add_tile_metric(left, Friendly.sep(total_rate, 1) + "/s", "req/sec (1m)"); + + // Right column: websocket stats + const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; + const right = columns.tag().classify("tile-metrics"); + + this._add_tile_metric(right, Friendly.sep(ws.active_connections || 0), "ws connections", true); + const ws_frames = (ws.frames_received || 0) + (ws.frames_sent || 0); + if (ws_frames > 0) + this._add_tile_metric(right, Friendly.sep(ws_frames), "ws frames"); + const ws_bytes = (ws.bytes_received || 0) + (ws.bytes_sent || 0); + if (ws_bytes > 0) + this._add_tile_metric(right, Friendly.bytes(ws_bytes), "ws traffic"); + + tile.on_click(() => { window.location = "?page=metrics"; }); + } + + // Cache tile (z$) + if (all_stats["z$"]) + { + const s = all_stats["z$"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Cache"); + const body = tile.tag().classify("tile-metrics"); + + const hits = safe_lookup(s, "cache.hits") || 0; + const misses = safe_lookup(s, "cache.misses") || 0; + const ratio = (hits + misses) > 0 ? ((hits / (hits + misses)) * 100).toFixed(1) + "%" : "-"; + + this._add_tile_metric(body, ratio, "hit ratio", true); + this._add_tile_metric(body, safe_lookup(s, "cache.size.disk", Friendly.bytes) || "-", "disk"); + this._add_tile_metric(body, safe_lookup(s, "cache.size.memory", Friendly.bytes) || "-", "memory"); + + tile.on_click(() => { window.location = "?page=stat&provider=z$"; }); + } + + // Project Store tile (prj) + if (all_stats["prj"]) + { + const s = all_stats["prj"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Project Store"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "store.size.disk", Friendly.bytes) || "-", "disk"); + + tile.on_click(() => { window.location = "?page=stat&provider=prj"; }); + } + + // Build Store tile (builds) + if (all_stats["builds"]) + { + const s = all_stats["builds"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Build Store"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "store.size.disk", Friendly.bytes) || "-", "disk"); + + tile.on_click(() => { window.location = "?page=stat&provider=builds"; }); + } + + // Workspace tile (ws) + if (all_stats["ws"]) + { + const s = all_stats["ws"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Workspace"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "workspaces.filescount", Friendly.sep) || "-", "files"); + + tile.on_click(() => { window.location = "?page=stat&provider=ws"; }); + } + } + + _add_tile_metric(parent, value, label, hero=false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); } view_stat(provider) diff --git a/src/zenserver/frontend/html/pages/stat.js b/src/zenserver/frontend/html/pages/stat.js index d6c7fa8e8..4f020ac5e 100644 --- a/src/zenserver/frontend/html/pages/stat.js +++ b/src/zenserver/frontend/html/pages/stat.js @@ -33,7 +33,7 @@ class TemporalStat out[key] = data[key]; } - var friendly = this._as_bytes ? Friendly.kib : Friendly.sep; + var friendly = this._as_bytes ? Friendly.bytes : Friendly.sep; var content = ""; for (var i = 0; i < columns.length; ++i) diff --git a/src/zenserver/frontend/html/pages/tree.js b/src/zenserver/frontend/html/pages/tree.js index 08a578492..b5fece5a3 100644 --- a/src/zenserver/frontend/html/pages/tree.js +++ b/src/zenserver/frontend/html/pages/tree.js @@ -106,7 +106,7 @@ export class Page extends ZenPage for (var i = 0; i < 2; ++i) { - const size = Friendly.kib(new_nodes[name][i]); + const size = Friendly.bytes(new_nodes[name][i]); info.tag().text(size); } diff --git a/src/zenserver/frontend/html/pages/zcache.js b/src/zenserver/frontend/html/pages/zcache.js index 974893b21..d8bdc892a 100644 --- a/src/zenserver/frontend/html/pages/zcache.js +++ b/src/zenserver/frontend/html/pages/zcache.js @@ -27,8 +27,8 @@ export class Page extends ZenPage cfg_table.add_object(info["Configuration"], true); - storage_table.add_property("disk", Friendly.kib(info["StorageSize"]["DiskSize"])); - storage_table.add_property("mem", Friendly.kib(info["StorageSize"]["MemorySize"])); + storage_table.add_property("disk", Friendly.bytes(info["StorageSize"]["DiskSize"])); + storage_table.add_property("mem", Friendly.bytes(info["StorageSize"]["MemorySize"])); storage_table.add_property("entries", Friendly.sep(info["EntryCount"])); var column_names = ["name", "disk", "mem", "entries", "actions"]; @@ -41,8 +41,8 @@ export class Page extends ZenPage { const row = bucket_table.add_row(bucket); new Fetcher().resource(`/z$/${namespace}/${bucket}`).json().then((data) => { - row.get_cell(1).text(Friendly.kib(data["StorageSize"]["DiskSize"])); - row.get_cell(2).text(Friendly.kib(data["StorageSize"]["MemorySize"])); + row.get_cell(1).text(Friendly.bytes(data["StorageSize"]["DiskSize"])); + row.get_cell(2).text(Friendly.bytes(data["StorageSize"]["MemorySize"])); row.get_cell(3).text(Friendly.sep(data["DiskEntryCount"])); const cell = row.get_cell(-1); diff --git a/src/zenserver/frontend/html/theme.js b/src/zenserver/frontend/html/theme.js new file mode 100644 index 000000000..52ca116ab --- /dev/null +++ b/src/zenserver/frontend/html/theme.js @@ -0,0 +1,116 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +// Theme toggle: cycles system → light → dark → system. +// Persists choice in localStorage. Applies data-theme attribute on <html>. + +(function() { + var KEY = 'zen-theme'; + + function getStored() { + try { return localStorage.getItem(KEY); } catch (e) { return null; } + } + + function setStored(value) { + try { + if (value) localStorage.setItem(KEY, value); + else localStorage.removeItem(KEY); + } catch (e) {} + } + + function apply(theme) { + if (theme) + document.documentElement.setAttribute('data-theme', theme); + else + document.documentElement.removeAttribute('data-theme'); + } + + function getEffective(stored) { + if (stored) return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + // Apply stored preference immediately (before paint) + var stored = getStored(); + apply(stored); + + // Create toggle button once DOM is ready + function createToggle() { + var btn = document.createElement('button'); + btn.id = 'zen_theme_toggle'; + btn.title = 'Toggle theme'; + + function updateIcon() { + var effective = getEffective(getStored()); + // Show sun in dark mode (click to go light), moon in light mode (click to go dark) + btn.textContent = effective === 'dark' ? '\u2600' : '\u263E'; + + var isManual = getStored() != null; + btn.title = isManual + ? 'Theme: ' + effective + ' (click to change, double-click for system)' + : 'Theme: system (click to change)'; + } + + btn.addEventListener('click', function() { + var current = getStored(); + var effective = getEffective(current); + // Toggle to the opposite + var next = effective === 'dark' ? 'light' : 'dark'; + setStored(next); + apply(next); + updateIcon(); + }); + + btn.addEventListener('dblclick', function(e) { + e.preventDefault(); + // Reset to system preference + setStored(null); + apply(null); + updateIcon(); + }); + + // Update icon when system preference changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { + if (!getStored()) updateIcon(); + }); + + updateIcon(); + document.body.appendChild(btn); + + // WebSocket pause/play toggle + var WS_KEY = 'zen-ws-paused'; + var wsBtn = document.createElement('button'); + wsBtn.id = 'zen_ws_toggle'; + + var initialPaused = false; + try { initialPaused = localStorage.getItem(WS_KEY) === 'true'; } catch (e) {} + + function updateWsIcon(paused) { + wsBtn.dataset.paused = paused ? 'true' : 'false'; + wsBtn.textContent = paused ? '\u25B6' : '\u23F8'; + wsBtn.title = paused ? 'Resume live updates' : 'Pause live updates'; + } + + updateWsIcon(initialPaused); + + // Fire initial event so pages pick up persisted state + document.addEventListener('DOMContentLoaded', function() { + if (initialPaused) { + document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: true } })); + } + }); + + wsBtn.addEventListener('click', function() { + var paused = wsBtn.dataset.paused !== 'true'; + try { localStorage.setItem(WS_KEY, paused ? 'true' : 'false'); } catch (e) {} + updateWsIcon(paused); + document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: paused } })); + }); + + document.body.appendChild(wsBtn); + } + + if (document.readyState === 'loading') + document.addEventListener('DOMContentLoaded', createToggle); + else + createToggle(); +})(); diff --git a/src/zenserver/frontend/html/util/compactbinary.js b/src/zenserver/frontend/html/util/compactbinary.js index 90e4249f6..415fa4be8 100644 --- a/src/zenserver/frontend/html/util/compactbinary.js +++ b/src/zenserver/frontend/html/util/compactbinary.js @@ -310,8 +310,8 @@ CbFieldView.prototype.as_value = function(int_type=BigInt) case CbFieldType.IntegerPositive: return VarInt.read_uint(this._data_view, int_type)[0]; case CbFieldType.IntegerNegative: return VarInt.read_int(this._data_view, int_type)[0]; - case CbFieldType.Float32: return new DataView(this._data_view.subarray(0, 4)).getFloat32(0, false); - case CbFieldType.Float64: return new DataView(this._data_view.subarray(0, 8)).getFloat64(0, false); + case CbFieldType.Float32: { const s = this._data_view; return new DataView(s.buffer, s.byteOffset, 4).getFloat32(0, false); } + case CbFieldType.Float64: { const s = this._data_view; return new DataView(s.buffer, s.byteOffset, 8).getFloat64(0, false); } case CbFieldType.BoolFalse: return false; case CbFieldType.BoolTrue: return true; diff --git a/src/zenserver/frontend/html/util/friendly.js b/src/zenserver/frontend/html/util/friendly.js index a15252faf..5d4586165 100644 --- a/src/zenserver/frontend/html/util/friendly.js +++ b/src/zenserver/frontend/html/util/friendly.js @@ -20,4 +20,25 @@ export class Friendly static kib(x, p=0) { return Friendly.sep((BigInt(x) + 1023n) / (1n << 10n)|0n, p) + " KiB"; } static mib(x, p=1) { return Friendly.sep( BigInt(x) / (1n << 20n), p) + " MiB"; } static gib(x, p=2) { return Friendly.sep( BigInt(x) / (1n << 30n), p) + " GiB"; } + + static duration(s) + { + const v = Number(s); + if (v >= 1) return Friendly.sep(v, 2) + " s"; + if (v >= 0.001) return Friendly.sep(v * 1000, 2) + " ms"; + if (v >= 0.000001) return Friendly.sep(v * 1000000, 1) + " µs"; + return Friendly.sep(v * 1000000000, 0) + " ns"; + } + + static bytes(x) + { + const v = BigInt(Math.trunc(Number(x))); + if (v >= (1n << 60n)) return Friendly.sep(Number(v) / Number(1n << 60n), 2) + " EiB"; + if (v >= (1n << 50n)) return Friendly.sep(Number(v) / Number(1n << 50n), 2) + " PiB"; + if (v >= (1n << 40n)) return Friendly.sep(Number(v) / Number(1n << 40n), 2) + " TiB"; + if (v >= (1n << 30n)) return Friendly.sep(Number(v) / Number(1n << 30n), 2) + " GiB"; + if (v >= (1n << 20n)) return Friendly.sep(Number(v) / Number(1n << 20n), 1) + " MiB"; + if (v >= (1n << 10n)) return Friendly.sep(Number(v) / Number(1n << 10n), 0) + " KiB"; + return Friendly.sep(Number(v), 0) + " B"; + } } diff --git a/src/zenserver/frontend/html/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js index 32a3f4d28..2964f92f2 100644 --- a/src/zenserver/frontend/html/util/widgets.js +++ b/src/zenserver/frontend/html/util/widgets.js @@ -54,6 +54,8 @@ export class Table extends Widget static Flag_PackRight = 1 << 1; static Flag_BiasLeft = 1 << 2; static Flag_FitLeft = 1 << 3; + static Flag_Sortable = 1 << 4; + static Flag_AlignNumeric = 1 << 5; constructor(parent, column_names, flags=Table.Flag_EvenSpacing, index_base=0) { @@ -81,11 +83,108 @@ export class Table extends Widget root.style("gridTemplateColumns", column_style); - this._add_row(column_names, false); + this._header_row = this._add_row(column_names, false); this._index = index_base; this._num_columns = column_names.length; this._rows = []; + this._flags = flags; + this._sort_column = -1; + this._sort_ascending = true; + + if (flags & Table.Flag_Sortable) + { + this._init_sortable(); + } + } + + _init_sortable() + { + const header_elem = this._element.firstElementChild; + if (!header_elem) + { + return; + } + + const cells = header_elem.children; + for (let i = 0; i < cells.length; i++) + { + const cell = cells[i]; + cell.style.cursor = "pointer"; + cell.style.userSelect = "none"; + cell.addEventListener("click", () => this._sort_by(i)); + } + } + + _sort_by(column_index) + { + if (this._sort_column === column_index) + { + this._sort_ascending = !this._sort_ascending; + } + else + { + this._sort_column = column_index; + this._sort_ascending = true; + } + + // Update header indicators + const header_elem = this._element.firstElementChild; + for (const cell of header_elem.children) + { + const text = cell.textContent.replace(/ [▲▼]$/, ""); + cell.textContent = text; + } + const active_cell = header_elem.children[column_index]; + active_cell.textContent += this._sort_ascending ? " ▲" : " ▼"; + + // Sort rows by comparing cell text content + const dir = this._sort_ascending ? 1 : -1; + const unit_multipliers = { "B": 1, "KiB": 1024, "MiB": 1048576, "GiB": 1073741824, "TiB": 1099511627776, "PiB": 1125899906842624, "EiB": 1152921504606846976 }; + const parse_sortable = (text) => { + // Try byte units first (e.g. "1,234 KiB", "1.5 GiB") + const byte_match = text.match(/^([\d,.]+)\s*(B|[KMGTPE]iB)/); + if (byte_match) + { + const num = parseFloat(byte_match[1].replace(/,/g, "")); + const mult = unit_multipliers[byte_match[2]] || 1; + return num * mult; + } + // Try percentage (e.g. "95.5%") + const pct_match = text.match(/^([\d,.]+)%/); + if (pct_match) + { + return parseFloat(pct_match[1].replace(/,/g, "")); + } + // Try plain number (possibly with commas/separators) + const num = parseFloat(text.replace(/,/g, "")); + if (!isNaN(num)) + { + return num; + } + return null; + }; + this._rows.sort((a, b) => { + const aElem = a.inner().children[column_index]; + const bElem = b.inner().children[column_index]; + const aText = aElem ? aElem.textContent : ""; + const bText = bElem ? bElem.textContent : ""; + + const aNum = parse_sortable(aText); + const bNum = parse_sortable(bText); + + if (aNum !== null && bNum !== null) + { + return (aNum - bNum) * dir; + } + return aText.localeCompare(bText) * dir; + }); + + // Re-order DOM elements + for (const row of this._rows) + { + this._element.appendChild(row.inner()); + } } *[Symbol.iterator]() @@ -121,6 +220,18 @@ export class Table extends Widget ret.push(new TableCell(leaf, row)); } + if ((this._flags & Table.Flag_AlignNumeric) && indexed) + { + for (const c of ret) + { + const t = c.inner().textContent; + if (t && /^\d/.test(t)) + { + c.style("textAlign", "right"); + } + } + } + if (this._index >= 0) ret.shift(); @@ -131,9 +242,34 @@ export class Table extends Widget { var row = this._add_row(args); this._rows.push(row); + + if ((this._flags & Table.Flag_AlignNumeric) && this._rows.length === 1) + { + this._align_header(); + } + return row; } + _align_header() + { + const first_row = this._rows[0]; + if (!first_row) + { + return; + } + const header_elem = this._element.firstElementChild; + const header_cells = header_elem.children; + const data_cells = first_row.inner().children; + for (let i = 0; i < data_cells.length && i < header_cells.length; i++) + { + if (data_cells[i].style.textAlign === "right") + { + header_cells[i].style.textAlign = "right"; + } + } + } + clear(index=0) { const elem = this._element; diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index a80a1a4f6..a968aecab 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -2,66 +2,202 @@ /* theme -------------------------------------------------------------------- */ +/* system preference (default) */ @media (prefers-color-scheme: light) { :root { - --theme_g0: #000; - --theme_g4: #fff; - --theme_g1: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 45%); - --theme_g2: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 80%); - --theme_g3: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 96%); - - --theme_p0: #069; - --theme_p4: hsl(210deg 40% 94%); + --theme_g0: #1f2328; + --theme_g1: #656d76; + --theme_g2: #d0d7de; + --theme_g3: #f6f8fa; + --theme_g4: #ffffff; + + --theme_p0: #0969da; + --theme_p4: #ddf4ff; --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); --theme_ln: var(--theme_p0); - --theme_er: #fcc; + --theme_er: #ffebe9; + + --theme_ok: #1a7f37; + --theme_warn: #9a6700; + --theme_fail: #cf222e; + + --theme_bright: #1f2328; + --theme_faint: #6e7781; + --theme_border_subtle: #d8dee4; } } @media (prefers-color-scheme: dark) { :root { - --theme_g0: #ddd; - --theme_g4: #222; - --theme_g1: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 35%); - --theme_g2: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 65%); - --theme_g3: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 88%); - - --theme_p0: #479; - --theme_p4: #333; + --theme_g0: #c9d1d9; + --theme_g1: #8b949e; + --theme_g2: #30363d; + --theme_g3: #161b22; + --theme_g4: #0d1117; + + --theme_p0: #58a6ff; + --theme_p4: #1c2128; --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); - --theme_ln: #feb; - --theme_er: #622; + --theme_ln: #58a6ff; + --theme_er: #1c1c1c; + + --theme_ok: #3fb950; + --theme_warn: #d29922; + --theme_fail: #f85149; + + --theme_bright: #f0f6fc; + --theme_faint: #6e7681; + --theme_border_subtle: #21262d; } } +/* manual overrides (higher specificity than media queries) */ +:root[data-theme="light"] { + --theme_g0: #1f2328; + --theme_g1: #656d76; + --theme_g2: #d0d7de; + --theme_g3: #f6f8fa; + --theme_g4: #ffffff; + + --theme_p0: #0969da; + --theme_p4: #ddf4ff; + --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); + --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); + --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); + + --theme_ln: var(--theme_p0); + --theme_er: #ffebe9; + + --theme_ok: #1a7f37; + --theme_warn: #9a6700; + --theme_fail: #cf222e; + + --theme_bright: #1f2328; + --theme_faint: #6e7781; + --theme_border_subtle: #d8dee4; +} + +:root[data-theme="dark"] { + --theme_g0: #c9d1d9; + --theme_g1: #8b949e; + --theme_g2: #30363d; + --theme_g3: #161b22; + --theme_g4: #0d1117; + + --theme_p0: #58a6ff; + --theme_p4: #1c2128; + --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); + --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); + --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); + + --theme_ln: #58a6ff; + --theme_er: #1c1c1c; + + --theme_ok: #3fb950; + --theme_warn: #d29922; + --theme_fail: #f85149; + + --theme_bright: #f0f6fc; + --theme_faint: #6e7681; + --theme_border_subtle: #21262d; +} + +/* theme toggle ------------------------------------------------------------- */ + +#zen_ws_toggle { + position: fixed; + top: 16px; + right: 60px; + z-index: 10; + width: 36px; + height: 36px; + border-radius: 6px; + border: 1px solid var(--theme_g2); + background: var(--theme_g3); + color: var(--theme_g1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; + transition: color 0.15s, background 0.15s, border-color 0.15s; + user-select: none; +} + +#zen_ws_toggle:hover { + color: var(--theme_g0); + background: var(--theme_p4); + border-color: var(--theme_g1); +} + +#zen_theme_toggle { + position: fixed; + top: 16px; + right: 16px; + z-index: 10; + width: 36px; + height: 36px; + border-radius: 6px; + border: 1px solid var(--theme_g2); + background: var(--theme_g3); + color: var(--theme_g1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; + transition: color 0.15s, background 0.15s, border-color 0.15s; + user-select: none; +} + +#zen_theme_toggle:hover { + color: var(--theme_g0); + background: var(--theme_p4); + border-color: var(--theme_g1); +} + /* page --------------------------------------------------------------------- */ -body, input { - font-family: consolas, monospace; - font-size: 11pt; +body, input, button { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 14px; } body { overflow-y: scroll; margin: 0; + padding: 20px; background-color: var(--theme_g4); color: var(--theme_g0); } -pre { - margin: 0; +pre, code { + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 13px; + margin: 0; } input { color: var(--theme_g0); background-color: var(--theme_g3); border: 1px solid var(--theme_g2); + border-radius: 4px; + padding: 4px 8px; +} + +button { + color: var(--theme_g0); + background: transparent; + border: none; + cursor: pointer; } * { @@ -69,12 +205,10 @@ input { } #container { - max-width: 130em; - min-width: 80em; + max-width: 1400px; margin: auto; > div { - margin: 0.0em 2.2em 0.0em 2.2em; padding-top: 1.0em; padding-bottom: 1.5em; } @@ -84,20 +218,22 @@ input { #service_nav { display: flex; - justify-content: center; - gap: 0.3em; - margin-bottom: 1.5em; - padding: 0.3em; + align-items: center; + gap: 4px; + margin-bottom: 16px; + padding: 4px; background-color: var(--theme_g3); border: 1px solid var(--theme_g2); - border-radius: 0.4em; + border-radius: 6px; a { - padding: 0.3em 0.9em; - border-radius: 0.3em; - font-size: 0.85em; + padding: 6px 14px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; color: var(--theme_g1); text-decoration: none; + transition: color 0.15s, background 0.15s; } a:hover { @@ -130,28 +266,37 @@ a { } h1 { - font-size: 1.5em; + font-size: 20px; + font-weight: 600; width: 100%; + color: var(--theme_bright); border-bottom: 1px solid var(--theme_g2); + padding-bottom: 0.4em; + margin-bottom: 16px; } h2 { - font-size: 1.25em; - margin-bottom: 0.5em; + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; } h3 { - font-size: 1.1em; + font-size: 14px; + font-weight: 600; margin: 0em; - padding: 0.4em; - background-color: var(--theme_p4); - border-left: 5px solid var(--theme_p2); - font-weight: normal; + padding: 8px 12px; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px 6px 0 0; + color: var(--theme_g1); + text-transform: uppercase; + letter-spacing: 0.5px; } - margin-bottom: 3em; + margin-bottom: 2em; > *:not(h1) { - margin-left: 2em; + margin-left: 0; } } @@ -161,23 +306,36 @@ a { .zen_table { display: grid; border: 1px solid var(--theme_g2); - border-left-style: none; + border-radius: 6px; + overflow: hidden; margin-bottom: 1.2em; + font-size: 13px; > div { display: contents; } - > div:nth-of-type(odd) { + > div:nth-of-type(odd) > div { + background-color: var(--theme_g4); + } + + > div:nth-of-type(even) > div { background-color: var(--theme_g3); } > div:first-of-type { - font-weight: bold; - background-color: var(--theme_p3); + font-weight: 600; + > div { + background-color: var(--theme_g3); + color: var(--theme_g1); + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; + border-bottom: 1px solid var(--theme_g2); + } } - > div:hover { + > div:not(:first-of-type):hover > div { background-color: var(--theme_p4); } @@ -187,16 +345,17 @@ a { } > div > div { - padding: 0.3em; - padding-left: 0.75em; - padding-right: 0.75em; + padding: 8px 12px; align-content: center; - border-left: 1px solid var(--theme_g2); + border-left: 1px solid var(--theme_border_subtle); overflow: auto; overflow-wrap: break-word; - background-color: inherit; white-space: pre-wrap; } + + > div > div:first-child { + border-left: none; + } } /* expandable cell ---------------------------------------------------------- */ @@ -215,6 +374,8 @@ a { .zen_data_text { user-select: text; + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 13px; } /* toolbar ------------------------------------------------------------------ */ @@ -223,6 +384,7 @@ a { display: flex; margin-top: 0.5em; margin-bottom: 0.6em; + font-size: 13px; > div { display: flex; @@ -270,15 +432,16 @@ a { z-index: -1; top: 0; left: 0; - width: 100%; + width: 100%; height: 100%; background: var(--theme_g0); opacity: 0.4; } > div { - border-radius: 0.5em; - background-color: var(--theme_g4); + border-radius: 6px; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); opacity: 1.0; width: 35em; padding: 0em 2em 2em 2em; @@ -289,10 +452,11 @@ a { } .zen_modal_title { - font-size: 1.2em; + font-size: 16px; + font-weight: 600; border-bottom: 1px solid var(--theme_g2); padding: 1.2em 0em 0.5em 0em; - color: var(--theme_g1); + color: var(--theme_bright); } .zen_modal_buttons { @@ -302,16 +466,19 @@ a { > div { margin: 0em 1em 0em 1em; - padding: 1em; + padding: 10px 16px; align-content: center; - border-radius: 0.3em; - background-color: var(--theme_p3); + border-radius: 6px; + background-color: var(--theme_p4); + border: 1px solid var(--theme_g2); width: 6em; cursor: pointer; + font-weight: 500; + transition: background 0.15s; } > div:hover { - background-color: var(--theme_p4); + background-color: var(--theme_p3); } } @@ -329,15 +496,18 @@ a { top: 0; left: 0; width: 100%; - height: 0.5em; + height: 4px; + border-radius: 2px; + overflow: hidden; > div:first-of-type { /* label */ padding: 0.3em; - padding-top: 0.8em; - background-color: var(--theme_p4); + padding-top: 8px; + background-color: var(--theme_g3); width: max-content; - font-size: 0.8em; + font-size: 12px; + color: var(--theme_g1); } > div:last-of-type { @@ -347,7 +517,8 @@ a { left: 0; width: 0%; height: 100%; - background-color: var(--theme_p1); + background-color: var(--theme_p0); + transition: width 0.3s ease; } > div:nth-of-type(2) { @@ -357,7 +528,7 @@ a { left: 0; width: 100%; height: 100%; - background-color: var(--theme_p3); + background-color: var(--theme_g3); } } @@ -366,67 +537,25 @@ a { #crumbs { display: flex; position: relative; - top: -1em; + top: -0.5em; + font-size: 13px; + color: var(--theme_g1); > div { padding-right: 0.5em; } > div:nth-child(odd)::after { - content: ":"; - font-weight: bolder; - color: var(--theme_p2); + content: "/"; + color: var(--theme_g2); + padding-left: 0.5em; } } -/* branding ----------------------------------------------------------------- */ - -#branding { - font-size: 10pt; - font-weight: bolder; - margin-bottom: 2.6em; - position: relative; - - #logo { - width: min-content; - margin: auto; - user-select: none; - position: relative; - display: flex; - align-items: center; - gap: 0.8em; +/* banner ------------------------------------------------------------------- */ - #zen_icon { - width: 3em; - height: 3em; - } - - #zen_text { - font-size: 2em; - font-weight: bold; - letter-spacing: 0.05em; - } - - #go_home { - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; - } - } - - #logo:hover { - filter: drop-shadow(0 0.15em 0.1em var(--theme_p2)); - } - - #epic_logo { - position: absolute; - top: 1em; - right: 0; - width: 5em; - margin: auto; - } +zen-banner { + margin-bottom: 24px; } /* error -------------------------------------------------------------------- */ @@ -437,26 +566,23 @@ a { z-index: 1; color: var(--theme_g0); background-color: var(--theme_er); - padding: 1.0em 2em 2em 2em; + padding: 12px 20px 16px 20px; width: 100%; - border-top: 1px solid var(--theme_g0); + border-top: 1px solid var(--theme_g2); display: flex; + gap: 16px; + align-items: center; + font-size: 13px; > div:nth-child(1) { - font-size: 2.5em; - font-weight: bolder; - font-family: serif; - transform: rotate(-13deg); - color: var(--theme_p0); - } - - > div:nth-child(2) { - margin-left: 2em; + font-size: 24px; + font-weight: bold; + color: var(--theme_fail); } > div:nth-child(2) > pre:nth-child(2) { - margin-top: 0.5em; - font-size: 0.8em; + margin-top: 4px; + font-size: 12px; color: var(--theme_g1); } } @@ -468,18 +594,144 @@ a { min-width: 15%; } +/* sections ----------------------------------------------------------------- */ + +.zen_sector { + position: relative; +} + +.dropall { + position: absolute; + top: 16px; + right: 0; + font-size: 12px; + margin: 0; +} + +/* stats tiles -------------------------------------------------------------- */ + +.stats-tiles { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.stats-tile { + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.stats-tile:hover { + border-color: var(--theme_p0); + background: var(--theme_p4); +} + +.stats-tile-detailed { + position: relative; +} + +.stats-tile-detailed::after { + content: "details \203A"; + position: absolute; + bottom: 12px; + right: 20px; + font-size: 11px; + color: var(--theme_g1); + opacity: 0.6; + transition: opacity 0.15s; +} + +.stats-tile-detailed:hover::after { + opacity: 1; + color: var(--theme_p0); +} + +.stats-tile-selected { + border-color: var(--theme_p0); + background: var(--theme_p4); + box-shadow: 0 0 0 1px var(--theme_p0); +} + +.stats-tile-selected::after { + content: "details \2039"; + opacity: 1; + color: var(--theme_p0); +} + +.tile-metrics { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tile-columns { + display: flex; + gap: 24px; +} + +.tile-columns > .tile-metrics { + flex: 1; + min-width: 0; +} + +.tile-metric .metric-value { + font-size: 16px; +} + +.tile-metric-hero .metric-value { + font-size: 28px; +} + /* start -------------------------------------------------------------------- */ #start { - .dropall { - text-align: right; - font-size: 0.85em; - margin: -0.5em 0 0.5em 0; - } #version { - color: var(--theme_g1); + color: var(--theme_faint); text-align: center; - font-size: 0.85em; + font-size: 12px; + margin-top: 24px; + } +} + +/* info --------------------------------------------------------------------- */ + +#info { + .info-tiles { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + } + + .info-tile { + overflow: hidden; + } + + .info-props { + display: flex; + flex-direction: column; + gap: 1px; + font-size: 13px; + } + + .info-prop { + display: flex; + gap: 12px; + padding: 4px 0; + border-bottom: 1px solid var(--theme_border_subtle); + } + + .info-prop:last-child { + border-bottom: none; + } + + .info-prop-label { + color: var(--theme_g1); + min-width: 140px; + flex-shrink: 0; + text-transform: capitalize; + } + + .info-prop-value { + color: var(--theme_bright); + word-break: break-all; + margin-left: auto; + text-align: right; } } @@ -496,6 +748,8 @@ a { /* tree --------------------------------------------------------------------- */ #tree { + font-size: 13px; + #tree_root > ul { margin-left: 0em; } @@ -507,29 +761,33 @@ a { li > div { display: flex; border-bottom: 1px solid transparent; - padding-left: 0.3em; - padding-right: 0.3em; + padding: 4px 6px; + border-radius: 4px; } li > div > div[active] { text-transform: uppercase; + color: var(--theme_p0); + font-weight: 600; } li > div > div:nth-last-child(3) { margin-left: auto; } li > div > div:nth-last-child(-n + 3) { - font-size: 0.8em; + font-size: 12px; width: 10em; text-align: right; + color: var(--theme_g1); + font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace; } li > div > div:nth-last-child(1) { width: 6em; } li > div:hover { background-color: var(--theme_p4); - border-bottom: 1px solid var(--theme_g2); + border-bottom: 1px solid var(--theme_border_subtle); } li a { - font-weight: bolder; + font-weight: 600; } li::marker { content: "+"; @@ -562,3 +820,262 @@ html:has(#map) { } } } + +/* ========================================================================== */ +/* Shared classes for compute / dashboard pages */ +/* ========================================================================== */ + +/* cards -------------------------------------------------------------------- */ + +.card { + background: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px; + padding: 20px; +} + +.card-title { + font-size: 14px; + font-weight: 600; + color: var(--theme_g1); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* grid --------------------------------------------------------------------- */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +/* metrics ------------------------------------------------------------------ */ + +.metric-value { + font-size: 36px; + font-weight: 600; + color: var(--theme_bright); + line-height: 1; +} + +.metric-label { + font-size: 12px; + color: var(--theme_g1); + margin-top: 4px; +} + +/* section titles ----------------------------------------------------------- */ + +.section-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 16px; + color: var(--theme_bright); +} + +/* html tables (compute pages) ---------------------------------------------- */ + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +th { + text-align: left; + color: var(--theme_g1); + padding: 8px 12px; + border-bottom: 1px solid var(--theme_g2); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; +} + +td { + padding: 8px 12px; + border-bottom: 1px solid var(--theme_border_subtle); + color: var(--theme_g0); +} + +tr:last-child td { + border-bottom: none; +} + +.total-row td { + border-top: 2px solid var(--theme_g2); + font-weight: 600; + color: var(--theme_bright); +} + +/* status badges ------------------------------------------------------------ */ + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.status-badge.active, +.status-badge.success { + background: color-mix(in srgb, var(--theme_ok) 15%, transparent); + color: var(--theme_ok); +} + +.status-badge.inactive { + background: color-mix(in srgb, var(--theme_g1) 15%, transparent); + color: var(--theme_g1); +} + +.status-badge.failure { + background: color-mix(in srgb, var(--theme_fail) 15%, transparent); + color: var(--theme_fail); +} + +/* health dots -------------------------------------------------------------- */ + +.health-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--theme_g1); +} + +.health-green { + background: var(--theme_ok); +} + +.health-yellow { + background: var(--theme_warn); +} + +.health-red { + background: var(--theme_fail); +} + +/* inline progress bar ------------------------------------------------------ */ + +.progress-bar { + width: 100%; + height: 8px; + background: var(--theme_border_subtle); + border-radius: 4px; + overflow: hidden; + margin-top: 8px; +} + +.progress-fill { + height: 100%; + background: var(--theme_p0); + transition: width 0.3s ease; +} + +/* stats row (label + value pair) ------------------------------------------- */ + +.stats-row { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--theme_border_subtle); +} + +.stats-row:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.stats-label { + color: var(--theme_g1); + font-size: 13px; +} + +.stats-value { + color: var(--theme_bright); + font-weight: 600; + font-size: 13px; +} + +/* detail tag (inline badge) ------------------------------------------------ */ + +.detail-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + background: var(--theme_border_subtle); + color: var(--theme_g0); + font-size: 11px; + margin: 2px 4px 2px 0; +} + +/* timestamp ---------------------------------------------------------------- */ + +.timestamp { + font-size: 12px; + color: var(--theme_faint); +} + +/* inline error ------------------------------------------------------------- */ + +.error { + color: var(--theme_fail); + padding: 12px; + background: var(--theme_er); + border-radius: 6px; + margin: 20px 0; + font-size: 13px; +} + +/* empty state -------------------------------------------------------------- */ + +.empty-state { + color: var(--theme_faint); + font-size: 13px; + padding: 20px 0; + text-align: center; +} + +/* header layout ------------------------------------------------------------ */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +/* history tabs ------------------------------------------------------------- */ + +.history-tabs { + display: flex; + gap: 4px; + background: var(--theme_g4); + border-radius: 6px; + padding: 2px; +} + +.history-tab { + background: transparent; + color: var(--theme_g1); + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.history-tab:hover { + color: var(--theme_g0); +} + +.history-tab.active { + background: var(--theme_g2); + color: var(--theme_bright); +} diff --git a/src/zenserver/hub/zenhubserver.cpp b/src/zenserver/hub/zenhubserver.cpp index c63c618df..c6d2dc8d4 100644 --- a/src/zenserver/hub/zenhubserver.cpp +++ b/src/zenserver/hub/zenhubserver.cpp @@ -115,6 +115,8 @@ ZenHubServer::Cleanup() m_IoRunner.join(); } + ShutdownServices(); + if (m_Http) { m_Http->Close(); diff --git a/src/zenserver/sessions/httpsessions.cpp b/src/zenserver/sessions/httpsessions.cpp new file mode 100644 index 000000000..05be3c814 --- /dev/null +++ b/src/zenserver/sessions/httpsessions.cpp @@ -0,0 +1,264 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "httpsessions.h" + +#include <zencore/compactbinarybuilder.h> +#include <zencore/compactbinaryvalidation.h> +#include <zencore/fmtutils.h> +#include <zencore/logging.h> +#include <zencore/trace.h> +#include "sessions.h" + +namespace zen { +using namespace std::literals; + +HttpSessionsService::HttpSessionsService(HttpStatusService& StatusService, HttpStatsService& StatsService, SessionsService& Sessions) +: m_Log(logging::Get("sessions")) +, m_StatusService(StatusService) +, m_StatsService(StatsService) +, m_Sessions(Sessions) +{ + Initialize(); +} + +HttpSessionsService::~HttpSessionsService() +{ + m_StatsService.UnregisterHandler("sessions", *this); + m_StatusService.UnregisterHandler("sessions", *this); +} + +const char* +HttpSessionsService::BaseUri() const +{ + return "/sessions/"; +} + +void +HttpSessionsService::HandleRequest(HttpServerRequest& Request) +{ + metrics::OperationTiming::Scope $(m_HttpRequests); + + if (m_Router.HandleRequest(Request) == false) + { + ZEN_WARN("No route found for {0}", Request.RelativeUri()); + return Request.WriteResponse(HttpResponseCode::NotFound, HttpContentType::kText, "Not found"sv); + } +} + +CbObject +HttpSessionsService::CollectStats() +{ + ZEN_TRACE_CPU("SessionsService::Stats"); + CbObjectWriter Cbo; + + EmitSnapshot("requests", m_HttpRequests, Cbo); + + Cbo.BeginObject("sessions"); + { + Cbo << "readcount" << m_SessionsStats.SessionReadCount; + Cbo << "writecount" << m_SessionsStats.SessionWriteCount; + Cbo << "deletecount" << m_SessionsStats.SessionDeleteCount; + Cbo << "listcount" << m_SessionsStats.SessionListCount; + Cbo << "requestcount" << m_SessionsStats.RequestCount; + Cbo << "badrequestcount" << m_SessionsStats.BadRequestCount; + Cbo << "count" << m_Sessions.GetSessionCount(); + } + Cbo.EndObject(); + + return Cbo.Save(); +} + +void +HttpSessionsService::HandleStatsRequest(HttpServerRequest& HttpReq) +{ + HttpReq.WriteResponse(HttpResponseCode::OK, CollectStats()); +} + +void +HttpSessionsService::HandleStatusRequest(HttpServerRequest& Request) +{ + ZEN_TRACE_CPU("HttpSessionsService::Status"); + CbObjectWriter Cbo; + Cbo << "ok" << true; + Request.WriteResponse(HttpResponseCode::OK, Cbo.Save()); +} + +void +HttpSessionsService::Initialize() +{ + using namespace std::literals; + + ZEN_INFO("Initializing Sessions Service"); + + static constexpr AsciiSet ValidHexCharactersSet{"0123456789abcdefABCDEF"}; + + m_Router.AddMatcher("session_id", [](std::string_view Str) -> bool { + return Str.length() == Oid::StringLength && AsciiSet::HasOnly(Str, ValidHexCharactersSet); + }); + + m_Router.RegisterRoute( + "list", + [this](HttpRouterRequest& Req) { ListSessionsRequest(Req); }, + HttpVerb::kGet); + + m_Router.RegisterRoute( + "{session_id}", + [this](HttpRouterRequest& Req) { SessionRequest(Req); }, + HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete); + + m_Router.RegisterRoute( + "", + [this](HttpRouterRequest& Req) { ListSessionsRequest(Req); }, + HttpVerb::kGet); + + m_StatsService.RegisterHandler("sessions", *this); + m_StatusService.RegisterHandler("sessions", *this); +} + +static void +WriteSessionInfo(CbWriter& Writer, const SessionsService::SessionInfo& Info) +{ + Writer << "id" << Info.Id; + if (!Info.AppName.empty()) + { + Writer << "appname" << Info.AppName; + } + if (Info.JobId != Oid::Zero) + { + Writer << "jobid" << Info.JobId; + } + Writer << "created_at" << Info.CreatedAt; + Writer << "updated_at" << Info.UpdatedAt; + + if (Info.Metadata.GetSize() > 0) + { + Writer.BeginObject("metadata"); + for (const CbField& Field : Info.Metadata) + { + Writer.AddField(Field); + } + Writer.EndObject(); + } +} + +void +HttpSessionsService::ListSessionsRequest(HttpRouterRequest& Req) +{ + HttpServerRequest& ServerRequest = Req.ServerRequest(); + + m_SessionsStats.SessionListCount++; + m_SessionsStats.RequestCount++; + + std::vector<Ref<SessionsService::Session>> Sessions = m_Sessions.GetSessions(); + + CbObjectWriter Response; + Response.BeginArray("sessions"); + for (const Ref<SessionsService::Session>& Session : Sessions) + { + Response.BeginObject(); + { + WriteSessionInfo(Response, Session->Info()); + } + Response.EndObject(); + } + Response.EndArray(); + + return ServerRequest.WriteResponse(HttpResponseCode::OK, Response.Save()); +} + +void +HttpSessionsService::SessionRequest(HttpRouterRequest& Req) +{ + HttpServerRequest& ServerRequest = Req.ServerRequest(); + + const Oid SessionId = Oid::TryFromHexString(Req.GetCapture(1)); + if (SessionId == Oid::Zero) + { + m_SessionsStats.BadRequestCount++; + return ServerRequest.WriteResponse(HttpResponseCode::BadRequest, + HttpContentType::kText, + fmt::format("Invalid session id '{}'", Req.GetCapture(1))); + } + + m_SessionsStats.RequestCount++; + + switch (ServerRequest.RequestVerb()) + { + case HttpVerb::kPost: + case HttpVerb::kPut: + { + IoBuffer Payload = ServerRequest.ReadPayload(); + CbObject RequestObject; + + if (Payload.GetSize() > 0) + { + if (CbValidateError ValidationResult = ValidateCompactBinary(Payload.GetView(), CbValidateMode::All); + ValidationResult != CbValidateError::None) + { + m_SessionsStats.BadRequestCount++; + return ServerRequest.WriteResponse(HttpResponseCode::BadRequest, + HttpContentType::kText, + fmt::format("Invalid payload: {}", zen::ToString(ValidationResult))); + } + RequestObject = LoadCompactBinaryObject(Payload); + } + + if (ServerRequest.RequestVerb() == HttpVerb::kPost) + { + std::string AppName(RequestObject["appname"sv].AsString()); + Oid JobId = RequestObject["jobid"sv].AsObjectId(); + CbObjectView MetadataView = RequestObject["metadata"sv].AsObjectView(); + + m_SessionsStats.SessionWriteCount++; + if (m_Sessions.RegisterSession(SessionId, std::move(AppName), JobId, MetadataView)) + { + return ServerRequest.WriteResponse(HttpResponseCode::Created, HttpContentType::kText, fmt::format("{}", SessionId)); + } + else + { + // Already exists - try update instead + if (m_Sessions.UpdateSession(SessionId, MetadataView)) + { + return ServerRequest.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, fmt::format("{}", SessionId)); + } + return ServerRequest.WriteResponse(HttpResponseCode::InternalServerError); + } + } + else + { + // PUT - update only + m_SessionsStats.SessionWriteCount++; + if (m_Sessions.UpdateSession(SessionId, RequestObject["metadata"sv].AsObjectView())) + { + return ServerRequest.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, fmt::format("{}", SessionId)); + } + return ServerRequest.WriteResponse(HttpResponseCode::NotFound, + HttpContentType::kText, + fmt::format("Session '{}' not found", SessionId)); + } + } + case HttpVerb::kGet: + { + m_SessionsStats.SessionReadCount++; + Ref<SessionsService::Session> Session = m_Sessions.GetSession(SessionId); + if (Session) + { + CbObjectWriter Response; + WriteSessionInfo(Response, Session->Info()); + return ServerRequest.WriteResponse(HttpResponseCode::OK, Response.Save()); + } + return ServerRequest.WriteResponse(HttpResponseCode::NotFound); + } + case HttpVerb::kDelete: + { + m_SessionsStats.SessionDeleteCount++; + if (m_Sessions.RemoveSession(SessionId)) + { + return ServerRequest.WriteResponse(HttpResponseCode::OK); + } + return ServerRequest.WriteResponse(HttpResponseCode::NotFound); + } + } +} + +} // namespace zen diff --git a/src/zenserver/sessions/httpsessions.h b/src/zenserver/sessions/httpsessions.h new file mode 100644 index 000000000..e07f3b59b --- /dev/null +++ b/src/zenserver/sessions/httpsessions.h @@ -0,0 +1,55 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zenhttp/httpserver.h> +#include <zenhttp/httpstats.h> +#include <zenhttp/httpstatus.h> +#include <zentelemetry/stats.h> + +namespace zen { + +class SessionsService; + +class HttpSessionsService final : public HttpService, public IHttpStatusProvider, public IHttpStatsProvider +{ +public: + HttpSessionsService(HttpStatusService& StatusService, HttpStatsService& StatsService, SessionsService& Sessions); + virtual ~HttpSessionsService(); + + virtual const char* BaseUri() const override; + virtual void HandleRequest(HttpServerRequest& Request) override; + + virtual CbObject CollectStats() override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual void HandleStatusRequest(HttpServerRequest& Request) override; + +private: + struct SessionsStats + { + std::atomic_uint64_t SessionReadCount{}; + std::atomic_uint64_t SessionWriteCount{}; + std::atomic_uint64_t SessionDeleteCount{}; + std::atomic_uint64_t SessionListCount{}; + std::atomic_uint64_t RequestCount{}; + std::atomic_uint64_t BadRequestCount{}; + }; + + inline LoggerRef Log() { return m_Log; } + + LoggerRef m_Log; + + void Initialize(); + + void ListSessionsRequest(HttpRouterRequest& Req); + void SessionRequest(HttpRouterRequest& Req); + + HttpStatusService& m_StatusService; + HttpStatsService& m_StatsService; + HttpRequestRouter m_Router; + SessionsService& m_Sessions; + SessionsStats m_SessionsStats; + metrics::OperationTiming m_HttpRequests; +}; + +} // namespace zen diff --git a/src/zenserver/sessions/sessions.cpp b/src/zenserver/sessions/sessions.cpp new file mode 100644 index 000000000..f73aa40ff --- /dev/null +++ b/src/zenserver/sessions/sessions.cpp @@ -0,0 +1,150 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "sessions.h" + +#include <zencore/basicfile.h> +#include <zencore/fmtutils.h> +#include <zencore/logging.h> + +namespace zen { +using namespace std::literals; + +class SessionLog : public TRefCounted<SessionLog> +{ +public: + SessionLog(std::filesystem::path LogFilePath) { m_LogFile.Open(LogFilePath, BasicFile::Mode::kWrite); } + +private: + BasicFile m_LogFile; +}; + +class SessionLogStore +{ +public: + SessionLogStore(std::filesystem::path StoragePath) : m_StoragePath(std::move(StoragePath)) {} + + ~SessionLogStore() = default; + + Ref<SessionLog> GetLogForSession(const Oid& SessionId) + { + // For now, just return a new log for each session. We can implement actual log storage and retrieval later. + return Ref(new SessionLog(m_StoragePath / (SessionId.ToString() + ".log"))); + } + + Ref<SessionLog> CreateLogForSession(const Oid& SessionId) + { + // For now, just return a new log for each session. We can implement actual log storage and retrieval later. + return Ref(new SessionLog(m_StoragePath / (SessionId.ToString() + ".log"))); + } + +private: + std::filesystem::path m_StoragePath; +}; + +SessionsService::Session::Session(const SessionInfo& Info) : m_Info(Info) +{ +} +SessionsService::Session::~Session() = default; + +////////////////////////////////////////////////////////////////////////// + +SessionsService::SessionsService() : m_Log(logging::Get("sessions")) +{ +} + +SessionsService::~SessionsService() = default; + +bool +SessionsService::RegisterSession(const Oid& SessionId, std::string AppName, const Oid& JobId, CbObjectView Metadata) +{ + RwLock::ExclusiveLockScope Lock(m_Lock); + + if (m_Sessions.contains(SessionId)) + { + return false; + } + + const DateTime Now = DateTime::Now(); + m_Sessions.emplace(SessionId, + Ref(new Session(SessionInfo{.Id = SessionId, + .AppName = std::move(AppName), + .JobId = JobId, + .Metadata = CbObject::Clone(Metadata), + .CreatedAt = Now, + .UpdatedAt = Now}))); + + ZEN_INFO("Session {} registered (AppName: {}, JobId: {})", SessionId, AppName, JobId); + return true; +} + +bool +SessionsService::UpdateSession(const Oid& SessionId, CbObjectView Metadata) +{ + RwLock::ExclusiveLockScope Lock(m_Lock); + + auto It = m_Sessions.find(SessionId); + if (It == m_Sessions.end()) + { + return false; + } + + It.value()->UpdateMetadata(Metadata); + + const SessionInfo& Info = It.value()->Info(); + ZEN_DEBUG("Session {} updated (AppName: {}, JobId: {})", SessionId, Info.AppName, Info.JobId); + return true; +} + +Ref<SessionsService::Session> +SessionsService::GetSession(const Oid& SessionId) const +{ + RwLock::SharedLockScope Lock(m_Lock); + + auto It = m_Sessions.find(SessionId); + if (It == m_Sessions.end()) + { + return {}; + } + + return It->second; +} + +std::vector<Ref<SessionsService::Session>> +SessionsService::GetSessions() const +{ + RwLock::SharedLockScope Lock(m_Lock); + + std::vector<Ref<Session>> Result; + Result.reserve(m_Sessions.size()); + for (const auto& [Id, SessionRef] : m_Sessions) + { + Result.push_back(SessionRef); + } + return Result; +} + +bool +SessionsService::RemoveSession(const Oid& SessionId) +{ + RwLock::ExclusiveLockScope Lock(m_Lock); + + auto It = m_Sessions.find(SessionId); + if (It == m_Sessions.end()) + { + return false; + } + + ZEN_INFO("Session {} removed (AppName: {}, JobId: {})", SessionId, It.value()->Info().AppName, It.value()->Info().JobId); + + m_Sessions.erase(It); + return true; +} + +uint64_t +SessionsService::GetSessionCount() const +{ + RwLock::SharedLockScope Lock(m_Lock); + return m_Sessions.size(); +} + +} // namespace zen diff --git a/src/zenserver/sessions/sessions.h b/src/zenserver/sessions/sessions.h new file mode 100644 index 000000000..db9704430 --- /dev/null +++ b/src/zenserver/sessions/sessions.h @@ -0,0 +1,83 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include <zencore/compactbinary.h> +#include <zencore/logbase.h> +#include <zencore/thread.h> +#include <zencore/uid.h> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <tsl/robin_map.h> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <optional> +#include <string> +#include <vector> + +namespace zen { + +class SessionLogStore; +class SessionLog; + +/** Session tracker + * + * Acts as a log and session info concentrator when dealing with multiple + * servers and external processes acting as a group. + */ + +class SessionsService +{ +public: + struct SessionInfo + { + Oid Id; + std::string AppName; + Oid JobId; + CbObject Metadata; + DateTime CreatedAt; + DateTime UpdatedAt; + }; + + class Session : public TRefCounted<Session> + { + public: + Session(const SessionInfo& Info); + ~Session(); + + Session(Session&&) = delete; + Session& operator=(Session&&) = delete; + + const SessionInfo& Info() const { return m_Info; } + void UpdateMetadata(CbObjectView Metadata) + { + // Should this be additive rather than replacing the whole thing? We'll see. + m_Info.Metadata = CbObject::Clone(Metadata); + m_Info.UpdatedAt = DateTime::Now(); + } + + private: + SessionInfo m_Info; + Ref<SessionLog> m_Log; + }; + + SessionsService(); + ~SessionsService(); + + bool RegisterSession(const Oid& SessionId, std::string AppName, const Oid& JobId, CbObjectView Metadata); + bool UpdateSession(const Oid& SessionId, CbObjectView Metadata); + Ref<Session> GetSession(const Oid& SessionId) const; + std::vector<Ref<Session>> GetSessions() const; + bool RemoveSession(const Oid& SessionId); + uint64_t GetSessionCount() const; + +private: + LoggerRef& Log() { return m_Log; } + + LoggerRef m_Log; + mutable RwLock m_Lock; + tsl::robin_map<Oid, Ref<Session>, Oid::Hasher> m_Sessions; + std::unique_ptr<SessionLogStore> m_SessionLogs; +}; + +} // namespace zen diff --git a/src/zenserver/storage/buildstore/httpbuildstore.cpp b/src/zenserver/storage/buildstore/httpbuildstore.cpp index 38d97765b..de9589078 100644 --- a/src/zenserver/storage/buildstore/httpbuildstore.cpp +++ b/src/zenserver/storage/buildstore/httpbuildstore.cpp @@ -605,8 +605,8 @@ HttpBuildStoreService::BlobsExistsRequest(HttpRouterRequest& Req) return ServerRequest.WriteResponse(HttpResponseCode::OK, ResponseObject); } -void -HttpBuildStoreService::HandleStatsRequest(HttpServerRequest& Request) +CbObject +HttpBuildStoreService::CollectStats() { ZEN_TRACE_CPU("HttpBuildStoreService::Stats"); @@ -660,7 +660,13 @@ HttpBuildStoreService::HandleStatsRequest(HttpServerRequest& Request) } Cbo.EndObject(); - return Request.WriteResponse(HttpResponseCode::OK, Cbo.Save()); + return Cbo.Save(); +} + +void +HttpBuildStoreService::HandleStatsRequest(HttpServerRequest& Request) +{ + Request.WriteResponse(HttpResponseCode::OK, CollectStats()); } void diff --git a/src/zenserver/storage/buildstore/httpbuildstore.h b/src/zenserver/storage/buildstore/httpbuildstore.h index 5fa7cd642..2a09b71cf 100644 --- a/src/zenserver/storage/buildstore/httpbuildstore.h +++ b/src/zenserver/storage/buildstore/httpbuildstore.h @@ -22,8 +22,9 @@ public: virtual const char* BaseUri() const override; virtual void HandleRequest(zen::HttpServerRequest& Request) override; - virtual void HandleStatsRequest(HttpServerRequest& Request) override; - virtual void HandleStatusRequest(HttpServerRequest& Request) override; + virtual CbObject CollectStats() override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual void HandleStatusRequest(HttpServerRequest& Request) override; private: struct BuildStoreStats diff --git a/src/zenserver/storage/cache/httpstructuredcache.cpp b/src/zenserver/storage/cache/httpstructuredcache.cpp index 00151f79e..06b8f6c27 100644 --- a/src/zenserver/storage/cache/httpstructuredcache.cpp +++ b/src/zenserver/storage/cache/httpstructuredcache.cpp @@ -1827,8 +1827,8 @@ HttpStructuredCacheService::HandleRpcRequest(HttpServerRequest& Request, std::st } } -void -HttpStructuredCacheService::HandleStatsRequest(HttpServerRequest& Request) +CbObject +HttpStructuredCacheService::CollectStats() { ZEN_MEMSCOPE(GetCacheHttpTag()); @@ -1858,13 +1858,132 @@ HttpStructuredCacheService::HandleStatsRequest(HttpServerRequest& Request) const CidStoreSize CidSize = m_CidStore.TotalSize(); const CacheStoreSize CacheSize = m_CacheStore.TotalSize(); + Cbo.BeginObject("cache"); + { + Cbo << "badrequestcount" << BadRequestCount; + Cbo.BeginObject("rpc"); + Cbo << "count" << RpcRequests; + Cbo << "ops" << RpcRecordBatchRequests + RpcValueBatchRequests + RpcChunkBatchRequests; + Cbo.BeginObject("records"); + Cbo << "count" << RpcRecordRequests; + Cbo << "ops" << RpcRecordBatchRequests; + Cbo.EndObject(); + Cbo.BeginObject("values"); + Cbo << "count" << RpcValueRequests; + Cbo << "ops" << RpcValueBatchRequests; + Cbo.EndObject(); + Cbo.BeginObject("chunks"); + Cbo << "count" << RpcChunkRequests; + Cbo << "ops" << RpcChunkBatchRequests; + Cbo.EndObject(); + Cbo.EndObject(); + + Cbo.BeginObject("size"); + { + Cbo << "disk" << CacheSize.DiskSize; + Cbo << "memory" << CacheSize.MemorySize; + } + Cbo.EndObject(); + + Cbo << "hits" << HitCount << "misses" << MissCount << "writes" << WriteCount; + Cbo << "hit_ratio" << (TotalCount > 0 ? (double(HitCount) / double(TotalCount)) : 0.0); + + if (m_UpstreamCache.IsActive()) + { + Cbo << "upstream_ratio" << (HitCount > 0 ? (double(UpstreamHitCount) / double(HitCount)) : 0.0); + Cbo << "upstream_hits" << m_CacheStats.UpstreamHitCount; + Cbo << "upstream_ratio" << (HitCount > 0 ? (double(UpstreamHitCount) / double(HitCount)) : 0.0); + Cbo << "upstream_ratio" << (HitCount > 0 ? (double(UpstreamHitCount) / double(HitCount)) : 0.0); + } + + Cbo << "cidhits" << ChunkHitCount << "cidmisses" << ChunkMissCount << "cidwrites" << ChunkWriteCount; + + { + ZenCacheStore::CacheStoreStats StoreStatsData = m_CacheStore.Stats(); + Cbo.BeginObject("store"); + Cbo << "hits" << StoreStatsData.HitCount << "misses" << StoreStatsData.MissCount << "writes" << StoreStatsData.WriteCount + << "rejected_writes" << StoreStatsData.RejectedWriteCount << "rejected_reads" << StoreStatsData.RejectedReadCount; + const uint64_t StoreTotal = StoreStatsData.HitCount + StoreStatsData.MissCount; + Cbo << "hit_ratio" << (StoreTotal > 0 ? (double(StoreStatsData.HitCount) / double(StoreTotal)) : 0.0); + EmitSnapshot("read", StoreStatsData.GetOps, Cbo); + EmitSnapshot("write", StoreStatsData.PutOps, Cbo); + Cbo.EndObject(); + } + } + Cbo.EndObject(); + + if (m_UpstreamCache.IsActive()) + { + EmitSnapshot("upstream_gets", m_UpstreamGetRequestTiming, Cbo); + Cbo.BeginObject("upstream"); + { + m_UpstreamCache.GetStatus(Cbo); + } + Cbo.EndObject(); + } + + Cbo.BeginObject("cid"); + { + Cbo.BeginObject("size"); + { + Cbo << "tiny" << CidSize.TinySize; + Cbo << "small" << CidSize.SmallSize; + Cbo << "large" << CidSize.LargeSize; + Cbo << "total" << CidSize.TotalSize; + } + Cbo.EndObject(); + } + Cbo.EndObject(); + + return Cbo.Save(); +} + +void +HttpStructuredCacheService::HandleStatsRequest(HttpServerRequest& Request) +{ + ZEN_MEMSCOPE(GetCacheHttpTag()); + bool ShowCidStoreStats = Request.GetQueryParams().GetValue("cidstorestats") == "true"; bool ShowCacheStoreStats = Request.GetQueryParams().GetValue("cachestorestats") == "true"; - CidStoreStats CidStoreStats = {}; + if (!ShowCidStoreStats && !ShowCacheStoreStats) + { + Request.WriteResponse(HttpResponseCode::OK, CollectStats()); + return; + } + + // Full stats with optional detailed store/cid breakdowns + + CbObjectWriter Cbo; + + EmitSnapshot("requests", m_HttpRequests, Cbo); + + const uint64_t HitCount = m_CacheStats.HitCount; + const uint64_t UpstreamHitCount = m_CacheStats.UpstreamHitCount; + const uint64_t MissCount = m_CacheStats.MissCount; + const uint64_t WriteCount = m_CacheStats.WriteCount; + const uint64_t BadRequestCount = m_CacheStats.BadRequestCount; + struct CidStoreStats StoreStats = m_CidStore.Stats(); + const uint64_t ChunkHitCount = StoreStats.HitCount; + const uint64_t ChunkMissCount = StoreStats.MissCount; + const uint64_t ChunkWriteCount = StoreStats.WriteCount; + const uint64_t TotalCount = HitCount + MissCount; + + const uint64_t RpcRequests = m_CacheStats.RpcRequests; + const uint64_t RpcRecordRequests = m_CacheStats.RpcRecordRequests; + const uint64_t RpcRecordBatchRequests = m_CacheStats.RpcRecordBatchRequests; + const uint64_t RpcValueRequests = m_CacheStats.RpcValueRequests; + const uint64_t RpcValueBatchRequests = m_CacheStats.RpcValueBatchRequests; + const uint64_t RpcChunkRequests = m_CacheStats.RpcChunkRequests; + const uint64_t RpcChunkBatchRequests = m_CacheStats.RpcChunkBatchRequests; + + const CidStoreSize CidSize = m_CidStore.TotalSize(); + const CacheStoreSize CacheSize = m_CacheStore.TotalSize(); + + CidStoreStats DetailedCidStoreStats = {}; if (ShowCidStoreStats) { - CidStoreStats = m_CidStore.Stats(); + DetailedCidStoreStats = m_CidStore.Stats(); } ZenCacheStore::CacheStoreStats CacheStoreStats = {}; if (ShowCacheStoreStats) @@ -2002,8 +2121,8 @@ HttpStructuredCacheService::HandleStatsRequest(HttpServerRequest& Request) } Cbo.EndObject(); } - Cbo.EndObject(); } + Cbo.EndObject(); if (m_UpstreamCache.IsActive()) { @@ -2029,10 +2148,10 @@ HttpStructuredCacheService::HandleStatsRequest(HttpServerRequest& Request) if (ShowCidStoreStats) { Cbo.BeginObject("store"); - Cbo << "hits" << CidStoreStats.HitCount << "misses" << CidStoreStats.MissCount << "writes" << CidStoreStats.WriteCount; - EmitSnapshot("read", CidStoreStats.FindChunkOps, Cbo); - EmitSnapshot("write", CidStoreStats.AddChunkOps, Cbo); - // EmitSnapshot("exists", CidStoreStats.ContainChunkOps, Cbo); + Cbo << "hits" << DetailedCidStoreStats.HitCount << "misses" << DetailedCidStoreStats.MissCount << "writes" + << DetailedCidStoreStats.WriteCount; + EmitSnapshot("read", DetailedCidStoreStats.FindChunkOps, Cbo); + EmitSnapshot("write", DetailedCidStoreStats.AddChunkOps, Cbo); Cbo.EndObject(); } } diff --git a/src/zenserver/storage/cache/httpstructuredcache.h b/src/zenserver/storage/cache/httpstructuredcache.h index 5a795c215..d462415d4 100644 --- a/src/zenserver/storage/cache/httpstructuredcache.h +++ b/src/zenserver/storage/cache/httpstructuredcache.h @@ -102,11 +102,12 @@ private: void HandleRpcRequest(HttpServerRequest& Request, std::string_view UriNamespace); void HandleDetailsRequest(HttpServerRequest& Request); - void HandleCacheRequest(HttpServerRequest& Request); - void HandleCacheNamespaceRequest(HttpServerRequest& Request, std::string_view Namespace); - void HandleCacheBucketRequest(HttpServerRequest& Request, std::string_view Namespace, std::string_view Bucket); - virtual void HandleStatsRequest(HttpServerRequest& Request) override; - virtual void HandleStatusRequest(HttpServerRequest& Request) override; + void HandleCacheRequest(HttpServerRequest& Request); + void HandleCacheNamespaceRequest(HttpServerRequest& Request, std::string_view Namespace); + void HandleCacheBucketRequest(HttpServerRequest& Request, std::string_view Namespace, std::string_view Bucket); + virtual CbObject CollectStats() override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual void HandleStatusRequest(HttpServerRequest& Request) override; bool AreDiskWritesAllowed() const; diff --git a/src/zenserver/storage/projectstore/httpprojectstore.cpp b/src/zenserver/storage/projectstore/httpprojectstore.cpp index 0ec6faea3..3631ee1c5 100644 --- a/src/zenserver/storage/projectstore/httpprojectstore.cpp +++ b/src/zenserver/storage/projectstore/httpprojectstore.cpp @@ -831,8 +831,8 @@ HttpProjectService::HandleRequest(HttpServerRequest& Request) } } -void -HttpProjectService::HandleStatsRequest(HttpServerRequest& HttpReq) +CbObject +HttpProjectService::CollectStats() { ZEN_TRACE_CPU("ProjectService::Stats"); @@ -898,7 +898,13 @@ HttpProjectService::HandleStatsRequest(HttpServerRequest& HttpReq) } Cbo.EndObject(); - return HttpReq.WriteResponse(HttpResponseCode::OK, Cbo.Save()); + return Cbo.Save(); +} + +void +HttpProjectService::HandleStatsRequest(HttpServerRequest& HttpReq) +{ + HttpReq.WriteResponse(HttpResponseCode::OK, CollectStats()); } void diff --git a/src/zenserver/storage/projectstore/httpprojectstore.h b/src/zenserver/storage/projectstore/httpprojectstore.h index b742102a5..026ac32fa 100644 --- a/src/zenserver/storage/projectstore/httpprojectstore.h +++ b/src/zenserver/storage/projectstore/httpprojectstore.h @@ -50,8 +50,9 @@ public: virtual const char* BaseUri() const override; virtual void HandleRequest(HttpServerRequest& Request) override; - virtual void HandleStatsRequest(HttpServerRequest& Request) override; - virtual void HandleStatusRequest(HttpServerRequest& Request) override; + virtual CbObject CollectStats() override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual void HandleStatusRequest(HttpServerRequest& Request) override; private: struct ProjectStats diff --git a/src/zenserver/storage/workspaces/httpworkspaces.cpp b/src/zenserver/storage/workspaces/httpworkspaces.cpp index dc4cc7e69..785dd62f0 100644 --- a/src/zenserver/storage/workspaces/httpworkspaces.cpp +++ b/src/zenserver/storage/workspaces/httpworkspaces.cpp @@ -110,8 +110,8 @@ HttpWorkspacesService::HandleRequest(HttpServerRequest& Request) } } -void -HttpWorkspacesService::HandleStatsRequest(HttpServerRequest& HttpReq) +CbObject +HttpWorkspacesService::CollectStats() { ZEN_TRACE_CPU("WorkspacesService::Stats"); CbObjectWriter Cbo; @@ -150,7 +150,13 @@ HttpWorkspacesService::HandleStatsRequest(HttpServerRequest& HttpReq) } Cbo.EndObject(); - return HttpReq.WriteResponse(HttpResponseCode::OK, Cbo.Save()); + return Cbo.Save(); +} + +void +HttpWorkspacesService::HandleStatsRequest(HttpServerRequest& HttpReq) +{ + HttpReq.WriteResponse(HttpResponseCode::OK, CollectStats()); } void diff --git a/src/zenserver/storage/workspaces/httpworkspaces.h b/src/zenserver/storage/workspaces/httpworkspaces.h index 888a34b4d..7c5ddeff1 100644 --- a/src/zenserver/storage/workspaces/httpworkspaces.h +++ b/src/zenserver/storage/workspaces/httpworkspaces.h @@ -29,8 +29,9 @@ public: virtual const char* BaseUri() const override; virtual void HandleRequest(HttpServerRequest& Request) override; - virtual void HandleStatsRequest(HttpServerRequest& Request) override; - virtual void HandleStatusRequest(HttpServerRequest& Request) override; + virtual CbObject CollectStats() override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual void HandleStatusRequest(HttpServerRequest& Request) override; private: struct WorkspacesStats diff --git a/src/zenserver/storage/zenstorageserver.cpp b/src/zenserver/storage/zenstorageserver.cpp index bca26e87a..af2c0dc81 100644 --- a/src/zenserver/storage/zenstorageserver.cpp +++ b/src/zenserver/storage/zenstorageserver.cpp @@ -33,6 +33,7 @@ #include <zenutil/service.h> #include <zenutil/workerpools.h> #include <zenutil/zenserverprocess.h> +#include "../sessions/sessions.h" #if ZEN_PLATFORM_WINDOWS # include <zencore/windows.h> @@ -133,7 +134,6 @@ void ZenStorageServer::RegisterServices() { m_Http->RegisterService(*m_AuthService); - m_Http->RegisterService(m_StatsService); m_Http->RegisterService(m_TestService); // NOTE: this is intentionally not limited to test mode as it's useful for diagnostics #if ZEN_WITH_TESTS @@ -160,6 +160,11 @@ ZenStorageServer::RegisterServices() m_Http->RegisterService(*m_HttpWorkspacesService); } + if (m_HttpSessionsService) + { + m_Http->RegisterService(*m_HttpSessionsService); + } + m_FrontendService = std::make_unique<HttpFrontendService>(m_ContentRoot, m_StatusService); if (m_FrontendService) @@ -233,6 +238,11 @@ ZenStorageServer::InitializeServices(const ZenStorageServerConfig& ServerOptions *m_Workspaces)); } + { + m_SessionsService = std::make_unique<SessionsService>(); + m_HttpSessionsService = std::make_unique<HttpSessionsService>(m_StatusService, m_StatsService, *m_SessionsService); + } + if (ServerOptions.BuildStoreConfig.Enabled) { CidStoreConfiguration BuildCidConfig; @@ -823,6 +833,8 @@ ZenStorageServer::Cleanup() m_IoRunner.join(); } + ShutdownServices(); + if (m_Http) { m_Http->Close(); @@ -857,6 +869,8 @@ ZenStorageServer::Cleanup() m_UpstreamCache.reset(); m_CacheStore = {}; + m_HttpSessionsService.reset(); + m_SessionsService.reset(); m_HttpWorkspacesService.reset(); m_Workspaces.reset(); m_HttpProjectService.reset(); diff --git a/src/zenserver/storage/zenstorageserver.h b/src/zenserver/storage/zenstorageserver.h index 5b163fc8e..d625f869c 100644 --- a/src/zenserver/storage/zenstorageserver.h +++ b/src/zenserver/storage/zenstorageserver.h @@ -12,6 +12,7 @@ #include <zenstore/gc.h> #include <zenstore/projectstore.h> +#include "../sessions/httpsessions.h" #include "admin/admin.h" #include "buildstore/httpbuildstore.h" #include "cache/httpstructuredcache.h" @@ -62,7 +63,6 @@ private: void InitializeServices(const ZenStorageServerConfig& ServerOptions); void RegisterServices(); - HttpStatsService m_StatsService; std::unique_ptr<JobQueue> m_JobQueue; GcManager m_GcManager; GcScheduler m_GcScheduler{m_GcManager}; @@ -82,6 +82,8 @@ private: std::unique_ptr<HttpProjectService> m_HttpProjectService; std::unique_ptr<Workspaces> m_Workspaces; std::unique_ptr<HttpWorkspacesService> m_HttpWorkspacesService; + std::unique_ptr<SessionsService> m_SessionsService; + std::unique_ptr<HttpSessionsService> m_HttpSessionsService; std::unique_ptr<UpstreamCache> m_UpstreamCache; std::unique_ptr<HttpUpstreamService> m_UpstreamService; std::unique_ptr<HttpStructuredCacheService> m_StructuredCacheService; diff --git a/src/zenserver/zenserver.cpp b/src/zenserver/zenserver.cpp index 5fd35d9b4..bb6b02d21 100644 --- a/src/zenserver/zenserver.cpp +++ b/src/zenserver/zenserver.cpp @@ -46,6 +46,20 @@ ZEN_THIRD_PARTY_INCLUDES_END ////////////////////////////////////////////////////////////////////////// +#ifndef ZEN_WITH_COMPUTE_SERVICES +# define ZEN_WITH_COMPUTE_SERVICES 0 +#endif + +#ifndef ZEN_WITH_HORDE +# define ZEN_WITH_HORDE 0 +#endif + +#ifndef ZEN_WITH_NOMAD +# define ZEN_WITH_NOMAD 0 +#endif + +////////////////////////////////////////////////////////////////////////// + #include "config/config.h" #include "diag/logging.h" @@ -155,6 +169,7 @@ ZenServerBase::Initialize(const ZenServerConfig& ServerOptions, ZenServerState:: m_StatusService.RegisterHandler("status", *this); m_Http->RegisterService(m_StatusService); + m_Http->RegisterService(m_StatsService); m_StatsReporter.Initialize(ServerOptions.StatsConfig); if (ServerOptions.StatsConfig.Enabled) @@ -162,10 +177,37 @@ ZenServerBase::Initialize(const ZenServerConfig& ServerOptions, ZenServerState:: EnqueueStatsReportingTimer(); } - m_HealthService.SetHealthInfo({.DataRoot = ServerOptions.DataDir, - .AbsLogPath = ServerOptions.LoggingConfig.AbsLogFile, - .HttpServerClass = std::string(ServerOptions.HttpConfig.ServerClass), - .BuildVersion = std::string(ZEN_CFG_VERSION_BUILD_STRING_FULL)}); + // clang-format off + HealthServiceInfo HealthInfo { + .DataRoot = ServerOptions.DataDir, + .AbsLogPath = ServerOptions.LoggingConfig.AbsLogFile, + .HttpServerClass = std::string(ServerOptions.HttpConfig.ServerClass), + .BuildVersion = std::string(ZEN_CFG_VERSION_BUILD_STRING_FULL), + .Port = EffectiveBasePort, + .Pid = GetCurrentProcessId(), + .IsDedicated = ServerOptions.IsDedicated, + .StartTimeMs = std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::system_clock::now().time_since_epoch()).count(), + .BuildOptions = { + {"ZEN_ADDRESS_SANITIZER", ZEN_ADDRESS_SANITIZER != 0}, + {"ZEN_USE_SENTRY", ZEN_USE_SENTRY != 0}, + {"ZEN_WITH_TESTS", ZEN_WITH_TESTS != 0}, + {"ZEN_USE_MIMALLOC", ZEN_USE_MIMALLOC != 0}, + {"ZEN_USE_RPMALLOC", ZEN_USE_RPMALLOC != 0}, + {"ZEN_WITH_HTTPSYS", ZEN_WITH_HTTPSYS != 0}, + {"ZEN_WITH_MEMTRACK", ZEN_WITH_MEMTRACK != 0}, + {"ZEN_WITH_TRACE", ZEN_WITH_TRACE != 0}, + {"ZEN_WITH_COMPUTE_SERVICES", ZEN_WITH_COMPUTE_SERVICES != 0}, + {"ZEN_WITH_HORDE", ZEN_WITH_HORDE != 0}, + {"ZEN_WITH_NOMAD", ZEN_WITH_NOMAD != 0}, + }, + .RuntimeConfig = BuildSettingsList(ServerOptions), + }; + // clang-format on + + HealthInfo.RuntimeConfig.emplace(HealthInfo.RuntimeConfig.begin() + 2, "EffectivePort"sv, fmt::to_string(EffectiveBasePort)); + + m_HealthService.SetHealthInfo(std::move(HealthInfo)); LogSettingsSummary(ServerOptions); @@ -175,12 +217,23 @@ ZenServerBase::Initialize(const ZenServerConfig& ServerOptions, ZenServerState:: void ZenServerBase::Finalize() { + m_StatsService.RegisterHandler("http", *m_Http); + + m_Http->SetDefaultRedirect("/dashboard/"); + // Register health service last so if we return "OK" for health it means all services have been properly initialized m_Http->RegisterService(m_HealthService); } void +ZenServerBase::ShutdownServices() +{ + m_StatsService.UnregisterHandler("http", *m_Http); + m_StatsService.Shutdown(); +} + +void ZenServerBase::GetBuildOptions(StringBuilderBase& OutOptions, char Separator) const { ZEN_MEMSCOPE(GetZenserverTag()); @@ -386,22 +439,25 @@ ZenServerBase::CheckSigInt() void ZenServerBase::HandleStatusRequest(HttpServerRequest& Request) { + auto Metrics = m_MetricsTracker.Query(); + CbObjectWriter Cbo; Cbo << "ok" << true; Cbo << "state" << ToString(m_CurrentState); + Cbo << "hostname" << GetMachineName(); + Cbo << "cpuUsagePercent" << Metrics.CpuUsagePercent; Request.WriteResponse(HttpResponseCode::OK, Cbo.Save()); } -void -ZenServerBase::LogSettingsSummary(const ZenServerConfig& ServerConfig) +std::vector<std::pair<std::string_view, std::string>> +ZenServerBase::BuildSettingsList(const ZenServerConfig& ServerConfig) { // clang-format off - std::list<std::pair<std::string_view, std::string>> Settings = { - {"DataDir"sv, fmt::format("{}", ServerConfig.DataDir)}, - {"AbsLogFile"sv, fmt::format("{}", ServerConfig.LoggingConfig.AbsLogFile)}, + std::vector<std::pair<std::string_view, std::string>> Settings = { {"SystemRootDir"sv, fmt::format("{}", ServerConfig.SystemRootDir)}, {"ContentDir"sv, fmt::format("{}", ServerConfig.ContentDir)}, {"BasePort"sv, fmt::to_string(ServerConfig.BasePort)}, + {"CoreLimit"sv, fmt::to_string(ServerConfig.CoreLimit)}, {"IsDebug"sv, fmt::to_string(ServerConfig.IsDebug)}, {"IsCleanStart"sv, fmt::to_string(ServerConfig.IsCleanStart)}, {"IsPowerCycle"sv, fmt::to_string(ServerConfig.IsPowerCycle)}, @@ -409,9 +465,6 @@ ZenServerBase::LogSettingsSummary(const ZenServerConfig& ServerConfig) {"Detach"sv, fmt::to_string(ServerConfig.Detach)}, {"NoConsoleOutput"sv, fmt::to_string(ServerConfig.LoggingConfig.NoConsoleOutput)}, {"QuietConsole"sv, fmt::to_string(ServerConfig.LoggingConfig.QuietConsole)}, - {"CoreLimit"sv, fmt::to_string(ServerConfig.CoreLimit)}, - {"IsDedicated"sv, fmt::to_string(ServerConfig.IsDedicated)}, - {"ShouldCrash"sv, fmt::to_string(ServerConfig.ShouldCrash)}, {"ChildId"sv, ServerConfig.ChildId}, {"LogId"sv, ServerConfig.LoggingConfig.LogId}, {"Sentry DSN"sv, ServerConfig.SentryConfig.Dsn.empty() ? "not set" : ServerConfig.SentryConfig.Dsn}, @@ -423,10 +476,28 @@ ZenServerBase::LogSettingsSummary(const ZenServerConfig& ServerConfig) if (ServerConfig.StatsConfig.Enabled) { - Settings.emplace_back("Statsd Host", ServerConfig.StatsConfig.StatsdHost); - Settings.emplace_back("Statsd Port", fmt::to_string(ServerConfig.StatsConfig.StatsdPort)); + Settings.emplace_back("Statsd Host"sv, ServerConfig.StatsConfig.StatsdHost); + Settings.emplace_back("Statsd Port"sv, fmt::to_string(ServerConfig.StatsConfig.StatsdPort)); } + return Settings; +} + +void +ZenServerBase::LogSettingsSummary(const ZenServerConfig& ServerConfig) +{ + auto Settings = BuildSettingsList(ServerConfig); + + // Log-only entries not needed in RuntimeConfig + // clang-format off + Settings.insert(Settings.begin(), { + {"DataDir"sv, fmt::format("{}", ServerConfig.DataDir)}, + {"AbsLogFile"sv, fmt::format("{}", ServerConfig.LoggingConfig.AbsLogFile)}, + }); + // clang-format on + Settings.emplace_back("IsDedicated"sv, fmt::to_string(ServerConfig.IsDedicated)); + Settings.emplace_back("ShouldCrash"sv, fmt::to_string(ServerConfig.ShouldCrash)); + size_t MaxWidth = 0; for (const auto& Setting : Settings) { @@ -674,6 +745,10 @@ ZenServerMain::Run() RequestApplicationExit(1); } +#if ZEN_USE_SENTRY + Sentry.Close(); +#endif + ShutdownServerLogging(); ReportServiceStatus(ServiceStatus::Stopped); diff --git a/src/zenserver/zenserver.h b/src/zenserver/zenserver.h index 5a8a079c0..c06093f0d 100644 --- a/src/zenserver/zenserver.h +++ b/src/zenserver/zenserver.h @@ -3,11 +3,13 @@ #pragma once #include <zencore/basicfile.h> +#include <zencore/system.h> #include <zenhttp/httpserver.h> #include <zenhttp/httpstats.h> #include <zenhttp/httpstatus.h> #include <zenutil/zenserverprocess.h> +#include <atomic> #include <memory> #include <string_view> #include "config/config.h" @@ -51,8 +53,10 @@ public: protected: int Initialize(const ZenServerConfig& ServerOptions, ZenServerState::ZenServerEntry* ServerEntry); void Finalize(); + void ShutdownServices(); void GetBuildOptions(StringBuilderBase& OutOptions, char Separator = ',') const; - void LogSettingsSummary(const ZenServerConfig& ServerConfig); + static std::vector<std::pair<std::string_view, std::string>> BuildSettingsList(const ZenServerConfig& ServerConfig); + void LogSettingsSummary(const ZenServerConfig& ServerConfig); protected: NamedMutex m_ServerMutex; @@ -73,9 +77,10 @@ protected: kInitializing, kRunning, kShuttingDown - } m_CurrentState = kInitializing; + }; + std::atomic<ServerState> m_CurrentState = kInitializing; - inline void SetNewState(ServerState NewState) { m_CurrentState = NewState; } + inline void SetNewState(ServerState NewState) { m_CurrentState.store(NewState, std::memory_order_relaxed); } static std::string_view ToString(ServerState Value); std::function<void()> m_IsReadyFunc; @@ -88,8 +93,10 @@ protected: std::unique_ptr<IHttpRequestFilter> m_HttpRequestFilter; - HttpHealthService m_HealthService; - HttpStatusService m_StatusService; + HttpHealthService m_HealthService; + HttpStatsService m_StatsService{m_IoContext}; + HttpStatusService m_StatusService; + SystemMetricsTracker m_MetricsTracker; // Stats reporting diff --git a/src/zenstore/cache/cachedisklayer.cpp b/src/zenstore/cache/cachedisklayer.cpp index d53f9f369..4640309d9 100644 --- a/src/zenstore/cache/cachedisklayer.cpp +++ b/src/zenstore/cache/cachedisklayer.cpp @@ -4561,9 +4561,11 @@ ZenCacheDiskLayer::Stats() const ZenCacheDiskLayer::Info ZenCacheDiskLayer::GetInfo() const { - ZenCacheDiskLayer::Info Info = {.RootDir = m_RootDir, .Config = m_Configuration}; + ZenCacheDiskLayer::Info Info; + Info.RootDir = m_RootDir; { RwLock::SharedLockScope _(m_Lock); + Info.Config = m_Configuration; Info.BucketNames.reserve(m_Buckets.size()); for (auto& Kv : m_Buckets) { diff --git a/src/zenstore/projectstore.cpp b/src/zenstore/projectstore.cpp index 1706c9105..03086b473 100644 --- a/src/zenstore/projectstore.cpp +++ b/src/zenstore/projectstore.cpp @@ -4712,6 +4712,13 @@ ProjectStore::GetProjectsList() Response << "ProjectRootDir"sv << PathToUtf8(Prj.ProjectRootDir); Response << "EngineRootDir"sv << PathToUtf8(Prj.EngineRootDir); Response << "ProjectFilePath"sv << PathToUtf8(Prj.ProjectFilePath); + + const auto AccessTime = Prj.LastOplogAccessTime(""sv); + if (AccessTime != GcClock::TimePoint::min()) + { + Response << "LastAccessTime"sv << gsl::narrow<uint64_t>(AccessTime.time_since_epoch().count()); + } + Response.EndObject(); }); Response.EndArray(); diff --git a/src/zentelemetry/include/zentelemetry/stats.h b/src/zentelemetry/include/zentelemetry/stats.h index d58846a3b..260b0fcfb 100644 --- a/src/zentelemetry/include/zentelemetry/stats.h +++ b/src/zentelemetry/include/zentelemetry/stats.h @@ -26,6 +26,7 @@ class Gauge { public: Gauge() : m_Value{0} {} + explicit Gauge(T InitialValue) : m_Value{InitialValue} {} T Value() const { return m_Value; } void SetValue(T Value) { m_Value = Value; } |