aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-09 17:43:08 +0100
committerGitHub Enterprise <[email protected]>2026-03-09 17:43:08 +0100
commitb37b34ea6ad906f54e8104526e77ba66aed997da (patch)
treee80ce17d666aff6d2f0d73d4977128ffb4055476 /src
parentadd fallback for zencache multirange (#816) (diff)
downloadzen-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')
-rw-r--r--src/zen/zen.cpp78
-rw-r--r--src/zen/zen.h37
-rw-r--r--src/zenbase/include/zenbase/refcount.h3
-rw-r--r--src/zencore/include/zencore/logging/tracesink.h4
-rw-r--r--src/zencore/include/zencore/sentryintegration.h1
-rw-r--r--src/zencore/include/zencore/system.h2
-rw-r--r--src/zencore/logging/tracesink.cpp4
-rw-r--r--src/zencore/sentryintegration.cpp19
-rw-r--r--src/zencore/system.cpp60
-rw-r--r--src/zencore/testutils.cpp2
-rw-r--r--src/zenhttp/httpserver.cpp34
-rw-r--r--src/zenhttp/include/zenhttp/httpclient.h9
-rw-r--r--src/zenhttp/include/zenhttp/httpserver.h73
-rw-r--r--src/zenhttp/include/zenhttp/httpstats.h47
-rw-r--r--src/zenhttp/monitoring/httpstats.cpp195
-rw-r--r--src/zenhttp/servers/httpasio.cpp104
-rw-r--r--src/zenhttp/servers/httpplugin.cpp2
-rw-r--r--src/zenhttp/servers/httpsys.cpp113
-rw-r--r--src/zenhttp/servers/wsasio.cpp18
-rw-r--r--src/zenhttp/servers/wsasio.h8
-rw-r--r--src/zenhttp/servers/wshttpsys.cpp23
-rw-r--r--src/zenhttp/servers/wshttpsys.h5
-rw-r--r--src/zenhttp/servers/wstest.cpp3
-rw-r--r--src/zenremotestore/include/zenremotestore/jupiter/jupiterhost.h1
-rw-r--r--src/zenremotestore/projectstore/remoteprojectstore.cpp5
-rw-r--r--src/zenserver/compute/computeserver.cpp4
-rw-r--r--src/zenserver/compute/computeserver.h1
-rw-r--r--src/zenserver/diag/diagsvcs.cpp31
-rw-r--r--src/zenserver/diag/diagsvcs.h15
-rw-r--r--src/zenserver/diag/otlphttp.cpp59
-rw-r--r--src/zenserver/diag/otlphttp.h13
-rw-r--r--src/zenserver/frontend/html.zipbin319315 -> 406051 bytes
-rw-r--r--src/zenserver/frontend/html/banner.js (renamed from src/zenserver/frontend/html/compute/banner.js)59
-rw-r--r--src/zenserver/frontend/html/compute/compute.html327
-rw-r--r--src/zenserver/frontend/html/compute/hub.html154
-rw-r--r--src/zenserver/frontend/html/compute/orchestrator.html205
-rw-r--r--src/zenserver/frontend/html/index.html3
-rw-r--r--src/zenserver/frontend/html/nav.js (renamed from src/zenserver/frontend/html/compute/nav.js)14
-rw-r--r--src/zenserver/frontend/html/pages/cache.js690
-rw-r--r--src/zenserver/frontend/html/pages/compute.js693
-rw-r--r--src/zenserver/frontend/html/pages/entry.js4
-rw-r--r--src/zenserver/frontend/html/pages/hub.js122
-rw-r--r--src/zenserver/frontend/html/pages/info.js261
-rw-r--r--src/zenserver/frontend/html/pages/map.js4
-rw-r--r--src/zenserver/frontend/html/pages/metrics.js232
-rw-r--r--src/zenserver/frontend/html/pages/oplog.js2
-rw-r--r--src/zenserver/frontend/html/pages/orchestrator.js405
-rw-r--r--src/zenserver/frontend/html/pages/page.js69
-rw-r--r--src/zenserver/frontend/html/pages/project.js2
-rw-r--r--src/zenserver/frontend/html/pages/projects.js447
-rw-r--r--src/zenserver/frontend/html/pages/sessions.js61
-rw-r--r--src/zenserver/frontend/html/pages/start.js327
-rw-r--r--src/zenserver/frontend/html/pages/stat.js2
-rw-r--r--src/zenserver/frontend/html/pages/tree.js2
-rw-r--r--src/zenserver/frontend/html/pages/zcache.js8
-rw-r--r--src/zenserver/frontend/html/theme.js116
-rw-r--r--src/zenserver/frontend/html/util/compactbinary.js4
-rw-r--r--src/zenserver/frontend/html/util/friendly.js21
-rw-r--r--src/zenserver/frontend/html/util/widgets.js138
-rw-r--r--src/zenserver/frontend/html/zen.css809
-rw-r--r--src/zenserver/hub/zenhubserver.cpp2
-rw-r--r--src/zenserver/sessions/httpsessions.cpp264
-rw-r--r--src/zenserver/sessions/httpsessions.h55
-rw-r--r--src/zenserver/sessions/sessions.cpp150
-rw-r--r--src/zenserver/sessions/sessions.h83
-rw-r--r--src/zenserver/storage/buildstore/httpbuildstore.cpp12
-rw-r--r--src/zenserver/storage/buildstore/httpbuildstore.h5
-rw-r--r--src/zenserver/storage/cache/httpstructuredcache.cpp137
-rw-r--r--src/zenserver/storage/cache/httpstructuredcache.h11
-rw-r--r--src/zenserver/storage/projectstore/httpprojectstore.cpp12
-rw-r--r--src/zenserver/storage/projectstore/httpprojectstore.h5
-rw-r--r--src/zenserver/storage/workspaces/httpworkspaces.cpp12
-rw-r--r--src/zenserver/storage/workspaces/httpworkspaces.h5
-rw-r--r--src/zenserver/storage/zenstorageserver.cpp16
-rw-r--r--src/zenserver/storage/zenstorageserver.h4
-rw-r--r--src/zenserver/zenserver.cpp103
-rw-r--r--src/zenserver/zenserver.h17
-rw-r--r--src/zenstore/cache/cachedisklayer.cpp4
-rw-r--r--src/zenstore/projectstore.cpp7
-rw-r--r--src/zentelemetry/include/zentelemetry/stats.h1
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
index c167cc70e..84472ff08 100644
--- a/src/zenserver/frontend/html.zip
+++ b/src/zenserver/frontend/html.zip
Binary files differ
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; }